Integrate APIs for showing Bridge transactions and total APK download #2
@ -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=
|
||||
|
||||
80
README.md
80
README.md
@ -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
|
||||
```
|
||||
|
||||
264
data/mockData.ts
264
data/mockData.ts
@ -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'
|
||||
}
|
||||
];
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
31
pages/api/github/releases.ts
Normal file
31
pages/api/github/releases.ts
Normal 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'
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
}
|
||||
43
utils/api.ts
43
utils/api.ts
@ -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
19
utils/clipboard.ts
Normal 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
18
utils/common.ts
Normal 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
76
utils/downloads.ts
Normal 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
17
utils/explorer.ts
Normal 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';
|
||||
Loading…
Reference in New Issue
Block a user