checkout-page

This commit is contained in:
kirukib 2025-12-21 21:44:04 +03:00
parent a973501f4a
commit 264f1e6159
35 changed files with 2512 additions and 0 deletions

20
.eslintrc.cjs Normal file
View File

@ -0,0 +1,20 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-explicit-any': 'warn',
},
}

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

10
env.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Amba Checkout</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

36
package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "amba-checkout",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"react-hook-form": "^7.48.2",
"react-icons": "^4.12.0",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}

7
postcss.config.js Normal file
View File

@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

35
src/App.tsx Normal file
View File

@ -0,0 +1,35 @@
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { CheckoutProvider } from './contexts/CheckoutContext';
import { Layout } from './components/layout/Layout';
import { CheckoutPage } from './pages/CheckoutPage';
import { AdminPage } from './pages/AdminPage';
import { TransactionsPage } from './pages/TransactionsPage';
import { SuspiciousPage } from './pages/SuspiciousPage';
import { AccountsPage } from './pages/AccountsPage';
function App() {
return (
<BrowserRouter>
<Layout>
<Routes>
<Route
path="/"
element={
<CheckoutProvider>
<CheckoutPage />
</CheckoutProvider>
}
/>
<Route path="/admin" element={<AdminPage />} />
<Route path="/admin/transactions" element={<TransactionsPage />} />
<Route path="/admin/suspicious" element={<SuspiciousPage />} />
<Route path="/admin/accounts" element={<AccountsPage />} />
</Routes>
</Layout>
</BrowserRouter>
);
}
export default App;

View File

