Integrate APIs for showing Bridge transactions and total APK download #2

Merged
nabarun merged 5 commits from ng-show-bridge-txs into main 2025-09-03 02:29:41 +00:00
16 changed files with 923 additions and 561 deletions

View File

@ -1,5 +1,8 @@
# Public endpoint of mtm-to-nym-service
NEXT_PUBLIC_MTM_SERVICE_URL=http://localhost:3000
# Blockchain RPC Endpoints
NEXT_PUBLIC_ETH_RPC_URL=
NEXT_PUBLIC_NYX_RPC_URL=https://rpc.nymtech.net
# NYM chain RPC endpoint
NEXT_PUBLIC_NYX_RPC_URL=https://rpc.nymtech.net
# ETH chain RPC endpoint
ETH_RPC_URL=

View File

@ -1,7 +1,79 @@
# Test Progressive Web App
# MTM VPN Dashboard
This example used [`next-pwa`](https://github.com/shadowwalker/next-pwa) to create a progressive web app (PWA) powered by [Workbox](https://developers.google.com/web/tools/workbox/).
A Next.js dashboard application for monitoring the MTM VPN app.
## Deploy your own
## Build & Deploy
(Under construction)
See [deployment instructions](./deploy/README.md) for detailed deployment steps using the Laconic registry system.
## Prerequisites
- Node.js 20+
- npm
- Access to MTM-to-NYM service API
## Setup
1. **Clone and install dependencies**:
```bash
git clone https://git.vdb.to/cerc-io/mtm-vpn-dashboard.git
cd mtm-vpn-dashboard
npm install
```
1. **Start mtm-to-nym-service**
Follow steps in <https://git.vdb.to/cerc-io/mtm-to-nym-service>
1. **Configure environment variables**:
```bash
cp .env.example .env.local
```
Update the following variables in `.env.local`:
```env
# Public endpoint of mtm-to-nym-service
NEXT_PUBLIC_MTM_SERVICE_URL=http://localhost:3000
# NYM chain RPC endpoint
NEXT_PUBLIC_NYX_RPC_URL=https://rpc.nymtech.net
# ETH chain RPC endpoint (for balance checking)
ETH_RPC_URL=https://eth.rpc.laconic.com/your-api-key
```
1. **Start the development server**:
```bash
npm run dev -- -p 4000
```
1. **Access the application**:
Open <http://localhost:4000> in your browser
## Usage
- Navigate to `/dashboard` for the main overview
- View transactions at `/dashboard/transactions`
- Monitor failed transactions at `/dashboard/failed`
- Check app downloads at `/dashboard/downloads`
## API Integration
The dashboard connects to:
- **MTM Service API**: Transaction and conversion data
- **Gitea API**: App release and download statistics (via proxy to avoid CORS)
- **Blockchain RPCs**: ETH and NYM network data for balance checking
## Project Structure
```
├── components/ # Reusable UI components
├── pages/ # Next.js pages and API routes
│ ├── api/ # API proxy endpoints
│ └── dashboard/ # Dashboard pages
├── utils/ # Utility functions and API clients
├── public/ # Static assets and PWA files
├── styles/ # Global CSS and Tailwind config
└── deploy/ # Deployment configuration
```

View File

@ -1,264 +0,0 @@
import { Transaction, BridgeTransaction, SwapTransaction, AccountBalance, AppDownload, DashboardStats } from '../types';
export const mockTransactions: Transaction[] = [
{
id: '1',
transactionHash: '2AUxZpuQqR7pYyuYcYqwbGHFgJfbvGRJfrGW1D4RSbVeHBprrCoVBb8YEb7uYAiGTL7tGLWYAbaJZjmxetDCpW9o',
fromAddress: 'HmWkGTaLQXzqDUVUThqSksp2gRYupRGKegJ4TZzBgEHi',
nymTransactionHash: '4E169C934D2782EC35DCC1BBB578FF543B081B06E76D8C030B75ACD3EDE590F8',
createdAt: new Date('2025-08-24T10:30:00Z')
},
{
id: '2',
transactionHash: '3bVxNqrQsR8qZzrYdYrxcGHFgKgcwGTKgrHX2E5STdWfICqssEpVCc9ZFc8vZBjHTM8uHLXYBcbKam4yguEidl9P',
fromAddress: 'JkXlRgaQTbzfMcXvRuTyFqPmLdGhGfKjVnBxEzRgQaFi',
nymTransactionHash: '7F259D847F3892FD46EDD2CCC689GG654C192C17F87E9D141C86BDE4FEF701G9',
createdAt: new Date('2025-08-24T09:15:00Z')
},
{
id: '3',
transactionHash: '4CWyOrsTt9rAarZeEasydIGGhKheaHTLhsIY3F6TUeXgJDrtfFqWDd0AhGd9wCkIUN9vIMYZCdcLbm5zhvFjelqQ',
fromAddress: 'MnYmShbRUczgOfYwSvUzGrRnHfHmGgLkWoCzF1ShRbGj',
error: 'insufficient funds: got 100unym, required 1000unym',
createdAt: new Date('2025-08-24T08:45:00Z')
},
{
id: '4',
transactionHash: '5DXzPstUu0rBbsaZcZsycJHIhLjdxIULhsJZ4G7TUfYhJErttGqXEd1BiIe0xDlJVnOvJNZaCeCMcm6ajvHkfmrR',
fromAddress: 'NpZnTicSVd0gPdZyTyU0HsSmMeHnHhMlXpDzG2TicRiJ',
nymTransactionHash: '8G360E958G4903GE47FEE3DDD7901H765D203D28G98F0E252D97CEF5GFG812H0',
createdAt: new Date('2025-08-24T07:20:00Z')
},
{
id: '5',
transactionHash: '6EYqRtuVv1sDbtraBdatdKIJiMkeyJVMitKa5H8UVgZiKFsuuHrYFe2CjJf1yEkKWoMaKOZbDfFNdn7bkvIlgorS',
fromAddress: 'OqAoUjdTWe1hQeaUzVV1ItTnNfIoIiNmYqEaH3UjdSjK',
error: 'account sequence mismatch, expected 42, got 41',
createdAt: new Date('2025-08-24T06:45:00Z')
},
{
id: '6',
transactionHash: '7FZsSwvWw2tEcusbCebudLJKjNkfzKWNjuLb6I9VWhajLGtvvIsZGf3DkKg2zFlLXqZbLPZcEgGOdo8clwJmhpsT',
fromAddress: 'PrBpVkeUXf2iRfbV0WW2JuUoPgJpJjOnZrFbI4VkeTkL',
nymTransactionHash: '9H471F069H5014HF58GFF4EEE8012I876E314E39H09G1F363E08DFG6HGH023I1',
createdAt: new Date('2025-08-24T05:30:00Z')
},
{
id: '7',
transactionHash: '8GAtTxwXx3uFdvtcDfcveMLLkOlg0LXOkvMc7J0WXibkMHuwwJtaHg4ElLh3AFmMYrAcMQAdFhHPeq9dmxKniqtU',
fromAddress: 'QsCqWlfVYg3jSgcW1XX3KvVpQhKqKkPpAsGcJ5WlfUlM',
nymTransactionHash: 'AI582G17AI6125IG69HGG5FFF9123J987F425F4AI1AH2G474F19EGH7IHI134J2',
createdAt: new Date('2025-08-24T04:15:00Z')
},
{
id: '8',
transactionHash: '9HBuUyxYy4vGewudEgdwfNMMlPmh1MYPlwNd8K1XYjclNIvxxKubIh5FmMi4BGnNZsBdNRBeFiIQfr0eoyLofsVU',
fromAddress: 'RtDrXmgWZh4kThcX2YY4LwWqRiLrLlQrBtHdK6XmgVmN',
error: 'insufficient funds: got 50unym, required 250unym',
createdAt: new Date('2025-08-24T03:00:00Z')
},
{
id: '9',
transactionHash: 'ACVwZzyZ5wHfxveEhdxgONNmNqmiBNaQmOe9L2YZkdmOJwyxLvcJi6GnNj5CHoOAtCeOSCfGjJRgs1fqzMqgtWV',
fromAddress: 'SuEsYnhXai5lUidY3ZZ5MxXsSkMsLmRSCuIeL7YnhWnO',
nymTransactionHash: 'BJ693H28BJ7236JH7AIIH6GGG0234K098G536G5BJ2BI3H585G20FHI8JIJ245K3',
createdAt: new Date('2025-08-24T02:45:00Z')
},
{
id: '10',
transactionHash: 'BDCxA00a6xIgywxfFiegGOOoOrnoDObRynPf0M3aalmPKxzMwdKj7HoOk6DIpPatDeP0TCgHkKSht2grANruuXW',
fromAddress: 'TvFtZoiYbj6mVjeZ4Aa6NyYtTlNtMnStDvJfM8ZoiXoP',
error: 'tx parse error: invalid transaction format',
createdAt: new Date('2025-08-24T01:30:00Z')
},
{
id: '11',
transactionHash: 'CEDyB11b7yJhzygGjfzgGPPpPsopEPcSzoPg1N4bmpoPLyyNxeeLk8IpPl7EJqPbuEfQ1UDhIlLTiu3hsOsVwwYX',
fromAddress: 'UwGuapjZck7nWkfa5Bb7OzZuUmOuNoTuEwKgN9apjYpQ',
nymTransactionHash: 'CK704I39CK8347KI8BJJI7HHH1345L109H647H6CK3CJ4I696H31GJJ9KJK356L4',
createdAt: new Date('2025-08-24T00:15:00Z')
},
{
id: '12',
transactionHash: 'DFEzC22c8zKi0zhHkgAgHQQqQtqpFQdT0qQh2O5cnpqQMzzOyfFMl9JqQm8FKrQcvFgR2VEiJmMUjv4itPtWxxZY',
fromAddress: 'VxHvbqkai8oXlgb6CcC8P0avVnPvOpUvFxLhO0bqkZqR',
error: 'gas wanted 200000 exceeds block gas limit 100000',
createdAt: new Date('2025-08-23T23:00:00Z')
}
];
export const mockBridgeTransactions: BridgeTransaction[] = [
{
id: 1,
nymAmount: '125.5',
ethTransactionHash: '0x1a2b3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890ab',
createdAt: new Date('2025-08-24T11:00:00Z')
},
{
id: 2,
nymAmount: '67.25',
ethTransactionHash: '0x2b3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890abcd',
createdAt: new Date('2025-08-24T10:20:00Z')
},
{
id: 3,
nymAmount: '200.0',
error: 'execution reverted: insufficient allowance',
createdAt: new Date('2025-08-24T09:30:00Z')
},
{
id: 4,
nymAmount: '89.75',
error: 'nonce too low',
createdAt: new Date('2025-08-24T08:15:00Z')
},
{
id: 5,
nymAmount: '156.0',
error: 'insufficient funds for gas * price + value',
createdAt: new Date('2025-08-24T07:45:00Z')
},
{
id: 6,
nymAmount: '78.3',
ethTransactionHash: '0x3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
createdAt: new Date('2025-08-24T06:30:00Z')
},
{
id: 7,
nymAmount: '245.8',
ethTransactionHash: '0x4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12',
createdAt: new Date('2025-08-24T05:15:00Z')
},
{
id: 8,
nymAmount: '132.4',
error: 'execution reverted: transfer amount exceeds balance',
createdAt: new Date('2025-08-24T04:00:00Z')
},
{
id: 9,
nymAmount: '98.7',
ethTransactionHash: '0x5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890abcdef123',
createdAt: new Date('2025-08-24T02:45:00Z')
},
{
id: 10,
nymAmount: '187.9',
error: 'max fee per gas less than block base fee',
createdAt: new Date('2025-08-24T01:30:00Z')
},
{
id: 11,
nymAmount: '67.5',
ethTransactionHash: '0x6f7890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234',
createdAt: new Date('2025-08-23T23:15:00Z')
},
{
id: 12,
nymAmount: '298.2',
ethTransactionHash: '0x7890abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456',
createdAt: new Date('2025-08-23T22:00:00Z')
}
];
export const mockSwapTransactions: SwapTransaction[] = [
{
id: 1,
ethAmount: '0.05',
transactionHash: '0x3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
createdAt: new Date('2025-08-24T12:15:00Z')
},
{
id: 2,
ethAmount: '0.025',
transactionHash: '0x4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12',
createdAt: new Date('2025-08-24T11:40:00Z')
},
{
id: 3,
ethAmount: '0.1',
error: 'transaction underpriced: gas price too low',
createdAt: new Date('2025-08-24T10:55:00Z')
},
{
id: 5,
ethAmount: '0.03',
error: 'replacement transaction underpriced',
createdAt: new Date('2025-08-24T08:30:00Z')
},
{
id: 6,
ethAmount: '0.08',
transactionHash: '0x8901abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567',
createdAt: new Date('2025-08-24T07:15:00Z')
},
{
id: 7,
ethAmount: '0.12',
transactionHash: '0x901abcdef1234567890abcdef1234567890abcdef1234567890abcdef12345678',
createdAt: new Date('2025-08-24T06:00:00Z')
},
{
id: 8,
ethAmount: '0.045',
error: 'execution reverted: pool insufficient liquidity',
createdAt: new Date('2025-08-24T04:45:00Z')
},
{
id: 9,
ethAmount: '0.09',
transactionHash: '0xa01bcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789',
createdAt: new Date('2025-08-24T03:30:00Z')
},
{
id: 10,
ethAmount: '0.067',
error: 'gas limit reached',
createdAt: new Date('2025-08-24T02:15:00Z')
},
{
id: 11,
ethAmount: '0.15',
transactionHash: '0xb01cdef1234567890abcdef1234567890abcdef1234567890abcdef123456789a',
createdAt: new Date('2025-08-24T01:00:00Z')
},
{
id: 12,
ethAmount: '0.055',
error: 'execution reverted: deadline expired',
createdAt: new Date('2025-08-23T23:45:00Z')
}
];
export const mockAccountBalances: AccountBalance[] = [
{
asset: 'ETH',
balance: '2.456789',
address: '0x742d35Cc643C0532E6A8b32F4F5bbFB6C6e13aE2'
},
{
asset: 'NYM',
balance: '15234.789123',
address: 'n1sdnrq62m07gcwzpfmdgvqfpqqjvtnnllcypur8'
}
];
export const mockAppDownloads: AppDownload[] = [
{
id: '1',
version: 'v1.8.0-mtm-0.1.2',
platform: 'Android',
downloads: 1247,
releaseDate: new Date('2025-08-19'),
fileSize: '111 MiB'
},
{
id: '2',
version: 'v1.7.5-mtm-0.1.1',
platform: 'Android',
downloads: 892,
releaseDate: new Date('2025-08-10'),
fileSize: '108 MiB'
}
];

View File

@ -41,9 +41,19 @@
```bash
# Create env for deployment from example env
cp ../.env.example .app.env
```
# Fill in the required values
nano .app.env
- Update the env variables in `.app.env`
```bash
# Public endpoint of mtm-to-nym-service
NEXT_PUBLIC_MTM_SERVICE_URL=https://mtm-vpn.laconic.com
# NYM chain RPC endpoint
NEXT_PUBLIC_NYX_RPC_URL=https://rpc.nymtech.net
# ETH chain RPC endpoint
ETH_RPC_URL=
```
## Run

View File

@ -12,8 +12,7 @@ echo "Using AUTHORITY: $AUTHORITY"
REPO_URL="https://git.vdb.to/cerc-io/mtm-vpn-dashboard"
# Get the latest commit hash for a branch
# TODO: Change to main branch when ready
BRANCH_NAME="ng-deploy-laconic"
BRANCH_NAME="main"
LATEST_HASH=$(git ls-remote $REPO_URL refs/heads/$BRANCH_NAME | awk '{print $1}')
# Gitea

View File

@ -0,0 +1,31 @@
import { NextApiRequest, NextApiResponse } from 'next';
const GITHUB_RELEASES_URL="https://git.vdb.to/api/v1/repos/cerc-io/mtm-vpn-client-public/releases"
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const response = await fetch(GITHUB_RELEASES_URL, {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': 'MTM-VPN-Dashboard/1.0',
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
res.status(200).json(data);
} catch (error) {
console.error('GitHub releases proxy error:', error);
res.status(500).json({
error: error instanceof Error ? error.message : 'GitHub releases request failed'
});
}
}

View File

@ -1,15 +1,40 @@
import { useState, useEffect } from 'react';
import {
CloudArrowDownIcon,
DevicePhoneMobileIcon,
ArrowTopRightOnSquareIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/outline';
import Layout from '../../components/Layout';
import { mockAppDownloads } from '../../data/mockData';
import { ApiError } from '../../utils/api';
import { fetchGitHubReleases, ProcessedRelease } from '../../utils/downloads';
export default function Downloads() {
// Filter to only Android apps
const androidApps = mockAppDownloads.filter(app => app.platform === 'Android');
const totalDownloads = androidApps.reduce((sum, app) => sum + app.downloads, 0);
const [releases, setReleases] = useState<ProcessedRelease[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadReleases = async () => {
try {
setLoading(true);
setError(null);
const { releases } = await fetchGitHubReleases();
setReleases(releases);
} catch (err) {
console.error('Failed to fetch releases:', err);
setError(err instanceof ApiError ? err.message : 'Failed to load release data');
} finally {
setLoading(false);
}
};
loadReleases();
}, []);
const totalDownloads = releases.reduce((sum, release) => sum + release.downloads, 0);
return (
<Layout>
@ -37,7 +62,13 @@ export default function Downloads() {
Total Downloads
</dt>
<dd className="text-lg font-medium text-gray-900">
{totalDownloads.toLocaleString()}
{loading ? (
<div className="animate-pulse bg-gray-200 h-6 w-20 rounded"></div>
) : error ? (
<span className="text-red-600">Error</span>
) : (
totalDownloads.toLocaleString()
)}
</dd>
</dl>
</div>
@ -57,9 +88,6 @@ export default function Downloads() {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Version
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Platform
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Downloads
</th>
@ -75,50 +103,96 @@ export default function Downloads() {
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{androidApps
.sort((a, b) => new Date(b.releaseDate).getTime() - new Date(a.releaseDate).getTime())
.map((app, index) => (
<tr key={app.id} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{app.version}
{loading ? (
Array.from({ length: 3 }).map((_, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="animate-pulse bg-gray-200 h-4 w-24 rounded"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center">
<DevicePhoneMobileIcon className="h-5 w-5 text-gray-400 mr-2" />
{app.platform}
</div>
<td className="px-6 py-4 whitespace-nowrap">
<div className="animate-pulse bg-gray-200 h-4 w-12 rounded"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="animate-pulse bg-gray-200 h-4 w-16 rounded"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="animate-pulse bg-gray-200 h-4 w-20 rounded"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="animate-pulse bg-gray-200 h-4 w-24 rounded"></div>
</td>
</tr>
))
) : error ? (
<tr>
<td colSpan={5} className="px-6 py-8 whitespace-nowrap text-sm text-gray-500 text-center">
<div className="flex flex-col items-center">
<ExclamationTriangleIcon className="h-12 w-12 text-red-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">Error Loading Releases</h3>
<p className="mt-1 text-sm text-red-600">{error}</p>
</div>
</td>
</tr>
) : releases.length > 0 ? (
releases.map((release, index) => (
<tr key={release.id} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{release.version}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<span className="text-lg font-semibold">
{app.downloads.toLocaleString()}
{release.downloads.toLocaleString()}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{app.fileSize}
{release.fileSize}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span suppressHydrationWarning>
{app.releaseDate.toLocaleDateString()}
{release.releaseDate.toLocaleDateString()}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<a
href={`https://git.vdb.to/cerc-io/mtm-vpn-client-public/releases/tag/${app.version}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 flex items-center"
>
View Release
<ArrowTopRightOnSquareIcon className="ml-1 h-4 w-4" />
</a>
<div className="flex space-x-3">
<a
href={`https://git.vdb.to/cerc-io/mtm-vpn-client-public/releases/tag/${release.tagName}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 flex items-center"
>
View Release
<ArrowTopRightOnSquareIcon className="ml-1 h-4 w-4" />
</a>
<a
href={release.downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="text-green-600 hover:text-green-800 flex items-center"
>
Download APK
<CloudArrowDownIcon className="ml-1 h-4 w-4" />
</a>
</div>
</td>
</tr>
))}
))
) : (
<tr>
<td colSpan={5} className="px-6 py-8 whitespace-nowrap text-sm text-gray-500 text-center">
<div className="flex flex-col items-center">
<DevicePhoneMobileIcon className="h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No Releases Found</h3>
<p className="mt-1 text-sm text-gray-500">
No Android app releases are currently available.
</p>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>

View File

@ -1,41 +1,95 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
XCircleIcon,
ArrowTopRightOnSquareIcon,
ChevronLeftIcon,
ChevronRightIcon,
ExclamationTriangleIcon,
ClipboardDocumentIcon,
} from '@heroicons/react/24/outline';
import Layout from '../../components/Layout';
import { mockTransactions, mockBridgeTransactions, mockSwapTransactions } from '../../data/mockData';
import { dashboardApi, TransactionData, SwapData, ApiError } from '../../utils/api';
import { getExplorerUrl } from '../../utils/explorer';
import { copyToClipboard } from '../../utils/clipboard';
export default function FailedTransactions() {
const [currentPage, setCurrentPage] = useState(1);
// MTM to NYM state
const [mtmFailures, setMtmFailures] = useState<TransactionData[]>([]);
const [mtmLoading, setMtmLoading] = useState(true);
const [mtmError, setMtmError] = useState<string | null>(null);
const [mtmCurrentPage, setMtmCurrentPage] = useState(1);
const [mtmTotalPages, setMtmTotalPages] = useState(1);
// ETH to NYM state
const [ethFailures, setEthFailures] = useState<SwapData[]>([]);
const [ethLoading, setEthLoading] = useState(true);
const [ethError, setEthError] = useState<string | null>(null);
const [ethCurrentPage, setEthCurrentPage] = useState(1);
const [ethTotalPages, setEthTotalPages] = useState(1);
const itemsPerPage = 5;
// Get only failed transactions
const failedTransactions = [
...mockTransactions.filter(tx => tx.error).map(tx => ({ ...tx, type: 'MTM to NYM' as const })),
...mockBridgeTransactions.filter(tx => tx.error).map(tx => ({ ...tx, type: 'ETH Bridge' as const })),
...mockSwapTransactions.filter(tx => tx.error).map(tx => ({ ...tx, type: 'ETH Swap' as const })),
].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
// Fetch MTM to NYM failed conversions
useEffect(() => {
const fetchMtmFailures = async () => {
try {
setMtmLoading(true);
setMtmError(null);
const response = await dashboardApi.getConversions({
page: mtmCurrentPage,
limit: itemsPerPage,
status: 'failed'
});
setMtmFailures(response.transactions);
setMtmTotalPages(response.pagination.totalPages);
} catch (error) {
console.error('Error fetching MTM failures:', error);
setMtmError(error instanceof ApiError ? error.message : 'Failed to load MTM conversion failures');
} finally {
setMtmLoading(false);
}
};
// Pagination logic
const totalPages = Math.ceil(failedTransactions.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedTransactions = failedTransactions.slice(startIndex, endIndex);
fetchMtmFailures();
}, [mtmCurrentPage]);
const renderPagination = () => {
// Fetch ETH to NYM failed swaps/bridges
useEffect(() => {
const fetchEthFailures = async () => {
try {
setEthLoading(true);
setEthError(null);
const response = await dashboardApi.getSwaps({
page: ethCurrentPage,
limit: itemsPerPage,
status: 'failed'
});
setEthFailures(response.swaps);
setEthTotalPages(response.pagination.totalPages);
} catch (error) {
console.error('Error fetching ETH failures:', error);
setEthError(error instanceof ApiError ? error.message : 'Failed to load ETH conversion failures');
} finally {
setEthLoading(false);
}
};
fetchEthFailures();
}, [ethCurrentPage]);
const renderPagination = (currentPage: number, totalPages: number, onPageChange: (page: number) => void) => {
if (totalPages <= 1) return null;
return (
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-gray-700">
Showing {startIndex + 1} to {Math.min(endIndex, failedTransactions.length)} of {failedTransactions.length} results
Page {currentPage} of {totalPages}
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage <= 1}
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
@ -45,7 +99,7 @@ export default function FailedTransactions() {
{currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage >= totalPages}
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
@ -56,18 +110,43 @@ export default function FailedTransactions() {
);
};
const getExplorerUrl = (hash: string, type: 'solana' | 'ethereum' | 'nym') => {
switch (type) {
case 'solana':
return `https://explorer.solana.com/tx/${hash}`;
case 'ethereum':
return `https://etherscan.io/tx/${hash}`;
case 'nym':
return `https://explorer.nyx.net/transactions/${hash}`;
default:
return '#';
}
};
const renderLoadingState = () => (
<tr>
<td colSpan={4} className="px-6 py-8 whitespace-nowrap text-sm text-gray-500 text-center">
<div className="flex flex-col items-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-2">Loading failed transactions...</p>
</div>
</td>
</tr>
);
const renderErrorState = (error: string) => (
<tr>
<td colSpan={4} className="px-6 py-8 whitespace-nowrap text-sm text-gray-500 text-center">
<div className="flex flex-col items-center">
<ExclamationTriangleIcon className="h-12 w-12 text-red-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">Error Loading Data</h3>
<p className="mt-1 text-sm text-red-600">{error}</p>
</div>
</td>
</tr>
);
const renderEmptyState = (message: string) => (
<tr>
<td colSpan={4} className="px-6 py-8 whitespace-nowrap text-sm text-gray-500 text-center">
<div className="flex flex-col items-center">
<svg className="h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No Failed Transactions</h3>
<p className="mt-1 text-sm text-gray-500">{message}</p>
</div>
</td>
</tr>
);
return (
<Layout>
@ -81,104 +160,231 @@ export default function FailedTransactions() {
</div>
</div>
{/* Failed Transactions Table */}
<div className="mt-8 flex flex-col">
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Transaction Link
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Error Details
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Failed At
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{paginatedTransactions.length > 0 ? (
paginatedTransactions.map((tx, index) => {
const txKey = `${tx.type}-${tx.id}`;
return (
<tr key={txKey} className={index % 2 === 0 ? 'bg-white' : 'bg-red-50'}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
<div className="flex items-center">
<XCircleIcon className="h-5 w-5 text-red-400 mr-2" />
{tx.type}
</div>
</td>
{/* MTM to NYM Conversion Failures */}
<div className="mt-8">
<h2 className="text-lg font-medium text-gray-900 mb-4">MTM to NYM Conversion Failures</h2>
<div className="flex flex-col">
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Transaction Link
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
From Address
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Error Details
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Failed At
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{mtmLoading ? (
renderLoadingState()
) : mtmError ? (
renderErrorState(mtmError)
) : mtmFailures.length > 0 ? (
mtmFailures.map((tx, index) => (
<tr key={tx.id} className={index % 2 === 0 ? 'bg-white' : 'bg-red-50'}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{'transactionHash' in tx && tx.transactionHash && (
<a
href={getExplorerUrl(tx.transactionHash, 'solana')}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 flex items-center"
>
View Transaction
<ArrowTopRightOnSquareIcon className="ml-1 h-4 w-4" />
</a>
)}
{'ethTransactionHash' in tx && tx.ethTransactionHash && (
<a
href={getExplorerUrl(tx.ethTransactionHash, 'ethereum')}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 flex items-center"
>
View Transaction
<ArrowTopRightOnSquareIcon className="ml-1 h-4 w-4" />
</a>
)}
<a
href={getExplorerUrl(tx.transactionHash, 'solana')}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 flex items-center"
>
View Solana TX
<ArrowTopRightOnSquareIcon className="ml-1 h-4 w-4" />
</a>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<span className="font-mono text-xs">{tx.fromAddress}</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
<div className="max-w-xs">
<p className="text-red-600 break-words" title={tx.error}>
{tx.error}
<div className="max-w-xs flex items-start space-x-2">
<p className="text-red-600 break-words truncate flex-1" title={tx.error || 'Unknown error'}>
{tx.error && tx.error.length > 80 ? `${tx.error.substring(0, 80)}...` : (tx.error || 'Unknown error')}
</p>
{tx.error && (
<button
onClick={() => copyToClipboard(tx.error!, 'MTM conversion error')}
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded"
title="Copy full error message"
>
<ClipboardDocumentIcon className="h-4 w-4" />
</button>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span suppressHydrationWarning>
{new Date(tx.createdAt).toLocaleString()}
</span>
</td>
</tr>
);
})
) : (
<tr>
<td colSpan={4} className="px-6 py-8 whitespace-nowrap text-sm text-gray-500 text-center">
<div className="flex flex-col items-center">
<svg className="h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No failed transactions</h3>
<p className="mt-1 text-sm text-gray-500">
All transactions are processing successfully.
</p>
</div>
</td>
</tr>
)}
</tbody>
</table>
))
) : (
renderEmptyState('All MTM to NYM conversions have been processed successfully.')
)}
</tbody>
</table>
</div>
</div>
</div>
{renderPagination(mtmCurrentPage, mtmTotalPages, setMtmCurrentPage)}
</div>
</div>
{/* ETH to NYM Conversion Failures */}
<div className="mt-12">
<h2 className="text-lg font-medium text-gray-900 mb-4">ETH to NYM Conversion Failures</h2>
<div className="flex flex-col">
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
ETH Amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Swap Transaction
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Bridge Transaction
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Error Details
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Failed At
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{ethLoading ? (
<tr>
<td colSpan={5} className="px-6 py-8 whitespace-nowrap text-sm text-gray-500 text-center">
<div className="flex flex-col items-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-2">Loading failed transactions...</p>
</div>
</td>
</tr>
) : ethError ? (
<tr>
<td colSpan={5} className="px-6 py-8 whitespace-nowrap text-sm text-gray-500 text-center">
<div className="flex flex-col items-center">
<ExclamationTriangleIcon className="h-12 w-12 text-red-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">Error Loading Data</h3>
<p className="mt-1 text-sm text-red-600">{ethError}</p>
</div>
</td>
</tr>
) : ethFailures.length > 0 ? (
ethFailures.map((swap, index) => (
<tr key={swap.id} className={index % 2 === 0 ? 'bg-white' : 'bg-red-50'}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{parseFloat(swap.ethAmount).toFixed(4)} ETH
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{swap.transactionHash ? (
<a
href={getExplorerUrl(swap.transactionHash, 'ethereum')}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 flex items-center"
>
View Swap TX
<ArrowTopRightOnSquareIcon className="ml-1 h-4 w-4" />
</a>
) : (
<span className="text-gray-400">No transaction</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{swap.bridgeTransaction?.ethTransactionHash ? (
<a
href={getExplorerUrl(swap.bridgeTransaction.ethTransactionHash, 'ethereum')}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 flex items-center"
>
View Bridge TX
<ArrowTopRightOnSquareIcon className="ml-1 h-4 w-4" />
</a>
) : (
<span className="text-gray-400">No bridge transaction</span>
)}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
<div className="max-w-xs space-y-1">
{swap.error && (
<div className="flex items-start space-x-2">
<p className="text-red-600 break-words truncate flex-1" title={`Swap Error: ${swap.error}`}>
<span className="font-medium">Swap:</span> {swap.error.length > 50 ? `${swap.error.substring(0, 50)}...` : swap.error}
</p>
<button
onClick={() => copyToClipboard(swap.error!, 'Swap error')}
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded"
title="Copy full swap error message"
>
<ClipboardDocumentIcon className="h-4 w-4" />
</button>
</div>
)}
{swap.bridgeTransaction?.error && (
<div className="flex items-start space-x-2">
<p className="text-red-600 break-words truncate flex-1" title={`Bridge Error: ${swap.bridgeTransaction.error}`}>
<span className="font-medium">Bridge:</span> {swap.bridgeTransaction.error.length > 50 ? `${swap.bridgeTransaction.error.substring(0, 50)}...` : swap.bridgeTransaction.error}
</p>
<button
onClick={() => copyToClipboard(swap.bridgeTransaction!.error!, 'Bridge error')}
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded"
title="Copy full bridge error message"
>
<ClipboardDocumentIcon className="h-4 w-4" />
</button>
</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span suppressHydrationWarning>
{new Date(swap.createdAt).toLocaleString()}
</span>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="px-6 py-8 whitespace-nowrap text-sm text-gray-500 text-center">
<div className="flex flex-col items-center">
<svg className="h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No Failed Transactions</h3>
<p className="mt-1 text-sm text-gray-500">
All ETH to NYM conversions have been processed successfully.
</p>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{renderPagination(ethCurrentPage, ethTotalPages, setEthCurrentPage)}
</div>
{renderPagination()}
</div>
</div>
</Layout>

View File

@ -8,12 +8,15 @@ import {
XCircleIcon,
CloudArrowDownIcon,
WalletIcon,
ArrowTopRightOnSquareIcon,
} from '@heroicons/react/24/outline';
import { StargateClient } from '@cosmjs/stargate';
import { Decimal } from '@cosmjs/math';
import Layout from '../../components/Layout';
import dashboardApi, { ApiError, DashboardStats, TransactionData } from '../../utils/api';
import { getExplorerUrl } from '../../utils/explorer';
import { fetchGitHubReleases } from '../../utils/downloads';
interface BalanceData {
address: string;
@ -26,6 +29,7 @@ export default function Dashboard() {
const [dashboardStats, setDashboardStats] = useState<DashboardStats | null>(null);
const [recentTransactions, setRecentTransactions] = useState<TransactionData[]>([]);
const [accountBalances, setAccountBalances] = useState<BalanceData[]>([]);
const [totalDownloads, setTotalDownloads] = useState<number>(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -37,10 +41,11 @@ export default function Dashboard() {
try {
setIsLoading(true);
// Fetch dashboard data (without balances)
const [statsData, conversionsData] = await Promise.allSettled([
// Fetch all dashboard data including downloads
const [statsData, conversionsData, downloadsData] = await Promise.allSettled([
dashboardApi.getStats(),
dashboardApi.getConversions({ limit: 5, status: 'all' })
dashboardApi.getConversions({ limit: 5, status: 'all' }),
fetchGitHubReleases()
]);
// Handle dashboard stats
@ -55,6 +60,13 @@ export default function Dashboard() {
console.error('Failed to fetch dashboard stats:', statsData.reason);
}
// Handle downloads data separately
if (downloadsData.status === 'fulfilled') {
setTotalDownloads(downloadsData.value.totalDownloads);
} else {
console.error('Failed to fetch downloads data:', downloadsData.reason);
}
// Handle recent transactions
if (conversionsData.status === 'fulfilled') {
setRecentTransactions(conversionsData.value.transactions);
@ -175,7 +187,7 @@ export default function Dashboard() {
{
id: 4,
name: 'Total Downloads',
stat: dashboardStats.totalDownloads.toLocaleString(),
stat: totalDownloads.toLocaleString(),
icon: CloudArrowDownIcon,
},
];
@ -358,14 +370,36 @@ export default function Dashboard() {
)}
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{`${transaction.transactionHash.slice(0, 8)}...${transaction.transactionHash.slice(-8)}`}
<div className="text-sm font-medium text-gray-900 flex items-center space-x-2">
<a
href={getExplorerUrl(transaction.transactionHash, 'solana')}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 font-mono"
>
{`${transaction.transactionHash.slice(0, 8)}...${transaction.transactionHash.slice(-8)}`}
</a>
<ArrowTopRightOnSquareIcon className="h-4 w-4 text-gray-400" />
</div>
<div className="text-sm text-gray-500">
{transaction.error ?
`Error: ${transaction.error.substring(0, 40)}...` :
{transaction.error ? (
`Error: ${transaction.error.substring(0, 40)}...`
) : transaction.nymTransactionHash ? (
<div className="flex items-center space-x-2">
<span>NYM:</span>
<a
href={getExplorerUrl(transaction.nymTransactionHash, 'nym')}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 font-mono text-xs"
>
{`${transaction.nymTransactionHash.slice(0, 8)}...${transaction.nymTransactionHash.slice(-8)}`}
</a>
<ArrowTopRightOnSquareIcon className="h-3 w-3 text-gray-400" />
</div>
) : (
`From: ${transaction.fromAddress.slice(0, 8)}...${transaction.fromAddress.slice(-8)}`
}
)}
</div>
</div>
</div>

View File

@ -1,56 +1,76 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
CheckCircleIcon,
XCircleIcon,
ArrowTopRightOnSquareIcon,
ChevronLeftIcon,
ChevronRightIcon,
ClipboardDocumentIcon,
} from '@heroicons/react/24/outline';
import Layout from '../../components/Layout';
import { mockTransactions, mockBridgeTransactions, mockSwapTransactions } from '../../data/mockData';
import dashboardApi, { TransactionData, SwapData } from '../../utils/api';
import { getExplorerUrl } from '../../utils/explorer';
import { copyToClipboard } from '../../utils/clipboard';
export default function Transactions() {
const [mtmCurrentPage, setMtmCurrentPage] = useState(1);
const [otherCurrentPage, setOtherCurrentPage] = useState(1);
const [mtmConversions, setMtmConversions] = useState<TransactionData[]>([]);
const [ethConversions, setEthConversions] = useState<SwapData[]>([]);
const [mtmTotalPages, setMtmTotalPages] = useState(1);
const [ethTotalPages, setEthTotalPages] = useState(1);
const [mtmLoading, setMtmLoading] = useState(true);
const [ethLoading, setEthLoading] = useState(true);
const [mtmError, setMtmError] = useState<string | null>(null);
const [ethError, setEthError] = useState<string | null>(null);
const itemsPerPage = 5;
// MTM to NYM conversions (2-transaction structure: Solana + Nyx)
const mtmToNymConversions = mockTransactions.map(tx => ({ ...tx, type: 'MTM to NYM' as const }));
// ETH to NYM conversions (each comprising of a Swap and Bridge tx)
const ethToNymConversions = mockBridgeTransactions.map((bridgeTx, index) => ({
id: `eth-nym-${bridgeTx.id}`,
bridgeTransaction: bridgeTx,
swapTransaction: mockSwapTransactions[index] || null, // Pair with corresponding swap
createdAt: bridgeTx.createdAt,
error: bridgeTx.error || (mockSwapTransactions[index]?.error || null),
})).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
useEffect(() => {
fetchMtmConversions();
}, [mtmCurrentPage]);
const getExplorerUrl = (hash: string, type: 'solana' | 'ethereum' | 'nym') => {
switch (type) {
case 'solana':
return `https://explorer.solana.com/tx/${hash}`;
case 'ethereum':
return `https://etherscan.io/tx/${hash}`;
case 'nym':
return `https://explorer.nyx.net/transactions/${hash}`;
default:
return '#';
useEffect(() => {
fetchEthConversions();
}, [otherCurrentPage]);
const fetchMtmConversions = async () => {
try {
setMtmLoading(true);
setMtmError(null);
const response = await dashboardApi.getConversions({
page: mtmCurrentPage,
limit: itemsPerPage,
status: 'all'
});
setMtmConversions(response.transactions);
setMtmTotalPages(response.pagination.totalPages);
} catch (error) {
console.error('Failed to fetch MTM conversions:', error);
setMtmError('Failed to load MTM conversions');
} finally {
setMtmLoading(false);
}
};
// Pagination logic for MTM conversions
const mtmTotalPages = Math.ceil(mtmToNymConversions.length / itemsPerPage);
const mtmStartIndex = (mtmCurrentPage - 1) * itemsPerPage;
const mtmEndIndex = mtmStartIndex + itemsPerPage;
const paginatedMtmConversions = mtmToNymConversions.slice(mtmStartIndex, mtmEndIndex);
// Pagination logic for ETH to NYM conversions
const ethTotalPages = Math.ceil(ethToNymConversions.length / itemsPerPage);
const ethStartIndex = (otherCurrentPage - 1) * itemsPerPage;
const ethEndIndex = ethStartIndex + itemsPerPage;
const paginatedEthConversions = ethToNymConversions.slice(ethStartIndex, ethEndIndex);
const fetchEthConversions = async () => {
try {
setEthLoading(true);
setEthError(null);
const response = await dashboardApi.getSwaps({
page: otherCurrentPage,
limit: itemsPerPage,
status: 'all'
});
setEthConversions(response.swaps);
setEthTotalPages(response.pagination.totalPages);
} catch (error) {
console.error('Failed to fetch ETH conversions:', error);
setEthError('Failed to load ETH conversions');
} finally {
setEthLoading(false);
}
};
const renderPagination = (currentPage: number, totalPages: number, setCurrentPage: (page: number) => void) => {
if (totalPages <= 1) return null;
@ -153,8 +173,17 @@ export default function Transactions() {
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{tx.error ? (
<div className="max-w-xs truncate text-red-600" title={tx.error}>
{tx.error}
<div className="max-w-xs flex items-start space-x-2">
<div className="truncate text-red-600 flex-1" title={tx.error}>
{tx.error}
</div>
<button
onClick={() => copyToClipboard(tx.error!, 'MTM conversion error')}
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded"
title="Copy full error message"
>
<ClipboardDocumentIcon className="h-4 w-4" />
</button>
</div>
) : '-'}
</td>
@ -168,19 +197,20 @@ export default function Transactions() {
);
};
const renderEthConversionRow = (conversion: any, index: number) => {
const { bridgeTransaction, swapTransaction } = conversion;
const renderEthConversionRow = (swap: SwapData, index: number) => {
const { bridgeTransaction } = swap;
const hasError = swap.error || (bridgeTransaction?.error);
return (
<tr key={conversion.id} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<tr key={swap.id} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
<div className="flex items-center">
{conversion.error ? (
{hasError ? (
<XCircleIcon className="h-5 w-5 text-red-400 mr-2" />
) : (
<CheckCircleIcon className="h-5 w-5 text-green-400 mr-2" />
)}
<span className={conversion.error ? 'text-red-600' : 'text-green-600'}>
{conversion.error ? 'Failed' : 'Success'}
<span className={hasError ? 'text-red-600' : 'text-green-600'}>
{hasError ? 'Failed' : 'Success'}
</span>
</div>
</td>
@ -188,13 +218,13 @@ export default function Transactions() {
<td className="px-6 py-4 text-sm text-gray-500">
<div className="space-y-3">
{/* Swap Transaction */}
{swapTransaction && (
{swap && (
<div>
<div className="flex items-center space-x-2 mb-1">
<span className="text-xs font-medium text-orange-600">Swap:</span>
{swapTransaction.transactionHash && (
{swap.transactionHash && (
<a
href={getExplorerUrl(swapTransaction.transactionHash, 'ethereum')}
href={getExplorerUrl(swap.transactionHash, 'ethereum')}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800"
@ -204,41 +234,43 @@ export default function Transactions() {
)}
</div>
<div className="font-mono text-xs text-gray-600">
{swapTransaction.transactionHash ? `${swapTransaction.transactionHash.slice(0, 12)}...${swapTransaction.transactionHash.slice(-12)}` : 'Pending...'}
{swap.transactionHash ? `${swap.transactionHash.slice(0, 12)}...${swap.transactionHash.slice(-12)}` : 'Error during transaction'}
</div>
</div>
)}
{/* Bridge Transaction */}
<div>
<div className="flex items-center space-x-2 mb-1">
<span className="text-xs font-medium text-green-600">Bridge:</span>
{bridgeTransaction.ethTransactionHash && (
<a
href={getExplorerUrl(bridgeTransaction.ethTransactionHash, 'ethereum')}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800"
>
<ArrowTopRightOnSquareIcon className="h-3 w-3" />
</a>
)}
{bridgeTransaction && (
<div>
<div className="flex items-center space-x-2 mb-1">
<span className="text-xs font-medium text-green-600">Bridge:</span>
{bridgeTransaction.ethTransactionHash && (
<a
href={getExplorerUrl(bridgeTransaction.ethTransactionHash, 'ethereum')}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800"
>
<ArrowTopRightOnSquareIcon className="h-3 w-3" />
</a>
)}
</div>
<div className="font-mono text-xs text-gray-600">
{bridgeTransaction.ethTransactionHash ? `${bridgeTransaction.ethTransactionHash.slice(0, 12)}...${bridgeTransaction.ethTransactionHash.slice(-12)}` : 'Error during transaction'}
</div>
</div>
<div className="font-mono text-xs text-gray-600">
{bridgeTransaction.ethTransactionHash ? `${bridgeTransaction.ethTransactionHash.slice(0, 12)}...${bridgeTransaction.ethTransactionHash.slice(-12)}` : 'Pending...'}
</div>
</div>
)}
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
<div className="space-y-1">
{swapTransaction && swapTransaction.ethAmount && (
{swap.ethAmount && (
<div className="text-xs text-gray-600">
<span className="font-medium">ETH:</span> {swapTransaction.ethAmount}
<span className="font-medium">ETH:</span> {swap.ethAmount}
</div>
)}
{bridgeTransaction.nymAmount && (
{bridgeTransaction?.nymAmount && (
<div className="text-xs text-gray-600">
<span className="font-medium">NYM:</span> {bridgeTransaction.nymAmount}
</div>
@ -247,16 +279,43 @@ export default function Transactions() {
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{conversion.error ? (
<div className="max-w-xs truncate text-red-600" title={conversion.error}>
{conversion.error}
{hasError ? (
<div className="max-w-xs space-y-1">
{swap.error && (
<div className="flex items-start space-x-2">
<div className="text-red-600 truncate flex-1" title={`Swap Error: ${swap.error}`}>
<span className="font-medium">Swap:</span> {swap.error.length > 30 ? `${swap.error.substring(0, 30)}...` : swap.error}
</div>
<button
onClick={() => copyToClipboard(swap.error!, 'Swap error')}
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded"
title="Copy full swap error message"
>
<ClipboardDocumentIcon className="h-4 w-4" />
</button>
</div>
)}
{bridgeTransaction?.error && (
<div className="flex items-start space-x-2">
<div className="text-red-600 truncate flex-1" title={`Bridge Error: ${bridgeTransaction.error}`}>
<span className="font-medium">Bridge:</span> {bridgeTransaction.error.length > 30 ? `${bridgeTransaction.error.substring(0, 30)}...` : bridgeTransaction.error}
</div>
<button
onClick={() => copyToClipboard(bridgeTransaction.error!, 'Bridge error')}
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded"
title="Copy full bridge error message"
>
<ClipboardDocumentIcon className="h-4 w-4" />
</button>
</div>
)}
</div>
) : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span suppressHydrationWarning>
{new Date(conversion.createdAt).toLocaleString()}
{new Date(swap.createdAt).toLocaleString()}
</span>
</td>
</tr>
@ -307,8 +366,20 @@ export default function Transactions() {
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{paginatedMtmConversions.length > 0 ? (
paginatedMtmConversions.map((tx, index) => renderMtmConversionRow(tx, index))
{mtmLoading ? (
<tr>
<td colSpan={5} className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-center">
Loading MTM conversions...
</td>
</tr>
) : mtmError ? (
<tr>
<td colSpan={5} className="px-6 py-4 whitespace-nowrap text-sm text-red-500 text-center">
{mtmError}
</td>
</tr>
) : mtmConversions.length > 0 ? (
mtmConversions.map((tx, index) => renderMtmConversionRow(tx, index))
) : (
<tr>
<td colSpan={5} className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-center">
@ -355,8 +426,20 @@ export default function Transactions() {
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{paginatedEthConversions.length > 0 ? (
paginatedEthConversions.map((conversion, index) => renderEthConversionRow(conversion, index))
{ethLoading ? (
<tr>
<td colSpan={5} className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-center">
Loading ETH conversions...
</td>
</tr>
) : ethError ? (
<tr>
<td colSpan={5} className="px-6 py-4 whitespace-nowrap text-sm text-red-500 text-center">
{ethError}
</td>
</tr>
) : ethConversions.length > 0 ? (
ethConversions.map((swap, index) => renderEthConversionRow(swap, index))
) : (
<tr>
<td colSpan={5} className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-center">

View File

@ -1,55 +0,0 @@
export interface Transaction {
id: string;
transactionHash: string;
fromAddress: string;
nymTransactionHash?: string;
error?: string;
createdAt: Date;
}
export interface BridgeTransaction {
id: number;
nymAmount: string;
ethTransactionHash?: string;
error?: string;
createdAt: Date;
}
export interface SwapTransaction {
id: number;
ethAmount: string;
transactionHash?: string;
error?: string;
createdAt: Date;
}
export interface AccountBalance {
asset: 'ETH' | 'NYM';
balance: string;
address: string;
}
export interface AppDownload {
id: string;
version: string;
platform: 'Android' | 'iOS' | 'Windows' | 'macOS' | 'Linux';
downloads: number;
releaseDate: Date;
fileSize: string;
}
export interface DashboardStats {
totalConversions: number;
successfulConversions: number;
failedConversions: number;
totalDownloads: number;
monthlyData: MonthlyData[];
}
export interface MonthlyData {
month: string;
totalConversions: number;
successfulConversions: number;
failedConversions: number;
}

View File

@ -11,7 +11,6 @@ export interface DashboardStats {
totalConversions: number;
successfulConversions: number;
failedConversions: number;
totalDownloads: number;
monthlyData: MonthlyData[];
walletAddresses: {
eth?: string;
@ -38,6 +37,30 @@ export interface TransactionsResponse {
};
}
export interface SwapData {
id: number;
ethAmount: string;
transactionHash?: string;
error?: string | null;
createdAt: string;
bridgeTransaction?: {
id: number;
nymAmount: string;
ethTransactionHash?: string;
error?: string | null;
};
}
export interface SwapsResponse {
swaps: SwapData[];
totalCount: number;
pagination: {
page: number;
limit: number;
totalPages: number;
};
}
export class ApiError extends Error {
constructor(
message: string,
@ -109,6 +132,22 @@ export const dashboardApi = {
return apiRequest<TransactionsResponse>(endpoint);
},
// Get swaps with pagination and filtering
getSwaps: (params?: {
page?: number;
limit?: number;
status?: string;
}): Promise<SwapsResponse> => {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', params.page.toString());
if (params?.limit) searchParams.set('limit', params.limit.toString());
if (params?.status) searchParams.set('status', params.status);
const query = searchParams.toString();
const endpoint = `/api/swaps${query ? `?${query}` : ''}`;
return apiRequest<SwapsResponse>(endpoint);
},
};
export default dashboardApi;
export default dashboardApi;

19
utils/clipboard.ts Normal file
View File

@ -0,0 +1,19 @@
/**
* Copy text to clipboard utility
*/
export const copyToClipboard = async (text: string, label: string = 'Text'): Promise<void> => {
try {
await navigator.clipboard.writeText(text);
console.log(`${label} copied to clipboard`);
} catch (err) {
console.error('Failed to copy text: ', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
console.log(`${label} copied to clipboard (fallback method)`);
}
};

18
utils/common.ts Normal file
View File

@ -0,0 +1,18 @@
export const formatFileSize = (bytes: number): string => {
const units = ['B', 'KiB', 'MiB', 'GiB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
};
export const extractVersionFromTag = (tagName: string): string => {
// Extract version from tag like "mtm-vpn-android-v1.8.0-mtm-0.1.3"
const match = tagName.match(/v?(\d+\.\d+\.\d+(?:-mtm-\d+\.\d+\.\d+)?)/);
return match ? match[1] : tagName;
};

76
utils/downloads.ts Normal file
View File

@ -0,0 +1,76 @@
import { ApiError } from './api';
import { formatFileSize, extractVersionFromTag } from './common';
// GitHub Releases API interfaces
export interface GitHubAsset {
name: string;
size: number;
download_count: number;
browser_download_url: string;
created_at: string;
}
export interface GitHubAuthor {
login: string;
avatar_url: string;
}
export interface GitHubRelease {
id: number;
tag_name: string;
name: string;
body: string;
created_at: string;
published_at: string;
author: GitHubAuthor;
assets: GitHubAsset[];
}
export interface ProcessedRelease {
id: string;
version: string;
downloads: number;
releaseDate: Date;
fileSize: string;
downloadUrl: string;
tagName: string;
}
export interface DownloadStats {
totalDownloads: number;
releases: ProcessedRelease[];
}
export const fetchGitHubReleases = async (): Promise<DownloadStats> => {
const response = await fetch('/api/github/releases');
if (!response.ok) {
throw new ApiError(`Failed to fetch releases: ${response.statusText}`, response.status);
}
const releases: GitHubRelease[] = await response.json();
const processedReleases = releases
.filter(release => release.assets && release.assets.length > 0)
.map(release => {
// Find APK asset
const apkAsset = release.assets.find(asset => asset.name.endsWith('.apk'));
return {
id: release.id.toString(),
version: extractVersionFromTag(release.tag_name),
downloads: apkAsset ? apkAsset.download_count : 0,
releaseDate: new Date(release.published_at || release.created_at),
fileSize: apkAsset ? formatFileSize(apkAsset.size) : 'Unknown',
downloadUrl: apkAsset ? apkAsset.browser_download_url : '#',
tagName: release.tag_name
};
})
.sort((a, b) => b.releaseDate.getTime() - a.releaseDate.getTime());
const totalDownloads = processedReleases.reduce((sum, release) => sum + release.downloads, 0);
return {
totalDownloads,
releases: processedReleases
};
};

17
utils/explorer.ts Normal file
View File

@ -0,0 +1,17 @@
/**
* Generate blockchain explorer URLs for different networks
*/
export const getExplorerUrl = (hash: string, type: 'solana' | 'ethereum' | 'nym'): string => {
switch (type) {
case 'solana':
return `https://explorer.solana.com/tx/${hash}`;
case 'ethereum':
return `https://etherscan.io/tx/${hash}`;
case 'nym':
return `https://ping.pub/nyx/tx/${hash}`;
default:
return '#';
}
};
export type ExplorerType = 'solana' | 'ethereum' | 'nym';