checkout-page
This commit is contained in:
parent
a973501f4a
commit
264f1e6159
20
.eslintrc.cjs
Normal file
20
.eslintrc.cjs
Normal 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
25
.gitignore
vendored
Normal 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
10
env.d.ts
vendored
Normal 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
14
index.html
Normal 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
36
package.json
Normal 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
7
postcss.config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
35
src/App.tsx
Normal file
35
src/App.tsx
Normal 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;
|
||||
|
||||
269
src/components/admin/AccountFlagging.tsx
Normal file
269
src/components/admin/AccountFlagging.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
221
src/components/admin/AdminDashboard.tsx
Normal file
221
src/components/admin/AdminDashboard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
190
src/components/admin/SuspiciousTransactions.tsx
Normal file
190
src/components/admin/SuspiciousTransactions.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
361
src/components/admin/TransactionList.tsx
Normal file
361
src/components/admin/TransactionList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
87
src/components/checkout/AccountSelection.tsx
Normal file
87
src/components/checkout/AccountSelection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
163
src/components/checkout/CheckoutFlow.tsx
Normal file
163
src/components/checkout/CheckoutFlow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
111
src/components/checkout/FundRequestForm.tsx
Normal file
111
src/components/checkout/FundRequestForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
30
src/components/checkout/PromotionalPanel.tsx
Normal file
30
src/components/checkout/PromotionalPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
114
src/components/checkout/TransactionConfirmation.tsx
Normal file
114
src/components/checkout/TransactionConfirmation.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
156
src/components/checkout/TransactionStatus.tsx
Normal file
156
src/components/checkout/TransactionStatus.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
86
src/components/layout/Layout.tsx
Normal file
86
src/components/layout/Layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
54
src/components/ui/Button.tsx
Normal file
54
src/components/ui/Button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
33
src/components/ui/Card.tsx
Normal file
33
src/components/ui/Card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
49
src/components/ui/Input.tsx
Normal file
49
src/components/ui/Input.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
107
src/contexts/CheckoutContext.tsx
Normal file
107
src/contexts/CheckoutContext.tsx
Normal 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
17
src/index.css
Normal 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
11
src/main.tsx
Normal 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>,
|
||||
)
|
||||
|
||||
7
src/pages/AccountsPage.tsx
Normal file
7
src/pages/AccountsPage.tsx
Normal 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
7
src/pages/AdminPage.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
import { AdminDashboard } from '../components/admin/AdminDashboard';
|
||||
|
||||
export const AdminPage: React.FC = () => {
|
||||
return <AdminDashboard />;
|
||||
};
|
||||
|
||||
11
src/pages/CheckoutPage.tsx
Normal file
11
src/pages/CheckoutPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
7
src/pages/SuspiciousPage.tsx
Normal file
7
src/pages/SuspiciousPage.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
import { SuspiciousTransactions } from '../components/admin/SuspiciousTransactions';
|
||||
|
||||
export const SuspiciousPage: React.FC = () => {
|
||||
return <SuspiciousTransactions />;
|
||||
};
|
||||
|
||||
7
src/pages/TransactionsPage.tsx
Normal file
7
src/pages/TransactionsPage.tsx
Normal 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
38
src/types/index.ts
Normal 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
156
src/utils/api.ts
Normal 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
28
tailwind.config.js
Normal 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
26
tsconfig.json
Normal 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
11
tsconfig.node.json
Normal 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
8
vite.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user