@ -0,0 +1,269 @@
import React, { useEffect, useState } from 'react';
import { api } from '../../utils/api';
import type { Account } from '../../types';
import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { Input } from '../ui/Input';
import { FiFlag, FiX, FiSearch, FiUser, FiBriefcase } from 'react-icons/fi';
export const AccountFlagging: React.FC = () => {
const [accounts, setAccounts] = useState<Account[]>([]);
const [filteredAccounts, setFilteredAccounts] = useState<Account[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [flaggingAccount, setFlaggingAccount] = useState<Account | null>(null);
const [flagReason, setFlagReason] = useState('');
useEffect(() => {
const fetchAccounts = async () => {
try {
const data = await api.getAccounts();
setAccounts(data);
setFilteredAccounts(data);
} catch (error) {
console.error('Failed to fetch accounts:', error);
} finally {
setLoading(false);
}
};
fetchAccounts();
}, []);
useEffect(() => {
if (searchTerm) {
const filtered = accounts.filter(
(a) =>
a.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
a.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
a.id.toLowerCase().includes(searchTerm.toLowerCase())
);
setFilteredAccounts(filtered);
} else {
setFilteredAccounts(accounts);
}
}, [searchTerm, accounts]);
const handleFlag = async (accountId: string, reason: string) => {
try {
await api.flagAccount(accountId, reason);
const updated = await api.getAccounts();
setAccounts(updated);
setFlaggingAccount(null);
setFlagReason('');
} catch (error) {
console.error('Failed to flag account:', error);
}
};
const handleUnflag = async (accountId: string) => {
try {
await api.unflagAccount(accountId);
const updated = await api.getAccounts();
setAccounts(updated);
} catch (error) {
console.error('Failed to unflag account:', error);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-gray-600">Loading...</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Account Management</h1>
<p className="mt-2 text-gray-600">Flag or unflag accounts for suspicious activity</p>
</div>
{/* Search */}
<Card className="p-4 mb-6">
<div className="relative">
<FiSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search by email, name, or ID..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
</Card>
{/* Accounts List */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredAccounts.map((account) => (
<Card
key={account.id}
className={`p-6 ${account.isFlagged ? 'border-l-4 border-red-500' : ''}`}
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div
className={`w-12 h-12 rounded-lg flex items-center justify-center ${
account.accountType === 'business'
? 'bg-primary-100'
: 'bg-purple-100'
}`}
>
{account.accountType === 'business' ? (
<FiBriefcase className="w-6 h-6 text-primary-600" />
) : (
<FiUser className="w-6 h-6 text-purple-600" />
)}
</div>
<div>
<h3 className="font-semibold text-gray-900">
{account.name || 'Unknown User'}
</h3>
<p className="text-sm text-gray-500">{account.email}</p>
</div>
</div>
{account.isFlagged && (
<span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">
Flagged
</span>
)}
</div>
<div className="space-y-2 mb-4">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">Account Type</span>
<span className="font-medium text-gray-900 capitalize">
{account.accountType}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">Created</span>
<span className="font-medium text-gray-900">
{formatDate(account.createdAt)}
</span>
</div>
{account.isFlagged && account.flaggedReason && (
<div className="pt-2 border-t border-gray-200">
<p className="text-xs text-gray-600 mb-1">Flag Reason</p>
<p className="text-sm font-medium text-red-600">
{account.flaggedReason}
</p>
</div>
)}
{account.isFlagged && account.flaggedAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">Flagged At</span>
<span className="font-medium text-gray-900">
{formatDate(account.flaggedAt)}
</span>
</div>
)}
</div>
<div className="flex gap-2">
{account.isFlagged ? (
<Button
variant="outline"
size="sm"
onClick={() => handleUnflag(account.id)}
className="flex-1"
>
<FiX className="mr-2" />
Unflag
</Button>
) : (
<Button
variant="danger"
size="sm"
onClick={() => setFlaggingAccount(account)}
className="flex-1"
>
<FiFlag className="mr-2" />
Flag Account
</Button>
)}
</div>
</Card>
))}
</div>
{filteredAccounts.length === 0 && (
<Card className="p-12 text-center">
<p className="text-gray-500">No accounts found</p>
</Card>
)}
{/* Flag Account Modal */}
{flaggingAccount && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="max-w-md w-full">
<div className="p-6">
<div className="flex justify-between items-start mb-6">
<h2 className="text-2xl font-bold text-gray-900">Flag Account</h2>
<button
onClick={() => {
setFlaggingAccount(null);
setFlagReason('');
}}
className="text-gray-400 hover:text-gray-600"
>
×
</button>
</div>
<div className="mb-4">
<p className="text-sm text-gray-600 mb-2">
Flagging account: <span className="font-medium">{flaggingAccount.email}</span>
</p>
<Input
label="Reason for Flagging"
type="text"
placeholder="Enter reason..."
value={flagReason}
onChange={(e) => setFlagReason(e.target.value)}
required
/>
</div>
<div className="flex gap-3">
<Button
variant="danger"
onClick={() => {
if (flagReason.trim()) {
handleFlag(flaggingAccount.id, flagReason);
}
}}
disabled={!flagReason.trim()}
className="flex-1"
>
Flag Account
</Button>
<Button
variant="outline"
onClick={() => {
setFlaggingAccount(null);
setFlagReason('');
}}
className="flex-1"
>
Cancel
</Button>
</div>
</div>
</Card>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,221 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../utils/api';
import type { Transaction, Account } from '../../types';
import { Card } from '../ui/Card';
import { FiDollarSign, FiAlertTriangle, FiCheckCircle, FiClock, FiUsers } from 'react-icons/fi';
export const AdminDashboard: React.FC = () => {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [accounts, setAccounts] = useState<Account[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [txs, accts] = await Promise.all([
api.getTransactions(),
api.getAccounts(),
]);
setTransactions(txs);
setAccounts(accts);
} catch (error) {
console.error('Failed to fetch data:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const stats = {
totalTransactions: transactions.length,
pending: transactions.filter(t => t.status === 'pending').length,
suspicious: transactions.filter(t => t.status === 'suspicious').length,
approved: transactions.filter(t => t.status === 'approved').length,
totalAmount: transactions.reduce((sum, t) => sum + t.amount, 0),
flaggedAccounts: accounts.filter(a => a.isFlagged).length,
totalAccounts: accounts.length,
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-gray-600">Loading...</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Admin Dashboard</h1>
<p className="mt-2 text-gray-600">Overview of transactions and accounts</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Transactions</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.totalTransactions}</p>
</div>
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
<FiDollarSign className="w-6 h-6 text-primary-600" />
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Pending Review</p>
<p className="text-2xl font-bold text-yellow-600 mt-1">{stats.pending}</p>
</div>
<div className="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center">
<FiClock className="w-6 h-6 text-yellow-600" />
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Suspicious</p>
<p className="text-2xl font-bold text-red-600 mt-1">{stats.suspicious}</p>
</div>
<div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
<FiAlertTriangle className="w-6 h-6 text-red-600" />
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Approved</p>
<p className="text-2xl font-bold text-green-600 mt-1">{stats.approved}</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<FiCheckCircle className="w-6 h-6 text-green-600" />
</div>
</div>
</Card>
</div>
{/* Additional Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Amount</p>
<p className="text-2xl font-bold text-gray-900 mt-1">
{formatCurrency(stats.totalAmount)}
</p>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Flagged Accounts</p>
<p className="text-2xl font-bold text-red-600 mt-1">{stats.flaggedAccounts}</p>
</div>
<div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
<FiUsers className="w-6 h-6 text-red-600" />
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Accounts</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.totalAccounts}</p>
</div>
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
<FiUsers className="w-6 h-6 text-primary-600" />
</div>
</div>
</Card>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Quick Actions</h3>
<div className="space-y-3">
<Link
to="/admin/suspicious"
className="block w-full text-left px-4 py-3 bg-red-50 hover:bg-red-100 rounded-lg transition-colors"
>
<div className="flex items-center justify-between">
<span className="font-medium text-red-800">Review Suspicious Transactions</span>
<FiAlertTriangle className="w-5 h-5 text-red-600" />
</div>
</Link>
<Link
to="/admin/transactions"
className="block w-full text-left px-4 py-3 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors"
>
<div className="flex items-center justify-between">
<span className="font-medium text-primary-800">View All Transactions</span>
<FiDollarSign className="w-5 h-5 text-primary-600" />
</div>
</Link>
<Link
to="/admin/accounts"
className="block w-full text-left px-4 py-3 bg-gray-50 hover:bg-gray-100 rounded-lg transition-colors"
>
<div className="flex items-center justify-between">
<span className="font-medium text-gray-800">Manage Accounts</span>
<FiUsers className="w-5 h-5 text-gray-600" />
</div>
</Link>
</div>
</Card>
<Card className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Recent Activity</h3>
<div className="space-y-3">
{transactions.slice(0, 5).map((transaction) => (
<div
key={transaction.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div>
<p className="text-sm font-medium text-gray-900">
{formatCurrency(transaction.amount)}
</p>
<p className="text-xs text-gray-500">{transaction.recipient}</p>
</div>
<span
className={`px-2 py-1 text-xs font-medium rounded ${
transaction.status === 'approved'
? 'bg-green-100 text-green-800'
: transaction.status === 'suspicious' || transaction.status === 'rejected'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{transaction.status}
</span>
</div>
))}
</div>
</Card>
</div>
</div>
);
};

View File

@ -0,0 +1,190 @@
import React, { useEffect, useState } from 'react';
import { api } from '../../utils/api';
import type { Transaction } from '../../types';
import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { FiAlertTriangle, FiCheckCircle, FiXCircle } from 'react-icons/fi';
export const SuspiciousTransactions: React.FC = () => {
const [suspiciousTransactions, setSuspiciousTransactions] = useState<Transaction[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchSuspicious = async () => {
try {
const allTransactions = await api.getTransactions();
const suspicious = allTransactions.filter(
(t) => t.status === 'suspicious' || t.status === 'rejected'
);
setSuspiciousTransactions(suspicious);
} catch (error) {
console.error('Failed to fetch suspicious transactions:', error);
} finally {
setLoading(false);
}
};
fetchSuspicious();
}, []);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const handleApprove = async (id: string) => {
try {
await api.updateTransactionStatus(id, 'approved');
const allTransactions = await api.getTransactions();
const suspicious = allTransactions.filter(
(t) => t.status === 'suspicious' || t.status === 'rejected'
);
setSuspiciousTransactions(suspicious);
} catch (error) {
console.error('Failed to approve transaction:', error);
}
};
const handleReject = async (id: string) => {
try {
await api.updateTransactionStatus(id, 'rejected', 'Flagged as suspicious by admin');
const allTransactions = await api.getTransactions();
const suspicious = allTransactions.filter(
(t) => t.status === 'suspicious' || t.status === 'rejected'
);
setSuspiciousTransactions(suspicious);
} catch (error) {
console.error('Failed to reject transaction:', error);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-gray-600">Loading...</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<FiAlertTriangle className="w-8 h-8 text-red-600" />
<h1 className="text-3xl font-bold text-gray-900">Suspicious Transactions</h1>
</div>
<p className="mt-2 text-gray-600">Review and manage flagged transactions</p>
</div>
{suspiciousTransactions.length === 0 ? (
<Card className="p-12 text-center">
<FiCheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-900 mb-2">All Clear!</h3>
<p className="text-gray-600">No suspicious transactions to review.</p>
</Card>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{suspiciousTransactions.map((transaction) => (
<Card key={transaction.id} className="p-6 border-l-4 border-red-500">
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-2">
<FiAlertTriangle className="w-5 h-5 text-red-600" />
<span className="text-sm font-mono text-gray-500">
{transaction.id.slice(0, 8)}...
</span>
</div>
<h3 className="text-xl font-bold text-gray-900">
{formatCurrency(transaction.amount)}
</h3>
</div>
<span
className={`px-3 py-1 text-xs font-medium rounded ${
transaction.status === 'suspicious'
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
}`}
>
{transaction.status}
</span>
</div>
<div className="space-y-2 mb-4">
<div>
<p className="text-sm text-gray-600">Recipient</p>
<p className="font-medium text-gray-900">{transaction.recipient}</p>
{transaction.recipientEmail && (
<p className="text-sm text-gray-500">{transaction.recipientEmail}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-600">Account Type</p>
<p className="font-medium text-gray-900 capitalize">
{transaction.accountType}
</p>
</div>
<div>
<p className="text-sm text-gray-600">Date</p>
<p className="font-medium text-gray-900">
{formatDate(transaction.createdAt)}
</p>
</div>
</div>
{transaction.description && (
<div>
<p className="text-sm text-gray-600">Description</p>
<p className="font-medium text-gray-900">{transaction.description}</p>
</div>
)}
{transaction.rejectionReason && (
<div>
<p className="text-sm text-gray-600">Reason</p>
<p className="font-medium text-red-600">{transaction.rejectionReason}</p>
</div>
)}
</div>
<div className="flex gap-3 pt-4 border-t border-gray-200">
<Button
variant="primary"
size="sm"
onClick={() => handleApprove(transaction.id)}
className="flex-1"
>
<FiCheckCircle className="mr-2" />
Approve
</Button>
<Button
variant="danger"
size="sm"
onClick={() => handleReject(transaction.id)}
className="flex-1"
>
<FiXCircle className="mr-2" />
Reject
</Button>
</div>
</Card>
))}
</div>
)}
</div>
);
};

View File

@ -0,0 +1,361 @@
import React, { useEffect, useState } from 'react';
import { api } from '../../utils/api';
import type { Transaction } from '../../types';
import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { FiSearch, FiFilter, FiEye } from 'react-icons/fi';
export const TransactionList: React.FC = () => {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [filteredTransactions, setFilteredTransactions] = useState<Transaction[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [selectedTransaction, setSelectedTransaction] = useState<Transaction | null>(null);
useEffect(() => {
const fetchTransactions = async () => {
try {
const data = await api.getTransactions();
setTransactions(data);
setFilteredTransactions(data);
} catch (error) {
console.error('Failed to fetch transactions:', error);
} finally {
setLoading(false);
}
};
fetchTransactions();
}, []);
useEffect(() => {
let filtered = transactions;
if (searchTerm) {
filtered = filtered.filter(
(t) =>
t.recipient.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.recipientEmail?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
if (statusFilter !== 'all') {
filtered = filtered.filter((t) => t.status === statusFilter);
}
setFilteredTransactions(filtered);
}, [searchTerm, statusFilter, transactions]);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getStatusColor = (status: Transaction['status']) => {
switch (status) {
case 'approved':
return 'bg-green-100 text-green-800';
case 'rejected':
return 'bg-red-100 text-red-800';
case 'suspicious':
return 'bg-yellow-100 text-yellow-800';
default:
return 'bg-blue-100 text-blue-800';
}
};
const handleStatusUpdate = async (id: string, status: Transaction['status']) => {
try {
await api.updateTransactionStatus(id, status);
const updated = await api.getTransactions();
setTransactions(updated);
} catch (error) {
console.error('Failed to update transaction:', error);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-gray-600">Loading...</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Transactions</h1>
<p className="mt-2 text-gray-600">View and manage all transactions</p>
</div>
{/* Filters */}
<Card className="p-4 mb-6">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1 relative">
<FiSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search by recipient, ID, or email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="flex items-center gap-2">
<FiFilter className="text-gray-400" />
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="all">All Status</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="suspicious">Suspicious</option>
</select>
</div>
</div>
</Card>
{/* Transaction Table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<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">
ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Recipient
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Amount
</th>
<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">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredTransactions.map((transaction) => (
<tr key={transaction.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-mono text-gray-900">
{transaction.id.slice(0, 8)}...
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{transaction.recipient}
</div>
{transaction.recipientEmail && (
<div className="text-sm text-gray-500">{transaction.recipientEmail}</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{formatCurrency(transaction.amount)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-500 capitalize">
{transaction.accountType}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-medium rounded ${getStatusColor(
transaction.status
)}`}
>
{transaction.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(transaction.createdAt)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedTransaction(transaction)}
className="text-primary-600 hover:text-primary-900"
>
<FiEye className="w-5 h-5" />
</button>
{transaction.status === 'pending' && (
<>
<Button
size="sm"
variant="primary"
onClick={() => handleStatusUpdate(transaction.id, 'approved')}
>
Approve
</Button>
<Button
size="sm"
variant="danger"
onClick={() => handleStatusUpdate(transaction.id, 'rejected')}
>
Reject
</Button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredTransactions.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500">No transactions found</p>
</div>
)}
</Card>
{/* Transaction Detail Modal */}
{selectedTransaction && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex justify-between items-start mb-6">
<h2 className="text-2xl font-bold text-gray-900">Transaction Details</h2>
<button
onClick={() => setSelectedTransaction(null)}
className="text-gray-400 hover:text-gray-600"
>
×
</button>
</div>
<div className="space-y-4">
<div>
<p className="text-sm text-gray-600">Transaction ID</p>
<p className="font-mono text-sm font-medium text-gray-900">
{selectedTransaction.id}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-600">Amount</p>
<p className="text-lg font-bold text-gray-900">
{formatCurrency(selectedTransaction.amount)}
</p>
</div>
<div>
<p className="text-sm text-gray-600">Status</p>
<span
className={`inline-block px-3 py-1 text-sm font-medium rounded ${getStatusColor(
selectedTransaction.status
)}`}
>
{selectedTransaction.status}
</span>
</div>
</div>
<div>
<p className="text-sm text-gray-600">Recipient</p>
<p className="font-medium text-gray-900">{selectedTransaction.recipient}</p>
{selectedTransaction.recipientEmail && (
<p className="text-sm text-gray-500">{selectedTransaction.recipientEmail}</p>
)}
</div>
<div>
<p className="text-sm text-gray-600">Account Type</p>
<p className="font-medium text-gray-900 capitalize">
{selectedTransaction.accountType}
</p>
</div>
{selectedTransaction.description && (
<div>
<p className="text-sm text-gray-600">Description</p>
<p className="font-medium text-gray-900">
{selectedTransaction.description}
</p>
</div>
)}
<div>
<p className="text-sm text-gray-600">Created At</p>
<p className="font-medium text-gray-900">
{formatDate(selectedTransaction.createdAt)}
</p>
</div>
{selectedTransaction.rejectionReason && (
<div>
<p className="text-sm text-gray-600">Rejection Reason</p>
<p className="font-medium text-red-600">
{selectedTransaction.rejectionReason}
</p>
</div>
)}
</div>
<div className="mt-6 flex gap-4">
{selectedTransaction.status === 'pending' && (
<>
<Button
variant="primary"
onClick={() => {
handleStatusUpdate(selectedTransaction.id, 'approved');
setSelectedTransaction(null);
}}
>
Approve
</Button>
<Button
variant="danger"
onClick={() => {
handleStatusUpdate(selectedTransaction.id, 'rejected');
setSelectedTransaction(null);
}}
>
Reject
</Button>
</>
)}
<Button variant="outline" onClick={() => setSelectedTransaction(null)}>
Close
</Button>
</div>
</div>
</Card>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,87 @@
import React from 'react';
import { Card } from '../ui/Card';
import type { AccountType } from '../../types';
import { FiBriefcase, FiUser } from 'react-icons/fi';
interface AccountSelectionProps {
selectedAccountType?: AccountType;
onSelect: (accountType: AccountType) => void;
onNext?: () => void;
}
export const AccountSelection: React.FC<AccountSelectionProps> = ({
selectedAccountType,
onSelect,
onNext,
}) => {
return (
<div className="w-full">
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="grid grid-cols-2 gap-2">
<div className="w-3 h-3 rounded-full bg-primary-500"></div>
<div className="w-3 h-3 rounded-full bg-primary-500"></div>
<div className="w-3 h-3 rounded-full bg-primary-500"></div>
<div className="w-3 h-3 rounded-full bg-primary-500"></div>
</div>
</div>
<h2 className="text-2xl md:text-3xl font-bold text-gray-800 mb-2">
Choose your account type
</h2>
<p className="text-gray-600 text-sm md:text-base max-w-md mx-auto">
It is a long established fact that a reader will be distracted by the readable content of a page
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto mb-6">
<Card
selected={selectedAccountType === 'business'}
onClick={() => onSelect('business')}
className="p-6"
>
<div className="flex items-start gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 rounded-lg bg-primary-100 flex items-center justify-center">
<FiBriefcase className="w-6 h-6 text-primary-600" />
</div>
</div>
<div className="flex-1">
<h3 className="text-lg font-bold text-gray-800 mb-1">Business</h3>
<p className="text-sm text-gray-600">Search or find a home</p>
</div>
</div>
</Card>
<Card
selected={selectedAccountType === 'personal'}
onClick={() => onSelect('personal')}
className="p-6"
>
<div className="flex items-start gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 rounded-lg bg-primary-100 flex items-center justify-center">
<FiUser className="w-6 h-6 text-primary-600" />
</div>
</div>
<div className="flex-1">
<h3 className="text-lg font-bold text-gray-800 mb-1">Personal</h3>
<p className="text-sm text-gray-600">Post or share your home</p>
</div>
</div>
</Card>
</div>
{selectedAccountType && onNext && (
<div className="max-w-2xl mx-auto">
<button
onClick={onNext}
className="w-full bg-primary-500 hover:bg-primary-600 text-white font-medium py-3 px-6 rounded-lg transition-colors"
>
Next Step
</button>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,163 @@
import React from 'react';
import { useCheckout } from '../../contexts/CheckoutContext';
import { FundRequestForm } from './FundRequestForm';
import { AccountSelection } from './AccountSelection';
import { TransactionConfirmation } from './TransactionConfirmation';
import { TransactionStatus } from './TransactionStatus';
import { PromotionalPanel } from './PromotionalPanel';
import { FiCheck } from 'react-icons/fi';
export const CheckoutFlow: React.FC = () => {
const {
currentStep,
fundRequest,
selectedAccountType,
transaction,
isLoading,
error,
setFundRequest,
setAccountType,
submitTransaction,
reset,
goToStep,
} = useCheckout();
const handleFundRequestSubmit = (data: any) => {
setFundRequest(data);
goToStep(2);
};
const handleAccountSelect = (accountType: 'business' | 'personal') => {
setAccountType(accountType);
};
const handleConfirm = async () => {
await submitTransaction();
};
const handleCancel = () => {
goToStep(1);
};
const steps = [
{ number: 1, label: 'Request', title: 'Fund Request' },
{ number: 2, label: 'Account', title: 'Account Type' },
{ number: 3, label: 'Review', title: 'Confirmation' },
{ number: 4, label: 'Status', title: 'Status' },
];
return (
<div className="min-h-screen bg-gray-50 py-4 md:py-8 px-4">
<div className="max-w-7xl mx-auto">
{/* Progress Indicator - Hidden on account selection step to match design */}
{currentStep !== 2 && (
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
{steps.map((step, index) => (
<React.Fragment key={step.number}>
<div className="flex flex-col items-center flex-1">
<div
className={`
w-10 h-10 rounded-full flex items-center justify-center
transition-colors
${
currentStep > step.number
? 'bg-primary-500 text-white'
: currentStep === step.number
? 'bg-primary-500 text-white ring-4 ring-primary-200'
: 'bg-gray-200 text-gray-600'
}
`}
>
{currentStep > step.number ? (
<FiCheck className="w-5 h-5" />
) : (
<span className="text-sm font-medium">{step.number}</span>
)}
</div>
<span
className={`
mt-2 text-xs font-medium hidden sm:block
${
currentStep >= step.number
? 'text-primary-600'
: 'text-gray-500'
}
`}
>
{step.label}
</span>
</div>
{index < steps.length - 1 && (
<div
className={`
flex-1 h-1 mx-2 transition-colors
${
currentStep > step.number
? 'bg-primary-500'
: 'bg-gray-200'
}
`}
/>
)}
</React.Fragment>
))}
</div>
</div>
)}
{/* Error Display */}
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
{/* Step Content */}
{currentStep === 2 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-[600px] rounded-lg overflow-hidden shadow-lg">
<div className="hidden lg:block">
<PromotionalPanel />
</div>
<div className="bg-white p-6 md:p-8 lg:p-12 flex items-center">
<AccountSelection
selectedAccountType={selectedAccountType || undefined}
onSelect={handleAccountSelect}
onNext={() => goToStep(3)}
/>
</div>
</div>
) : (
<div className="bg-white rounded-lg shadow-sm p-6 md:p-8">
{currentStep === 1 && (
<FundRequestForm
onSubmit={handleFundRequestSubmit}
initialData={fundRequest || undefined}
/>
)}
{currentStep === 3 && fundRequest && selectedAccountType && (
<TransactionConfirmation
transactionData={{
...fundRequest,
accountType: selectedAccountType,
}}
onConfirm={handleConfirm}
onCancel={handleCancel}
isLoading={isLoading}
/>
)}
{currentStep === 4 && transaction && (
<TransactionStatus
transaction={transaction}
onNewTransaction={reset}
/>
)}
</div>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,111 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { Input } from '../ui/Input';
import { Button } from '../ui/Button';
import type { FundRequest } from '../../types';
interface FundRequestFormProps {
onSubmit: (data: FundRequest) => void;
initialData?: Partial<FundRequest>;
}
export const FundRequestForm: React.FC<FundRequestFormProps> = ({
onSubmit,
initialData,
}) => {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FundRequest>({
defaultValues: initialData,
});
return (
<div className="w-full max-w-md mx-auto">
<div className="text-center mb-8">
<h2 className="text-2xl md:text-3xl font-bold text-gray-800 mb-2">
Request Funds Transfer
</h2>
<p className="text-gray-600 text-sm md:text-base">
Enter the details for the funds you want to transfer
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<Input
label="Amount"
type="number"
step="0.01"
min="0.01"
placeholder="0.00"
error={errors.amount?.message}
{...register('amount', {
required: 'Amount is required',
min: { value: 0.01, message: 'Amount must be greater than 0' },
valueAsNumber: true,
})}
/>
<Input
label="Recipient Name"
type="text"
placeholder="John Doe"
error={errors.recipient?.message}
{...register('recipient', {
required: 'Recipient name is required',
minLength: { value: 2, message: 'Name must be at least 2 characters' },
})}
/>
<Input
label="Recipient Email"
type="email"
placeholder="recipient@example.com"
error={errors.recipientEmail?.message}
{...register('recipientEmail', {
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address',
},
})}
/>
<div>
<label
htmlFor="description"
className="block text-sm font-medium text-gray-700 mb-1"
>
Description (Optional)
</label>
<textarea
id="description"
rows={4}
placeholder="Add a note about this transaction..."
className={`
w-full px-4 py-2 border rounded-lg
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
transition-colors
${errors.description ? 'border-red-500' : 'border-gray-300'}
`}
{...register('description')}
/>
{errors.description && (
<p className="mt-1 text-sm text-red-600">{errors.description.message}</p>
)}
</div>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
isLoading={isSubmitting}
>
Continue
</Button>
</form>
</div>
);
};

View File

@ -0,0 +1,30 @@
import React from 'react';
export const PromotionalPanel: React.FC = () => {
return (
<div className="bg-primary-500 h-full flex flex-col justify-between p-8 md:p-12 text-white">
<div>
<div className="mb-8">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-white rounded"></div>
<span className="text-xl font-bold">sanswap</span>
</div>
</div>
<div className="flex-1 flex flex-col justify-center">
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-4">
Start searching for homes or posting ads now.
</h1>
<p className="text-primary-100 text-sm md:text-base max-w-md">
Interfaces are well designed for all ages and target audiences are extremely simple and work with social media integrations
</p>
</div>
</div>
<div className="flex gap-2 justify-center">
<div className="w-2 h-2 bg-white rounded-full"></div>
<div className="w-2 h-2 bg-white rounded-full"></div>
<div className="w-2 h-2 bg-white rounded-full"></div>
</div>
</div>
);
};

View File

@ -0,0 +1,114 @@
import React from 'react';
import { Button } from '../ui/Button';
import { Card } from '../ui/Card';
import type { FundRequest } from '../../types';
import { FiCheckCircle, FiXCircle, FiAlertTriangle } from 'react-icons/fi';
interface TransactionConfirmationProps {
transactionData: FundRequest & { accountType: 'business' | 'personal' };
onConfirm: () => void;
onCancel: () => void;
isLoading?: boolean;
}
export const TransactionConfirmation: React.FC<TransactionConfirmationProps> = ({
transactionData,
onConfirm,
onCancel,
isLoading = false,
}) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
};
return (
<div className="w-full max-w-2xl mx-auto">
<div className="text-center mb-8">
<h2 className="text-2xl md:text-3xl font-bold text-gray-800 mb-2">
Review Transaction
</h2>
<p className="text-gray-600 text-sm md:text-base">
Please review the details before confirming
</p>
</div>
<Card className="p-6 md:p-8 mb-6">
<div className="space-y-4">
<div className="flex justify-between items-center pb-4 border-b border-gray-200">
<span className="text-gray-600">Amount</span>
<span className="text-2xl font-bold text-gray-800">
{formatCurrency(transactionData.amount)}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4">
<div>
<p className="text-sm text-gray-600 mb-1">Recipient</p>
<p className="font-medium text-gray-800">{transactionData.recipient}</p>
</div>
{transactionData.recipientEmail && (
<div>
<p className="text-sm text-gray-600 mb-1">Email</p>
<p className="font-medium text-gray-800">{transactionData.recipientEmail}</p>
</div>
)}
<div>
<p className="text-sm text-gray-600 mb-1">Account Type</p>
<p className="font-medium text-gray-800 capitalize">
{transactionData.accountType}
</p>
</div>
{transactionData.description && (
<div className="md:col-span-2">
<p className="text-sm text-gray-600 mb-1">Description</p>
<p className="font-medium text-gray-800">{transactionData.description}</p>
</div>
)}
</div>
</div>
</Card>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<div className="flex items-start gap-3">
<FiAlertTriangle className="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-yellow-800 mb-1">
Transaction Review
</p>
<p className="text-sm text-yellow-700">
This transaction will be reviewed for suspicious activity. You will be notified once the review is complete.
</p>
</div>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-4">
<Button
variant="outline"
size="lg"
className="flex-1"
onClick={onCancel}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="primary"
size="lg"
className="flex-1"
onClick={onConfirm}
isLoading={isLoading}
>
Confirm Transaction
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,156 @@
import React from 'react';
import { Button } from '../ui/Button';
import { Card } from '../ui/Card';
import type { Transaction } from '../../types';
import { FiCheckCircle, FiXCircle, FiClock, FiAlertCircle } from 'react-icons/fi';
interface TransactionStatusProps {
transaction: Transaction;
onNewTransaction?: () => void;
}
export const TransactionStatus: React.FC<TransactionStatusProps> = ({
transaction,
onNewTransaction,
}) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
};
const getStatusConfig = () => {
switch (transaction.status) {
case 'approved':
return {
icon: FiCheckCircle,
title: 'Transaction Approved',
message: 'Your transaction has been successfully processed and approved.',
color: 'text-green-600',
bgColor: 'bg-green-50',
borderColor: 'border-green-200',
iconColor: 'text-green-600',
};
case 'rejected':
return {
icon: FiXCircle,
title: 'Transaction Rejected',
message: transaction.rejectionReason || 'Your transaction has been flagged as suspicious and rejected.',
color: 'text-red-600',
bgColor: 'bg-red-50',
borderColor: 'border-red-200',
iconColor: 'text-red-600',
};
case 'suspicious':
return {
icon: FiAlertCircle,
title: 'Under Review',
message: 'Your transaction is being reviewed for suspicious activity. You will be notified once the review is complete.',
color: 'text-yellow-600',
bgColor: 'bg-yellow-50',
borderColor: 'border-yellow-200',
iconColor: 'text-yellow-600',
};
default:
return {
icon: FiClock,
title: 'Transaction Pending',
message: 'Your transaction is being processed. Please wait for confirmation.',
color: 'text-blue-600',
bgColor: 'bg-blue-50',
borderColor: 'border-blue-200',
iconColor: 'text-blue-600',
};
}
};
const statusConfig = getStatusConfig();
const Icon = statusConfig.icon;
return (
<div className="w-full max-w-2xl mx-auto">
<Card className={`p-8 ${statusConfig.bgColor} ${statusConfig.borderColor}`}>
<div className="text-center mb-6">
<div className="flex justify-center mb-4">
<div className={`w-16 h-16 rounded-full ${statusConfig.bgColor} border-2 ${statusConfig.borderColor} flex items-center justify-center`}>
<Icon className={`w-8 h-8 ${statusConfig.iconColor}`} />
</div>
</div>
<h2 className={`text-2xl md:text-3xl font-bold ${statusConfig.color} mb-2`}>
{statusConfig.title}
</h2>
<p className="text-gray-600 text-sm md:text-base">
{statusConfig.message}
</p>
</div>
<div className="bg-white rounded-lg p-6 mb-6">
<div className="space-y-4">
<div className="flex justify-between items-center pb-4 border-b border-gray-200">
<span className="text-gray-600">Transaction ID</span>
<span className="font-mono text-sm font-medium text-gray-800">
{transaction.id}
</span>
</div>
<div className="flex justify-between items-center pb-4 border-b border-gray-200">
<span className="text-gray-600">Amount</span>
<span className="text-xl font-bold text-gray-800">
{formatCurrency(transaction.amount)}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4">
<div>
<p className="text-sm text-gray-600 mb-1">Recipient</p>
<p className="font-medium text-gray-800">{transaction.recipient}</p>
</div>
{transaction.recipientEmail && (
<div>
<p className="text-sm text-gray-600 mb-1">Email</p>
<p className="font-medium text-gray-800">{transaction.recipientEmail}</p>
</div>
)}
<div>
<p className="text-sm text-gray-600 mb-1">Account Type</p>
<p className="font-medium text-gray-800 capitalize">
{transaction.accountType}
</p>
</div>
<div>
<p className="text-sm text-gray-600 mb-1">Status</p>
<p className="font-medium text-gray-800 capitalize">
{transaction.status}
</p>
</div>
{transaction.description && (
<div className="md:col-span-2">
<p className="text-sm text-gray-600 mb-1">Description</p>
<p className="font-medium text-gray-800">{transaction.description}</p>
</div>
)}
</div>
</div>
</div>
{onNewTransaction && (
<div className="text-center">
<Button
variant="primary"
size="lg"
onClick={onNewTransaction}
>
Create New Transaction
</Button>
</div>
)}
</Card>
</div>
);
};

View File

@ -0,0 +1,86 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { FiHome, FiShield, FiUsers, FiAlertTriangle } from 'react-icons/fi';
interface LayoutProps {
children: React.ReactNode;
}
export const Layout: React.FC<LayoutProps> = ({ children }) => {
const location = useLocation();
const isAdmin = location.pathname.startsWith('/admin');
return (
<div className="min-h-screen bg-gray-50">
{isAdmin && (
<nav className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<span className="text-xl font-bold text-primary-600">Amba Checkout</span>
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
<Link
to="/admin"
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${
location.pathname === '/admin'
? 'border-primary-500 text-gray-900'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<FiHome className="mr-2" />
Dashboard
</Link>
<Link
to="/admin/transactions"
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${
location.pathname === '/admin/transactions'
? 'border-primary-500 text-gray-900'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<FiShield className="mr-2" />
Transactions
</Link>
<Link
to="/admin/suspicious"
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${
location.pathname === '/admin/suspicious'
? 'border-primary-500 text-gray-900'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<FiAlertTriangle className="mr-2" />
Suspicious
</Link>
<Link
to="/admin/accounts"
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${
location.pathname === '/admin/accounts'
? 'border-primary-500 text-gray-900'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<FiUsers className="mr-2" />
Accounts
</Link>
</div>
</div>
<div className="flex items-center">
<Link
to="/"
className="text-sm text-gray-500 hover:text-gray-700"
>
Back to Checkout
</Link>
</div>
</div>
</div>
</nav>
)}
<main>{children}</main>
</div>
);
};

View File

@ -0,0 +1,54 @@
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'danger';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
children: React.ReactNode;
}
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
isLoading = false,
className = '',
disabled,
children,
...props
}) => {
const baseStyles = 'font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
const variantStyles = {
primary: 'bg-primary-500 text-white hover:bg-primary-600 focus:ring-primary-500',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500',
outline: 'border-2 border-primary-500 text-primary-500 hover:bg-primary-50 focus:ring-primary-500',
danger: 'bg-red-500 text-white hover:bg-red-600 focus:ring-red-500',
};
const sizeStyles = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
return (
<button
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<span className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Loading...
</span>
) : (
children
)}
</button>
);
};

View File

@ -0,0 +1,33 @@
import React from 'react';
interface CardProps {
children: React.ReactNode;
className?: string;
onClick?: () => void;
selected?: boolean;
}
export const Card: React.FC<CardProps> = ({
children,
className = '',
onClick,
selected = false,
}) => {
const baseStyles = 'rounded-lg border transition-all';
const selectedStyles = selected
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 bg-white hover:border-gray-300';
const clickableStyles = onClick ? 'cursor-pointer' : '';
return (
<div
className={`${baseStyles} ${selectedStyles} ${clickableStyles} ${className}`}
onClick={onClick}
>
{children}
</div>
);
};

View File

@ -0,0 +1,49 @@
import React from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
}
export const Input: React.FC<InputProps> = ({
label,
error,
helperText,
className = '',
id,
...props
}) => {
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-gray-700 mb-1"
>
{label}
</label>
)}
<input
id={inputId}
className={`
w-full px-4 py-2 border rounded-lg
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
transition-colors
${error ? 'border-red-500' : 'border-gray-300'}
${className}
`}
{...props}
/>
{error && (
<p className="mt-1 text-sm text-red-600">{error}</p>
)}
{helperText && !error && (
<p className="mt-1 text-sm text-gray-500">{helperText}</p>
)}
</div>
);
};

View File

@ -0,0 +1,107 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import type { FundRequest, AccountType, Transaction } from '../types';
import { api } from '../utils/api';
interface CheckoutContextType {
currentStep: number;
fundRequest: Partial<FundRequest> | null;
selectedAccountType: AccountType | null;
transaction: Transaction | null;
isLoading: boolean;
error: string | null;
setFundRequest: (data: Partial<FundRequest>) => void;
setAccountType: (type: AccountType) => void;
submitTransaction: () => Promise<void>;
reset: () => void;
goToStep: (step: number) => void;
}
const CheckoutContext = createContext<CheckoutContextType | undefined>(undefined);
export const CheckoutProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [currentStep, setCurrentStep] = useState(1);
const [fundRequest, setFundRequestState] = useState<Partial<FundRequest> | null>(null);
const [selectedAccountType, setSelectedAccountType] = useState<AccountType | null>(null);
const [transaction, setTransaction] = useState<Transaction | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const setFundRequest = (data: Partial<FundRequest>) => {
setFundRequestState(data);
setError(null);
};
const setAccountType = (type: AccountType) => {
setSelectedAccountType(type);
setError(null);
};
const submitTransaction = async () => {
if (!fundRequest || !selectedAccountType) {
setError('Please complete all required fields');
return;
}
setIsLoading(true);
setError(null);
try {
const transactionData: FundRequest = {
...fundRequest,
accountType: selectedAccountType,
} as FundRequest;
const newTransaction = await api.createTransaction(transactionData);
setTransaction(newTransaction);
setCurrentStep(4);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create transaction');
} finally {
setIsLoading(false);
}
};
const reset = () => {
setCurrentStep(1);
setFundRequestState(null);
setSelectedAccountType(null);
setTransaction(null);
setError(null);
setIsLoading(false);
};
const goToStep = (step: number) => {
if (step >= 1 && step <= 4) {
setCurrentStep(step);
}
};
return (
<CheckoutContext.Provider
value={{
currentStep,
fundRequest,
selectedAccountType,
transaction,
isLoading,
error,
setFundRequest,
setAccountType,
submitTransaction,
reset,
goToStep,
}}
>
{children}
</CheckoutContext.Provider>
);
};
export const useCheckout = () => {
const context = useContext(CheckoutContext);
if (context === undefined) {
throw new Error('useCheckout must be used within a CheckoutProvider');
}
return context;
};

17
src/index.css Normal file
View File

@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}

11
src/main.tsx Normal file
View File

@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@ -0,0 +1,7 @@
import React from 'react';
import { AccountFlagging } from '../components/admin/AccountFlagging';
export const AccountsPage: React.FC = () => {
return <AccountFlagging />;
};

7
src/pages/AdminPage.tsx Normal file
View File

@ -0,0 +1,7 @@
import React from 'react';
import { AdminDashboard } from '../components/admin/AdminDashboard';
export const AdminPage: React.FC = () => {
return <AdminDashboard />;
};

View File

@ -0,0 +1,11 @@
import React from 'react';
import { CheckoutFlow } from '../components/checkout/CheckoutFlow';
export const CheckoutPage: React.FC = () => {
return (
<div className="min-h-screen bg-gray-50">
<CheckoutFlow />
</div>
);
};

View File

@ -0,0 +1,7 @@
import React from 'react';
import { SuspiciousTransactions } from '../components/admin/SuspiciousTransactions';
export const SuspiciousPage: React.FC = () => {
return <SuspiciousTransactions />;
};

View File

@ -0,0 +1,7 @@
import React from 'react';
import { TransactionList } from '../components/admin/TransactionList';
export const TransactionsPage: React.FC = () => {
return <TransactionList />;
};

38
src/types/index.ts Normal file
View File

@ -0,0 +1,38 @@
export type AccountType = 'business' | 'personal';
export type TransactionStatus = 'pending' | 'approved' | 'rejected' | 'suspicious';
export interface Transaction {
id: string;
amount: number;
recipient: string;
recipientEmail?: string;
description?: string;
accountType: AccountType;
status: TransactionStatus;
createdAt: string;
reviewedBy?: string;
reviewedAt?: string;
rejectionReason?: string;
}
export interface Account {
id: string;
email: string;
name?: string;
accountType: AccountType;
isFlagged: boolean;
flaggedReason?: string;
flaggedAt?: string;
flaggedBy?: string;
createdAt: string;
}
export interface FundRequest {
amount: number;
recipient: string;
recipientEmail?: string;
description?: string;
accountType?: AccountType;
}

156
src/utils/api.ts Normal file
View File

@ -0,0 +1,156 @@
import type { Transaction, Account, FundRequest } from '../types';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api';
// Mock data for development
let mockTransactions: Transaction[] = [
{
id: '1',
amount: 5000,
recipient: 'John Doe',
recipientEmail: 'john@example.com',
description: 'Payment for services',
accountType: 'business',
status: 'pending',
createdAt: new Date().toISOString(),
},
{
id: '2',
amount: 1200,
recipient: 'Jane Smith',
recipientEmail: 'jane@example.com',
description: 'Refund',
accountType: 'personal',
status: 'suspicious',
createdAt: new Date(Date.now() - 86400000).toISOString(),
},
];
let mockAccounts: Account[] = [
{
id: '1',
email: 'user1@example.com',
name: 'User One',
accountType: 'business',
isFlagged: false,
createdAt: new Date().toISOString(),
},
{
id: '2',
email: 'user2@example.com',
name: 'User Two',
accountType: 'personal',
isFlagged: true,
flaggedReason: 'Multiple suspicious transactions',
flaggedAt: new Date(Date.now() - 172800000).toISOString(),
createdAt: new Date(Date.now() - 2592000000).toISOString(),
},
];
// API Client
class ApiClient {
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
throw new Error(`API Error: ${response.statusText}`);
}
return response.json();
}
// Transaction endpoints
async createTransaction(data: FundRequest): Promise<Transaction> {
// Mock implementation
const transaction: Transaction = {
id: Date.now().toString(),
...data,
accountType: data.accountType || 'personal',
status: 'pending',
createdAt: new Date().toISOString(),
};
mockTransactions.unshift(transaction);
return Promise.resolve(transaction);
}
async getTransactions(): Promise<Transaction[]> {
// Mock implementation
return Promise.resolve(mockTransactions);
}
async getTransaction(id: string): Promise<Transaction> {
// Mock implementation
const transaction = mockTransactions.find(t => t.id === id);
if (!transaction) {
throw new Error('Transaction not found');
}
return Promise.resolve(transaction);
}
async updateTransactionStatus(
id: string,
status: Transaction['status'],
reason?: string
): Promise<Transaction> {
// Mock implementation
const transaction = mockTransactions.find(t => t.id === id);
if (!transaction) {
throw new Error('Transaction not found');
}
transaction.status = status;
transaction.reviewedAt = new Date().toISOString();
if (reason) {
transaction.rejectionReason = reason;
}
return Promise.resolve(transaction);
}
// Account endpoints
async getAccounts(): Promise<Account[]> {
// Mock implementation
return Promise.resolve(mockAccounts);
}
async getAccount(id: string): Promise<Account> {
// Mock implementation
const account = mockAccounts.find(a => a.id === id);
if (!account) {
throw new Error('Account not found');
}
return Promise.resolve(account);
}
async flagAccount(id: string, reason: string): Promise<Account> {
// Mock implementation
const account = mockAccounts.find(a => a.id === id);
if (!account) {
throw new Error('Account not found');
}
account.isFlagged = true;
account.flaggedReason = reason;
account.flaggedAt = new Date().toISOString();
return Promise.resolve(account);
}
async unflagAccount(id: string): Promise<Account> {
// Mock implementation
const account = mockAccounts.find(a => a.id === id);
if (!account) {
throw new Error('Account not found');
}
account.isFlagged = false;
account.flaggedReason = undefined;
account.flaggedAt = undefined;
return Promise.resolve(account);
}
}
export const api = new ApiClient();

28
tailwind.config.js Normal file
View File

@ -0,0 +1,28 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#10B981',
50: '#ECFDF5',
100: '#D1FAE5',
200: '#A7F3D0',
300: '#6EE7B7',
400: '#34D399',
500: '#10B981',
600: '#059669',
700: '#047857',
800: '#065F46',
900: '#064E3B',
},
},
},
},
plugins: [],
}

26
tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
tsconfig.node.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

8
vite.config.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})