final-touches
This commit is contained in:
parent
264f1e6159
commit
3d66182b95
|
|
@ -4,6 +4,9 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" rel="stylesheet">
|
||||||
<title>Amba Checkout</title>
|
<title>Amba Checkout</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
2602
pnpm-lock.yaml
Normal file
2602
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
1
public/404.svg
Normal file
1
public/404.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 58 KiB |
0
public/404.svg:Zone.Identifier
Normal file
0
public/404.svg:Zone.Identifier
Normal file
BIN
public/AmbaLogo.gif
Normal file
BIN
public/AmbaLogo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
0
public/AmbaLogo.gif:Zone.Identifier
Normal file
0
public/AmbaLogo.gif:Zone.Identifier
Normal file
6
public/Logo.svg
Normal file
6
public/Logo.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 86.42 80.35">
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<path d="m86.42,70.54h-8.94l-4.29-7.02c2.34-1.06,4.47-2.49,6.34-4.25l6.9,11.27Zm-3.16-28.6c0-10.99-8.94-19.92-19.92-19.92h-18.76l-25.06,40.9h-5.91L43.21,14.59l2.02,3.29h8.94L43.22,0,0,70.54h20.49c2.05,0,3.96-1.07,5.02-2.81l20.9-34.12,2.43-3.97h14.49c6.78,0,12.3,5.52,12.3,12.3,0,2.68-.86,5.16-2.33,7.18-1.51,2.1-3.67,3.7-6.18,4.51-1.2.39-2.47.6-3.8.6h-16.88c-3.48,0-6.77,1.85-8.59,4.81l-13.05,21.3h5.64c2.04,0,3.95-1.07,5.02-2.81l8.89-14.51c.44-.72,1.24-1.17,2.09-1.17h16.88c2.78,0,5.44-.57,7.85-1.61,2.37-1.02,4.51-2.49,6.31-4.31,3.57-3.6,5.77-8.56,5.77-14.01Zm-17.3,8.07c1.69-.55,3.21-1.65,4.25-3.11.34-.46.62-.95.84-1.46l-7.25-11.83h-8.94l10.19,16.64c.31-.05.61-.14.91-.24Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 877 B |
0
public/Logo.svg:Zone.Identifier
Normal file
0
public/Logo.svg:Zone.Identifier
Normal file
1
public/Maintenance.svg
Normal file
1
public/Maintenance.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 18 KiB |
0
public/Maintenance.svg:Zone.Identifier
Normal file
0
public/Maintenance.svg:Zone.Identifier
Normal file
1
public/NoInternet.svg
Normal file
1
public/NoInternet.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 27 KiB |
0
public/NoInternet.svg:Zone.Identifier
Normal file
0
public/NoInternet.svg:Zone.Identifier
Normal file
54
src/App.tsx
54
src/App.tsx
|
|
@ -1,32 +1,38 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
|
||||||
import { CheckoutProvider } from './contexts/CheckoutContext';
|
import { ChatButton } from './components/ui/ChatButton';
|
||||||
import { Layout } from './components/layout/Layout';
|
import { AccountSelectionPage } from './pages/AccountSelectionPage';
|
||||||
import { CheckoutPage } from './pages/CheckoutPage';
|
import { CheckoutPageRoute } from './pages/CheckoutPageRoute';
|
||||||
import { AdminPage } from './pages/AdminPage';
|
import { SuccessPageRoute } from './pages/SuccessPageRoute';
|
||||||
import { TransactionsPage } from './pages/TransactionsPage';
|
import { Error404 } from './components/errors/Error404';
|
||||||
import { SuspiciousPage } from './pages/SuspiciousPage';
|
import { NoInternet } from './components/errors/NoInternet';
|
||||||
import { AccountsPage } from './pages/AccountsPage';
|
import { useNetworkStatus } from './hooks/useNetworkStatus';
|
||||||
|
|
||||||
|
const AppRoutes: React.FC = () => {
|
||||||
|
const isOnline = useNetworkStatus();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Don't show NoInternet on the no-internet page itself to avoid infinite loop
|
||||||
|
if (!isOnline && location.pathname !== '/no-internet') {
|
||||||
|
return <NoInternet />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<AccountSelectionPage />} />
|
||||||
|
<Route path="/checkout" element={<CheckoutPageRoute />} />
|
||||||
|
<Route path="/success" element={<SuccessPageRoute />} />
|
||||||
|
<Route path="/no-internet" element={<NoInternet />} />
|
||||||
|
<Route path="*" element={<Error404 />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Layout>
|
<AppRoutes />
|
||||||
<Routes>
|
<ChatButton />
|
||||||
<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>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { Account } from '../../types';
|
||||||
import { Card } from '../ui/Card';
|
import { Card } from '../ui/Card';
|
||||||
import { Button } from '../ui/Button';
|
import { Button } from '../ui/Button';
|
||||||
import { Input } from '../ui/Input';
|
import { Input } from '../ui/Input';
|
||||||
|
import { Loading } from '../ui/Loading';
|
||||||
import { FiFlag, FiX, FiSearch, FiUser, FiBriefcase } from 'react-icons/fi';
|
import { FiFlag, FiX, FiSearch, FiUser, FiBriefcase } from 'react-icons/fi';
|
||||||
|
|
||||||
export const AccountFlagging: React.FC = () => {
|
export const AccountFlagging: React.FC = () => {
|
||||||
|
|
@ -75,11 +76,7 @@ export const AccountFlagging: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <Loading fullScreen message="Loading accounts..." />;
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<div className="text-gray-600">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
||||||
import { api } from '../../utils/api';
|
import { api } from '../../utils/api';
|
||||||
import type { Transaction, Account } from '../../types';
|
import type { Transaction, Account } from '../../types';
|
||||||
import { Card } from '../ui/Card';
|
import { Card } from '../ui/Card';
|
||||||
|
import { Loading } from '../ui/Loading';
|
||||||
import { FiDollarSign, FiAlertTriangle, FiCheckCircle, FiClock, FiUsers } from 'react-icons/fi';
|
import { FiDollarSign, FiAlertTriangle, FiCheckCircle, FiClock, FiUsers } from 'react-icons/fi';
|
||||||
|
|
||||||
export const AdminDashboard: React.FC = () => {
|
export const AdminDashboard: React.FC = () => {
|
||||||
|
|
@ -47,11 +48,7 @@ export const AdminDashboard: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <Loading fullScreen message="Loading dashboard..." />;
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<div className="text-gray-600">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { api } from '../../utils/api';
|
||||||
import type { Transaction } from '../../types';
|
import type { Transaction } from '../../types';
|
||||||
import { Card } from '../ui/Card';
|
import { Card } from '../ui/Card';
|
||||||
import { Button } from '../ui/Button';
|
import { Button } from '../ui/Button';
|
||||||
|
import { Loading } from '../ui/Loading';
|
||||||
import { FiAlertTriangle, FiCheckCircle, FiXCircle } from 'react-icons/fi';
|
import { FiAlertTriangle, FiCheckCircle, FiXCircle } from 'react-icons/fi';
|
||||||
|
|
||||||
export const SuspiciousTransactions: React.FC = () => {
|
export const SuspiciousTransactions: React.FC = () => {
|
||||||
|
|
@ -71,11 +72,7 @@ export const SuspiciousTransactions: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <Loading fullScreen message="Loading suspicious transactions..." />;
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<div className="text-gray-600">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { api } from '../../utils/api';
|
||||||
import type { Transaction } from '../../types';
|
import type { Transaction } from '../../types';
|
||||||
import { Card } from '../ui/Card';
|
import { Card } from '../ui/Card';
|
||||||
import { Button } from '../ui/Button';
|
import { Button } from '../ui/Button';
|
||||||
|
import { Loading } from '../ui/Loading';
|
||||||
import { FiSearch, FiFilter, FiEye } from 'react-icons/fi';
|
import { FiSearch, FiFilter, FiEye } from 'react-icons/fi';
|
||||||
|
|
||||||
export const TransactionList: React.FC = () => {
|
export const TransactionList: React.FC = () => {
|
||||||
|
|
@ -89,11 +90,7 @@ export const TransactionList: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <Loading fullScreen message="Loading transactions..." />;
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<div className="text-gray-600">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,163 +1,89 @@
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useCheckout } from '../../contexts/CheckoutContext';
|
import { useCheckout } from '../../contexts/CheckoutContext';
|
||||||
import { FundRequestForm } from './FundRequestForm';
|
import { RequesterDetails } from './RequesterDetails';
|
||||||
import { AccountSelection } from './AccountSelection';
|
import { CheckoutPage } from './CheckoutPage';
|
||||||
import { TransactionConfirmation } from './TransactionConfirmation';
|
import { Loading } from '../ui/Loading';
|
||||||
import { TransactionStatus } from './TransactionStatus';
|
|
||||||
import { PromotionalPanel } from './PromotionalPanel';
|
|
||||||
import { FiCheck } from 'react-icons/fi';
|
|
||||||
|
|
||||||
export const CheckoutFlow: React.FC = () => {
|
export const CheckoutFlow: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
currentStep,
|
requester,
|
||||||
|
selectedRecipientAccountId,
|
||||||
|
selectedRecipientAccount,
|
||||||
fundRequest,
|
fundRequest,
|
||||||
selectedAccountType,
|
transactionFee,
|
||||||
transaction,
|
setSelectedRecipientAccountId,
|
||||||
isLoading,
|
submitPayment,
|
||||||
error,
|
|
||||||
setFundRequest,
|
|
||||||
setAccountType,
|
|
||||||
submitTransaction,
|
|
||||||
reset,
|
|
||||||
goToStep,
|
|
||||||
} = useCheckout();
|
} = useCheckout();
|
||||||
|
|
||||||
const handleFundRequestSubmit = (data: any) => {
|
const [showCheckout, setShowCheckout] = useState(false);
|
||||||
setFundRequest(data);
|
|
||||||
goToStep(2);
|
const handleAccountSelect = (accountId: string) => {
|
||||||
|
setSelectedRecipientAccountId(accountId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAccountSelect = (accountType: 'business' | 'personal') => {
|
const handleNext = () => {
|
||||||
setAccountType(accountType);
|
// Ensure an account is selected before proceeding
|
||||||
|
if (!selectedRecipientAccountId && requester?.bankAccounts && requester.bankAccounts.length > 0) {
|
||||||
|
const defaultAccount = requester.bankAccounts.find(acc => acc.isDefault) || requester.bankAccounts[0];
|
||||||
|
if (defaultAccount) {
|
||||||
|
setSelectedRecipientAccountId(defaultAccount.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proceed to checkout page
|
||||||
|
setShowCheckout(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handlePaymentComplete = async () => {
|
||||||
await submitTransaction();
|
await submitPayment();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
if (!requester) {
|
||||||
goToStep(1);
|
return <Loading fullScreen message="Loading payment request..." />;
|
||||||
};
|
}
|
||||||
|
|
||||||
const steps = [
|
// Show checkout page if user clicked next
|
||||||
{ number: 1, label: 'Request', title: 'Fund Request' },
|
if (showCheckout && requester && fundRequest) {
|
||||||
{ number: 2, label: 'Account', title: 'Account Type' },
|
// Get the selected account from the requester's bank accounts
|
||||||
{ number: 3, label: 'Review', title: 'Confirmation' },
|
const account = selectedRecipientAccount ||
|
||||||
{ number: 4, label: 'Status', title: 'Status' },
|
(selectedRecipientAccountId && requester.bankAccounts?.find(acc => acc.id === selectedRecipientAccountId)) ||
|
||||||
];
|
requester.bankAccounts?.[0];
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return <Loading fullScreen message="Loading account details..." />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 py-4 md:py-8 px-4">
|
<CheckoutPage
|
||||||
<div className="max-w-7xl mx-auto">
|
requester={{
|
||||||
{/* Progress Indicator - Hidden on account selection step to match design */}
|
name: requester.name,
|
||||||
{currentStep !== 2 && (
|
requestAmount: requester.requestAmount,
|
||||||
<div className="mb-8">
|
description: requester.description,
|
||||||
<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}
|
selectedAccount={{
|
||||||
onCancel={handleCancel}
|
bankName: account.bankName,
|
||||||
isLoading={isLoading}
|
accountNumber: account.accountNumber,
|
||||||
|
}}
|
||||||
|
transactionFee={transactionFee}
|
||||||
|
onComplete={handlePaymentComplete}
|
||||||
/>
|
/>
|
||||||
)}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{currentStep === 4 && transaction && (
|
// Show requester details page (initial page)
|
||||||
<TransactionStatus
|
return (
|
||||||
transaction={transaction}
|
<RequesterDetails
|
||||||
onNewTransaction={reset}
|
requester={requester}
|
||||||
|
recipientAccounts={requester.bankAccounts || []}
|
||||||
|
selectedAccountId={selectedRecipientAccountId || undefined}
|
||||||
|
onSelectAccount={handleAccountSelect}
|
||||||
|
onNext={handleNext}
|
||||||
|
onFlag={(reason) => {
|
||||||
|
// Handle flagging - in real app, this would call an API
|
||||||
|
console.log('Flagged user:', requester.id, 'Reason:', reason);
|
||||||
|
alert(`User has been reported. Reason: ${reason === 'spam' ? 'Spam or suspicious activity' : reason === 'unknown' ? "I don't know this person" : 'Other'}`);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
633
src/components/checkout/CheckoutPage.tsx
Normal file
633
src/components/checkout/CheckoutPage.tsx
Normal file
|
|
@ -0,0 +1,633 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { Input } from '../ui/Input';
|
||||||
|
import { Card } from '../ui/Card';
|
||||||
|
import { Logo } from '../ui/Logo';
|
||||||
|
import { DonationSection } from './DonationSection';
|
||||||
|
import { FiLock, FiArrowLeft, FiEye, FiEyeOff, FiInfo, FiShield, FiCheck, FiCreditCard } from 'react-icons/fi';
|
||||||
|
|
||||||
|
interface CheckoutFormData {
|
||||||
|
email: string;
|
||||||
|
cardNumber: string;
|
||||||
|
expiryDate: string;
|
||||||
|
cvv: string;
|
||||||
|
cardName: string;
|
||||||
|
address: string;
|
||||||
|
agreeToTerms: boolean;
|
||||||
|
saveInfo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CheckoutPageProps {
|
||||||
|
requester: {
|
||||||
|
name: string;
|
||||||
|
requestAmount: number;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
selectedAccount: {
|
||||||
|
bankName: string;
|
||||||
|
accountNumber: string;
|
||||||
|
};
|
||||||
|
transactionFee: number;
|
||||||
|
onComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CheckoutPage: React.FC<CheckoutPageProps> = ({
|
||||||
|
requester,
|
||||||
|
selectedAccount,
|
||||||
|
transactionFee,
|
||||||
|
onComplete,
|
||||||
|
}) => {
|
||||||
|
const [cardNumber, setCardNumber] = useState('');
|
||||||
|
const [showCardNumber, setShowCardNumber] = useState(false);
|
||||||
|
const [donationAmount, setDonationAmount] = useState(0);
|
||||||
|
const [referralCode, setReferralCode] = useState('');
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState<'card' | 'googlepay' | 'applepay'>('card');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<CheckoutFormData>({
|
||||||
|
defaultValues: {
|
||||||
|
email: '',
|
||||||
|
cardNumber: '',
|
||||||
|
expiryDate: '',
|
||||||
|
cvv: '',
|
||||||
|
cardName: '',
|
||||||
|
address: '',
|
||||||
|
agreeToTerms: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtotal = requester.requestAmount;
|
||||||
|
const total = subtotal + transactionFee + donationAmount;
|
||||||
|
|
||||||
|
const onSubmit = async (data: CheckoutFormData) => {
|
||||||
|
// Simulate payment processing
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
// Navigate to success page with email and transaction details
|
||||||
|
const transactionId = `TXN-${Date.now()}`;
|
||||||
|
const totalAmount = subtotal + transactionFee + donationAmount;
|
||||||
|
navigate(`/success?email=${encodeURIComponent(data.email)}&amount=${totalAmount}&transactionId=${transactionId}`);
|
||||||
|
onComplete();
|
||||||
|
};
|
||||||
|
|
||||||
|
const emailValue = watch('email');
|
||||||
|
|
||||||
|
const handleGooglePay = async () => {
|
||||||
|
if (!emailValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
// Simulate Google Pay processing
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
const transactionId = `TXN-${Date.now()}`;
|
||||||
|
const totalAmount = subtotal + transactionFee + donationAmount;
|
||||||
|
// In real app, get email from Google Pay response
|
||||||
|
navigate(`/success?email=${encodeURIComponent(emailValue)}&amount=${totalAmount}&transactionId=${transactionId}`);
|
||||||
|
onComplete();
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplePay = async () => {
|
||||||
|
if (!emailValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
// Simulate Apple Pay processing
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
const transactionId = `TXN-${Date.now()}`;
|
||||||
|
const totalAmount = subtotal + transactionFee + donationAmount;
|
||||||
|
// In real app, get email from Apple Pay response
|
||||||
|
navigate(`/success?email=${encodeURIComponent(emailValue)}&amount=${totalAmount}&transactionId=${transactionId}`);
|
||||||
|
onComplete();
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCardNumber = (value: string) => {
|
||||||
|
const v = value.replace(/\s+/g, '').replace(/[^0-9]/gi, '');
|
||||||
|
const matches = v.match(/\d{4,16}/g);
|
||||||
|
const match = (matches && matches[0]) || '';
|
||||||
|
const parts = [];
|
||||||
|
for (let i = 0, len = match.length; i < len; i += 4) {
|
||||||
|
parts.push(match.substring(i, i + 4));
|
||||||
|
}
|
||||||
|
if (parts.length) {
|
||||||
|
return parts.join(' ');
|
||||||
|
} else {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCardBrand = (cardNumber: string) => {
|
||||||
|
const number = cardNumber.replace(/\s/g, '');
|
||||||
|
if (/^4/.test(number)) return 'visa';
|
||||||
|
if (/^5[1-5]/.test(number)) return 'mastercard';
|
||||||
|
if (/^3[47]/.test(number)) return 'amex';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[1.2fr_1fr] h-screen">
|
||||||
|
{/* Left Column - Order Summary (Dark Green) - Takes more space */}
|
||||||
|
<div className="bg-primary-500 overflow-y-auto relative">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-10"
|
||||||
|
style={{
|
||||||
|
backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 10px, rgba(255,255,255,.05) 10px, rgba(255,255,255,.05) 20px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative w-full px-4 sm:px-6 lg:px-8 py-8 lg:py-12">
|
||||||
|
{/* Back Button & Logo */}
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="text-white hover:text-gray-200 transition-colors"
|
||||||
|
aria-label="Go back to account selection"
|
||||||
|
>
|
||||||
|
<FiArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<Logo dark={true} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transaction Title */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-2">
|
||||||
|
Payment to {requester.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-primary-100">
|
||||||
|
Complete your payment to proceed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Price Display */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-baseline gap-1 mb-2">
|
||||||
|
<span className="text-5xl lg:text-6xl font-bold text-white">
|
||||||
|
${Math.floor(total)}
|
||||||
|
</span>
|
||||||
|
<span className="text-4xl lg:text-5xl font-bold text-primary-100">
|
||||||
|
.{((total % 1) * 100).toFixed(0).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-primary-100 text-sm">Total amount</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Plan/Transaction Box */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<Card className="p-4 bg-white/10 border-white/20 backdrop-blur-sm">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-white">
|
||||||
|
{requester.name} - Payment Request
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-primary-100">
|
||||||
|
{selectedAccount.bankName} • {selectedAccount.accountNumber}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-white font-semibold">
|
||||||
|
{formatCurrency(subtotal)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Order Summary */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">Order Summary</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-primary-100">Subtotal</span>
|
||||||
|
<span className="text-white">{formatCurrency(subtotal)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-primary-100">Transaction Fee</span>
|
||||||
|
<span className="text-white">{formatCurrency(transactionFee)}</span>
|
||||||
|
</div>
|
||||||
|
{donationAmount > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-primary-100">Donation</span>
|
||||||
|
<span className="text-white">{formatCurrency(donationAmount)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Referral Code Input */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm text-primary-100 mb-2">
|
||||||
|
Referral Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter referral code"
|
||||||
|
value={referralCode}
|
||||||
|
onChange={(e) => setReferralCode(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-primary-200 focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Due */}
|
||||||
|
<div className="flex justify-between items-center pt-2">
|
||||||
|
<span className="text-lg font-bold text-white">Total due today</span>
|
||||||
|
<span className="text-2xl font-bold text-white">
|
||||||
|
{formatCurrency(total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-auto pt-8 border-t border-white/20">
|
||||||
|
<p className="text-xs text-primary-100 mb-2">
|
||||||
|
©2023 All rights reserved
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4 text-xs">
|
||||||
|
<a href="#" className="text-primary-100 hover:text-white underline">
|
||||||
|
Terms
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-primary-100 hover:text-white underline">
|
||||||
|
Privacy
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Payment Form (Light Gray) */}
|
||||||
|
<div className="bg-gray-50 overflow-y-auto">
|
||||||
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-12">
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">Payment Method</h2>
|
||||||
|
|
||||||
|
{/* Payment Method Selector */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6">
|
||||||
|
{/* Card Payment */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaymentMethod('card')}
|
||||||
|
className={`p-4 border-2 rounded-lg transition-all ${
|
||||||
|
paymentMethod === 'card'
|
||||||
|
? 'border-primary-500 bg-primary-50'
|
||||||
|
: 'border-gray-200 hover:border-primary-300 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<FiCreditCard className={`w-6 h-6 ${paymentMethod === 'card' ? 'text-primary-500' : 'text-gray-600'}`} />
|
||||||
|
<span className={`text-sm font-medium ${paymentMethod === 'card' ? 'text-primary-600' : 'text-gray-700'}`}>
|
||||||
|
Card
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Google Pay */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaymentMethod('googlepay')}
|
||||||
|
className={`p-4 border-2 rounded-lg transition-all ${
|
||||||
|
paymentMethod === 'googlepay'
|
||||||
|
? 'border-primary-500 bg-primary-50'
|
||||||
|
: 'border-gray-200 hover:border-primary-300 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<svg className={`w-6 h-6 ${paymentMethod === 'googlepay' ? 'text-primary-500' : 'text-gray-600'}`} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||||
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||||
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||||
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||||
|
</svg>
|
||||||
|
<span className={`text-sm font-medium ${paymentMethod === 'googlepay' ? 'text-primary-600' : 'text-gray-700'}`}>
|
||||||
|
Google Pay
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Apple Pay */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaymentMethod('applepay')}
|
||||||
|
className={`p-4 border-2 rounded-lg transition-all ${
|
||||||
|
paymentMethod === 'applepay'
|
||||||
|
? 'border-primary-500 bg-primary-50'
|
||||||
|
: 'border-gray-200 hover:border-primary-300 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<svg className={`w-6 h-6 ${paymentMethod === 'applepay' ? 'text-primary-500' : 'text-gray-600'}`} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
|
||||||
|
</svg>
|
||||||
|
<span className={`text-sm font-medium ${paymentMethod === 'applepay' ? 'text-primary-600' : 'text-gray-700'}`}>
|
||||||
|
Apple Pay
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Method Content */}
|
||||||
|
{paymentMethod === 'card' && (
|
||||||
|
<>
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
error={errors.email?.message}
|
||||||
|
{...register('email', {
|
||||||
|
required: 'Email is required',
|
||||||
|
pattern: {
|
||||||
|
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||||
|
message: 'Invalid email address',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Details */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Card details
|
||||||
|
</label>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Card Number */}
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showCardNumber ? 'text' : 'password'}
|
||||||
|
placeholder="1234 1234 1234 1234"
|
||||||
|
maxLength={19}
|
||||||
|
className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent pr-20 ${
|
||||||
|
errors.cardNumber ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
{...register('cardNumber', {
|
||||||
|
required: 'Card number is required',
|
||||||
|
onChange: (e) => {
|
||||||
|
const formatted = formatCardNumber(e.target.value);
|
||||||
|
e.target.value = formatted;
|
||||||
|
setCardNumber(formatted);
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-3 top-1/2 transform -translate-y-1/2 flex items-center gap-2">
|
||||||
|
{getCardBrand(cardNumber) === 'visa' && (
|
||||||
|
<span className="text-xs font-semibold text-blue-600">VISA</span>
|
||||||
|
)}
|
||||||
|
{getCardBrand(cardNumber) === 'mastercard' && (
|
||||||
|
<span className="text-xs font-semibold text-red-600">MC</span>
|
||||||
|
)}
|
||||||
|
{getCardBrand(cardNumber) === 'amex' && (
|
||||||
|
<span className="text-xs font-semibold text-blue-600">AMEX</span>
|
||||||
|
)}
|
||||||
|
{!getCardBrand(cardNumber) && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<div className="w-6 h-4 bg-blue-600 rounded"></div>
|
||||||
|
<div className="w-6 h-4 bg-yellow-500 rounded"></div>
|
||||||
|
<div className="w-6 h-4 bg-blue-500 rounded"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCardNumber(!showCardNumber)}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showCardNumber ? <FiEyeOff className="w-5 h-5" /> : <FiEye className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{errors.cardNumber && (
|
||||||
|
<p className="text-sm text-red-600">{errors.cardNumber.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expiry and CVC */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="MM/YY"
|
||||||
|
maxLength={5}
|
||||||
|
className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent ${
|
||||||
|
errors.expiryDate ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
{...register('expiryDate', {
|
||||||
|
required: 'Expiry date is required',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{errors.expiryDate && (
|
||||||
|
<p className="text-sm text-red-600 mt-1">{errors.expiryDate.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="CVC"
|
||||||
|
maxLength={4}
|
||||||
|
className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent pr-10 ${
|
||||||
|
errors.cvv ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
{...register('cvv', {
|
||||||
|
required: 'CVC is required',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<FiLock className="absolute right-3 top-1/2 transform -translate-y-1/2 text-secondary-500" />
|
||||||
|
{errors.cvv && (
|
||||||
|
<p className="text-sm text-red-600 mt-1">{errors.cvv.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name on card */}
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label="Name on card"
|
||||||
|
type="text"
|
||||||
|
placeholder="Name on card"
|
||||||
|
error={errors.cardName?.message}
|
||||||
|
{...register('cardName', {
|
||||||
|
required: 'Name on card is required',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-gray-200 my-6"></div>
|
||||||
|
|
||||||
|
{/* Billing Address */}
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label="Billing address"
|
||||||
|
type="text"
|
||||||
|
placeholder="Address"
|
||||||
|
error={errors.address?.message}
|
||||||
|
{...register('address', {
|
||||||
|
required: 'Address is required',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Donation Section */}
|
||||||
|
<DonationSection
|
||||||
|
onDonationChange={(amount) => setDonationAmount(amount)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Terms Checkbox */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="agreeToTerms"
|
||||||
|
{...register('agreeToTerms', {
|
||||||
|
required: 'You must agree to the terms and conditions',
|
||||||
|
})}
|
||||||
|
className="mt-1 w-4 h-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="agreeToTerms" className="text-sm text-gray-700">
|
||||||
|
By confirming your payment, you allow us to charge your card for this and future payments in accordance with terms. You can always cancel your subscription.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{errors.agreeToTerms && (
|
||||||
|
<p className="text-sm text-red-600 -mt-2">{errors.agreeToTerms.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subscribe/Pay Button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full bg-primary-500 hover:bg-primary-600 text-white font-medium py-4 px-6 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Processing...' : 'Pay'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Google Pay Button */}
|
||||||
|
{paymentMethod === 'googlepay' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
error={errors.email?.message}
|
||||||
|
{...register('email', {
|
||||||
|
required: 'Email is required',
|
||||||
|
pattern: {
|
||||||
|
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||||
|
message: 'Invalid email address',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGooglePay}
|
||||||
|
disabled={isProcessing || !emailValue}
|
||||||
|
className="w-full bg-gray-900 hover:bg-gray-800 text-white font-medium py-4 px-6 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||||
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||||
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||||
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||||
|
</svg>
|
||||||
|
<span>{isProcessing ? 'Processing...' : 'Pay with Google Pay'}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Apple Pay Button */}
|
||||||
|
{paymentMethod === 'applepay' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
error={errors.email?.message}
|
||||||
|
{...register('email', {
|
||||||
|
required: 'Email is required',
|
||||||
|
pattern: {
|
||||||
|
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||||
|
message: 'Invalid email address',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApplePay}
|
||||||
|
disabled={isProcessing || !emailValue}
|
||||||
|
className="w-full bg-black hover:bg-gray-900 text-white font-medium py-4 px-6 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
|
||||||
|
</svg>
|
||||||
|
<span>{isProcessing ? 'Processing...' : 'Pay with Apple Pay'}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Common sections for all payment methods */}
|
||||||
|
{(paymentMethod === 'googlepay' || paymentMethod === 'applepay') && (
|
||||||
|
<>
|
||||||
|
{/* Donation Section */}
|
||||||
|
<DonationSection
|
||||||
|
onDonationChange={(amount) => setDonationAmount(amount)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Terms Checkbox */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="agreeToTermsAlt"
|
||||||
|
{...register('agreeToTerms', {
|
||||||
|
required: 'You must agree to the terms and conditions',
|
||||||
|
})}
|
||||||
|
className="mt-1 w-4 h-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="agreeToTermsAlt" className="text-sm text-gray-700">
|
||||||
|
By confirming your payment, you allow us to charge your card for this and future payments in accordance with terms. You can always cancel your subscription.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{errors.agreeToTerms && (
|
||||||
|
<p className="text-sm text-red-600 -mt-2">{errors.agreeToTerms.message}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Verified Checkout Badge */}
|
||||||
|
<div className="flex items-center justify-center gap-2 pt-4 border-t border-gray-200">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<FiShield className="w-5 h-5 text-secondary-500" />
|
||||||
|
<span className="font-medium">Verified checkout page by</span>
|
||||||
|
<span className="font-bold text-primary-500">AmbaPay</span>
|
||||||
|
<FiCheck className="w-4 h-4 text-secondary-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
216
src/components/checkout/DonationSection.tsx
Normal file
216
src/components/checkout/DonationSection.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { FiHeart, FiX, FiChevronLeft, FiChevronRight } from "react-icons/fi";
|
||||||
|
|
||||||
|
interface DonationCause {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
image?: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DonationSectionProps {
|
||||||
|
onDonationChange: (amount: number, causeId?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockCauses: DonationCause[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "Education for All",
|
||||||
|
description: "Supporting children's education in underserved communities",
|
||||||
|
category: "Education",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
name: "Clean Water Initiative",
|
||||||
|
description: "Providing access to clean drinking water",
|
||||||
|
category: "Health",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
name: "Wildlife Conservation",
|
||||||
|
description: "Protecting endangered species and their habitats",
|
||||||
|
category: "Environment",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
name: "Medical Relief",
|
||||||
|
description: "Emergency medical assistance for those in need",
|
||||||
|
category: "Health",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DonationSection: React.FC<DonationSectionProps> = ({
|
||||||
|
onDonationChange,
|
||||||
|
}) => {
|
||||||
|
const [donationAmount, setDonationAmount] = useState("");
|
||||||
|
const [selectedCause, setSelectedCause] = useState<DonationCause | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [showLeftButton, setShowLeftButton] = useState(false);
|
||||||
|
const [showRightButton, setShowRightButton] = useState(true);
|
||||||
|
|
||||||
|
const handleAmountChange = (value: string) => {
|
||||||
|
setDonationAmount(value);
|
||||||
|
const amount = parseFloat(value) || 0;
|
||||||
|
onDonationChange(amount, selectedCause?.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCauseSelect = (cause: DonationCause | null) => {
|
||||||
|
setSelectedCause(cause);
|
||||||
|
const amount = parseFloat(donationAmount) || 0;
|
||||||
|
onDonationChange(amount, cause?.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkScrollButtons = () => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
const { scrollLeft, scrollWidth, clientWidth } =
|
||||||
|
scrollContainerRef.current;
|
||||||
|
setShowLeftButton(scrollLeft > 0);
|
||||||
|
setShowRightButton(scrollLeft < scrollWidth - clientWidth - 10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkScrollButtons();
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
if (container) {
|
||||||
|
container.addEventListener("scroll", checkScrollButtons);
|
||||||
|
window.addEventListener("resize", checkScrollButtons);
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener("scroll", checkScrollButtons);
|
||||||
|
window.removeEventListener("resize", checkScrollButtons);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollLeft = () => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
const cardWidth =
|
||||||
|
scrollContainerRef.current.querySelector('div[style*="width"]')
|
||||||
|
?.clientWidth || 250;
|
||||||
|
scrollContainerRef.current.scrollBy({
|
||||||
|
left: -(cardWidth + 12),
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollRight = () => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
const cardWidth =
|
||||||
|
scrollContainerRef.current.querySelector('div[style*="width"]')
|
||||||
|
?.clientWidth || 250;
|
||||||
|
scrollContainerRef.current.scrollBy({
|
||||||
|
left: cardWidth + 12,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add "None" option to causes
|
||||||
|
const allCauses = [
|
||||||
|
{ id: "none", name: "None", description: "No donation", category: "None" },
|
||||||
|
...mockCauses,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Add donation to institution
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={donationAmount}
|
||||||
|
onChange={(e) => handleAmountChange(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 mb-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Select Cause
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
{/* Left Scroll Button */}
|
||||||
|
{showLeftButton && (
|
||||||
|
<button
|
||||||
|
onClick={scrollLeft}
|
||||||
|
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 bg-white border border-gray-300 rounded-full shadow-lg flex items-center justify-center hover:bg-gray-50 transition-colors"
|
||||||
|
aria-label="Scroll left"
|
||||||
|
>
|
||||||
|
<FiChevronLeft className="w-5 h-5 text-gray-700" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scrollable Container */}
|
||||||
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
className="overflow-x-auto scrollbar-hide -mx-4 px-4"
|
||||||
|
style={{
|
||||||
|
scrollbarWidth: "none",
|
||||||
|
msOverflowStyle: "none",
|
||||||
|
}}
|
||||||
|
onScroll={checkScrollButtons}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3 pb-2" style={{ width: "max-content" }}>
|
||||||
|
{allCauses.map((cause) => (
|
||||||
|
<div
|
||||||
|
key={cause.id}
|
||||||
|
onClick={() =>
|
||||||
|
handleCauseSelect(cause.id === "none" ? null : cause)
|
||||||
|
}
|
||||||
|
className={`p-3 cursor-pointer transition-colors flex-shrink-0 border rounded-lg ${
|
||||||
|
selectedCause?.id === cause.id ||
|
||||||
|
(cause.id === "none" && selectedCause === null)
|
||||||
|
? "border-primary-500 border-2 bg-primary-50"
|
||||||
|
: "border-gray-200 hover:border-primary-300 bg-white"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: "calc(50vw - 2rem)",
|
||||||
|
minWidth: "200px",
|
||||||
|
maxWidth: "250px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="w-8 h-8 bg-primary-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
{cause.id === "none" ? (
|
||||||
|
<FiX className="w-4 h-4 text-gray-600" />
|
||||||
|
) : (
|
||||||
|
<FiHeart className="w-4 h-4 text-primary-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{cause.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-600 line-clamp-2">
|
||||||
|
{cause.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Scroll Button */}
|
||||||
|
{showRightButton && (
|
||||||
|
<button
|
||||||
|
onClick={scrollRight}
|
||||||
|
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 bg-white border border-gray-300 rounded-full shadow-lg flex items-center justify-center hover:bg-gray-50 transition-colors"
|
||||||
|
aria-label="Scroll right"
|
||||||
|
>
|
||||||
|
<FiChevronRight className="w-5 h-5 text-gray-700" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,28 +1,93 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Logo } from '../ui/Logo';
|
||||||
|
import { FiFacebook, FiTwitter, FiInstagram, FiLinkedin, FiSmartphone } from 'react-icons/fi';
|
||||||
|
|
||||||
export const PromotionalPanel: React.FC = () => {
|
export const PromotionalPanel: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className="bg-primary-500 h-full flex flex-col justify-between p-8 md:p-12 text-white">
|
<div className="bg-primary-500 h-full flex flex-col justify-between p-8 md:p-12 text-white relative">
|
||||||
<div>
|
<div
|
||||||
<div className="mb-8">
|
className="absolute inset-0 opacity-10"
|
||||||
<div className="flex items-center gap-2">
|
style={{
|
||||||
<div className="w-4 h-4 bg-white rounded"></div>
|
backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 10px, rgba(255,255,255,.05) 10px, rgba(255,255,255,.05) 20px)',
|
||||||
<span className="text-xl font-bold">sanswap</span>
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative flex flex-col h-full">
|
||||||
|
<div className="mb-8 flex justify-center">
|
||||||
|
<Logo dark={true} className="h-16 md:h-20 lg:h-24" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex-1 flex flex-col justify-center items-center text-center">
|
||||||
<div className="flex-1 flex flex-col justify-center">
|
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold mb-6 max-w-2xl">
|
||||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-4">
|
|
||||||
Start searching for homes or posting ads now.
|
Start searching for homes or posting ads now.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-primary-100 text-sm md:text-base max-w-md">
|
<p className="text-primary-100 text-base md:text-lg max-w-xl">
|
||||||
Interfaces are well designed for all ages and target audiences are extremely simple and work with social media integrations
|
Interfaces are well designed for all ages and target audiences are extremely simple and work with social media integrations
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-auto">
|
||||||
|
{/* Download App Buttons */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-sm text-primary-100 text-center mb-3">Download the Amba App</p>
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => window.open('https://apps.apple.com/app/amba', '_blank')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors border border-white/20"
|
||||||
|
>
|
||||||
|
<FiSmartphone className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">iOS</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.open('https://play.google.com/store/apps/details?id=com.amba', '_blank')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors border border-white/20"
|
||||||
|
>
|
||||||
|
<FiSmartphone className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">Android</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Social Links */}
|
||||||
|
<div className="flex gap-4 justify-center mb-6">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
|
||||||
|
aria-label="Facebook"
|
||||||
|
>
|
||||||
|
<FiFacebook className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
|
||||||
|
aria-label="Twitter"
|
||||||
|
>
|
||||||
|
<FiTwitter className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
|
||||||
|
aria-label="Instagram"
|
||||||
|
>
|
||||||
|
<FiInstagram className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
|
||||||
|
aria-label="LinkedIn"
|
||||||
|
>
|
||||||
|
<FiLinkedin className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Links */}
|
||||||
|
<div className="flex gap-4 justify-center text-sm text-primary-100 pb-4">
|
||||||
|
<a href="#" className="hover:text-white underline">
|
||||||
|
Terms
|
||||||
|
</a>
|
||||||
|
<span className="text-primary-200">•</span>
|
||||||
|
<a href="#" className="hover:text-white underline">
|
||||||
|
Privacy Policy
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
396
src/components/checkout/RequesterDetails.tsx
Normal file
396
src/components/checkout/RequesterDetails.tsx
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Card } from "../ui/Card";
|
||||||
|
import { PromotionalPanel } from "./PromotionalPanel";
|
||||||
|
import type { RecipientBankAccount } from "../../types";
|
||||||
|
import {
|
||||||
|
FiBriefcase,
|
||||||
|
FiUser,
|
||||||
|
FiCreditCard,
|
||||||
|
FiFlag,
|
||||||
|
FiGlobe,
|
||||||
|
FiChevronDown,
|
||||||
|
} from "react-icons/fi";
|
||||||
|
|
||||||
|
interface RequesterDetailsProps {
|
||||||
|
requester: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
requestAmount: number;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
recipientAccounts: RecipientBankAccount[];
|
||||||
|
selectedAccountId?: string;
|
||||||
|
onSelectAccount: (accountId: string) => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onFlag?: (reason: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RequesterDetails: React.FC<RequesterDetailsProps> = ({
|
||||||
|
requester,
|
||||||
|
recipientAccounts,
|
||||||
|
selectedAccountId,
|
||||||
|
onSelectAccount,
|
||||||
|
onNext,
|
||||||
|
onFlag,
|
||||||
|
}) => {
|
||||||
|
const [showFlagModal, setShowFlagModal] = useState(false);
|
||||||
|
const [flagReason, setFlagReason] = useState("");
|
||||||
|
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
|
||||||
|
const [selectedLanguage, setSelectedLanguage] = useState("English");
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ code: "en", name: "English" },
|
||||||
|
{ code: "fr", name: "French" },
|
||||||
|
{ code: "am", name: "Amharic" },
|
||||||
|
{ code: "om", name: "Oromo" },
|
||||||
|
{ code: "ti", name: "Tigrinya" },
|
||||||
|
];
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-select first account if none selected and accounts exist
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedAccountId && recipientAccounts.length > 0) {
|
||||||
|
const defaultAccount =
|
||||||
|
recipientAccounts.find((acc) => acc.isDefault) || recipientAccounts[0];
|
||||||
|
if (defaultAccount) {
|
||||||
|
onSelectAccount(defaultAccount.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedAccountId, recipientAccounts]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen grid grid-cols-1 lg:grid-cols-2">
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<PromotionalPanel />
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-6 md:p-8 lg:p-12 overflow-y-auto">
|
||||||
|
{/* Requester Info */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||||
|
<h2 className="text-lg sm:text-xl font-bold text-gray-800 break-words">
|
||||||
|
Payment Request
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{/* Language Selector */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLanguageMenu(!showLanguageMenu)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-50 hover:bg-gray-100 text-gray-700 rounded-lg transition-colors border border-gray-200"
|
||||||
|
>
|
||||||
|
<FiGlobe className="w-4 h-4 text-secondary-500" />
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{selectedLanguage}
|
||||||
|
</span>
|
||||||
|
<FiChevronDown
|
||||||
|
className={`w-4 h-4 transition-transform ${
|
||||||
|
showLanguageMenu ? "rotate-180" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showLanguageMenu && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10"
|
||||||
|
onClick={() => setShowLanguageMenu(false)}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-20 py-1">
|
||||||
|
{languages.map((lang) => (
|
||||||
|
<button
|
||||||
|
key={lang.code}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedLanguage(lang.name);
|
||||||
|
setShowLanguageMenu(false);
|
||||||
|
// Here you would typically update the app language
|
||||||
|
// For now, we'll just update the selected language
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-50 transition-colors ${
|
||||||
|
selectedLanguage === lang.name
|
||||||
|
? "bg-primary-50 text-primary-600 font-medium"
|
||||||
|
: "text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{lang.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Report Button */}
|
||||||
|
{onFlag && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFlagModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-red-50 hover:bg-red-100 text-red-600 rounded-lg transition-colors border border-red-200"
|
||||||
|
>
|
||||||
|
<FiFlag className="w-4 h-4" />
|
||||||
|
<span className="font-medium">Report</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Card className="p-4 mb-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-primary-600 font-bold text-lg">
|
||||||
|
{requester.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-bold text-gray-900 text-lg mb-1 truncate">
|
||||||
|
{requester.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-1 truncate">
|
||||||
|
{requester.email}
|
||||||
|
</p>
|
||||||
|
{requester.phone && (
|
||||||
|
<p className="text-sm text-gray-600 truncate">
|
||||||
|
{requester.phone}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right flex-shrink-0">
|
||||||
|
<p className="text-sm text-gray-600 mb-1 whitespace-nowrap">
|
||||||
|
Requesting
|
||||||
|
</p>
|
||||||
|
<p className="text-xl sm:text-2xl font-bold text-primary-600 whitespace-nowrap">
|
||||||
|
{formatCurrency(requester.requestAmount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{requester.description && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||||
|
<p className="text-sm text-gray-600">{requester.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line Divider */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="h-px bg-gray-200"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recipient Account Selection */}
|
||||||
|
{recipientAccounts.length > 1 ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-left mb-6">
|
||||||
|
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-800 mb-2 break-words">
|
||||||
|
Choose recipient account
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 text-sm md:text-base break-words">
|
||||||
|
Select which account to send the money to
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 mb-6">
|
||||||
|
{recipientAccounts.map((account) => (
|
||||||
|
<Card
|
||||||
|
key={account.id}
|
||||||
|
selected={selectedAccountId === account.id}
|
||||||
|
onClick={() => onSelectAccount(account.id)}
|
||||||
|
className="p-6 cursor-pointer"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{account.accountType === "business" ? (
|
||||||
|
<FiBriefcase className="w-6 h-6 text-secondary-500" />
|
||||||
|
) : (
|
||||||
|
<FiUser className="w-6 h-6 text-secondary-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-lg font-bold text-gray-800">
|
||||||
|
{account.accountHolderName}
|
||||||
|
</h3>
|
||||||
|
<span className="text-sm font-medium text-primary-600">
|
||||||
|
{account.bankName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<FiCreditCard className="w-4 h-4 text-secondary-500" />
|
||||||
|
<span>{account.accountNumber}</span>
|
||||||
|
</div>
|
||||||
|
<span className="capitalize">
|
||||||
|
{account.accountType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : recipientAccounts.length === 1 ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-left mb-6">
|
||||||
|
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-800 mb-2 break-words">
|
||||||
|
Recipient Account
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 text-sm md:text-base break-words">
|
||||||
|
Money will be sent to this account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-6 mb-6 border-primary-500 border-2">
|
||||||
|
<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">
|
||||||
|
{recipientAccounts[0].accountType === "business" ? (
|
||||||
|
<FiBriefcase className="w-6 h-6 text-secondary-500" />
|
||||||
|
) : (
|
||||||
|
<FiUser className="w-6 h-6 text-secondary-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-lg font-bold text-gray-800">
|
||||||
|
{recipientAccounts[0].accountHolderName}
|
||||||
|
</h3>
|
||||||
|
<span className="text-sm font-medium text-primary-600">
|
||||||
|
{recipientAccounts[0].bankName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<FiCreditCard className="w-4 h-4 text-secondary-500" />
|
||||||
|
<span>{recipientAccounts[0].accountNumber}</span>
|
||||||
|
</div>
|
||||||
|
<span className="capitalize">
|
||||||
|
{recipientAccounts[0].accountType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Next Button */}
|
||||||
|
{recipientAccounts.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// Auto-select if only one account exists and none is selected
|
||||||
|
if (recipientAccounts.length === 1 && !selectedAccountId) {
|
||||||
|
onSelectAccount(recipientAccounts[0].id);
|
||||||
|
}
|
||||||
|
// Call onNext directly - it will handle account selection if needed
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Flag/Report Modal */}
|
||||||
|
{showFlagModal && onFlag && (
|
||||||
|
<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">
|
||||||
|
Report User
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowFlagModal(false);
|
||||||
|
setFlagReason("");
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
Why are you reporting{" "}
|
||||||
|
<span className="font-medium">{requester.name}</span>?
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="flagReason"
|
||||||
|
value="spam"
|
||||||
|
checked={flagReason === "spam"}
|
||||||
|
onChange={(e) => setFlagReason(e.target.value)}
|
||||||
|
className="w-4 h-4 text-primary-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-900">
|
||||||
|
Spam or suspicious activity
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="flagReason"
|
||||||
|
value="unknown"
|
||||||
|
checked={flagReason === "unknown"}
|
||||||
|
onChange={(e) => setFlagReason(e.target.value)}
|
||||||
|
className="w-4 h-4 text-primary-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-900">
|
||||||
|
I don't know this person
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="flagReason"
|
||||||
|
value="other"
|
||||||
|
checked={flagReason === "other"}
|
||||||
|
onChange={(e) => setFlagReason(e.target.value)}
|
||||||
|
className="w-4 h-4 text-primary-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-900">Other</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (flagReason) {
|
||||||
|
onFlag(flagReason);
|
||||||
|
setShowFlagModal(false);
|
||||||
|
setFlagReason("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!flagReason}
|
||||||
|
className="flex-1 bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Report
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowFlagModal(false);
|
||||||
|
setFlagReason("");
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
144
src/components/checkout/SuccessPage.tsx
Normal file
144
src/components/checkout/SuccessPage.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Card } from '../ui/Card';
|
||||||
|
import { Logo } from '../ui/Logo';
|
||||||
|
import { FiCheckCircle, FiMail, FiSmartphone, FiDownload } from 'react-icons/fi';
|
||||||
|
|
||||||
|
interface SuccessPageProps {
|
||||||
|
transactionId: string;
|
||||||
|
amount: number;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SuccessPage: React.FC<SuccessPageProps> = ({
|
||||||
|
transactionId,
|
||||||
|
amount,
|
||||||
|
email,
|
||||||
|
}) => {
|
||||||
|
const [emailSent, setEmailSent] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendEmail = async () => {
|
||||||
|
// Simulate sending email
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
setEmailSent(true);
|
||||||
|
// In real app, this would call an API to send the download link via email
|
||||||
|
console.log('Sending download link to:', email);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadIOS = () => {
|
||||||
|
// In real app, this would redirect to App Store
|
||||||
|
window.open('https://apps.apple.com/app/amba', '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadAndroid = () => {
|
||||||
|
// In real app, this would redirect to Google Play Store
|
||||||
|
window.open('https://play.google.com/store/apps/details?id=com.amba', '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
|
<div className="max-w-2xl w-full">
|
||||||
|
<Card className="p-8 md:p-12">
|
||||||
|
{/* Success Icon */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-20 h-20 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<FiCheckCircle className="w-12 h-12 text-primary-500" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||||
|
Payment Successful!
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mb-1">
|
||||||
|
Your payment of {formatCurrency(amount)} has been processed successfully.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">Transaction ID: {transactionId}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download App Section */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-4 text-center">
|
||||||
|
Download the Amba App
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 text-center mb-6">
|
||||||
|
Get the Amba app to manage your payments and transactions on the go.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadIOS}
|
||||||
|
className="flex items-center justify-center gap-3 px-6 py-4 bg-gray-900 hover:bg-gray-800 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<FiSmartphone className="w-6 h-6" />
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="text-xs">Download on the</p>
|
||||||
|
<p className="font-semibold">App Store</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadAndroid}
|
||||||
|
className="flex items-center justify-center gap-3 px-6 py-4 bg-gray-900 hover:bg-gray-800 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<FiSmartphone className="w-6 h-6" />
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="text-xs">Get it on</p>
|
||||||
|
<p className="font-semibold">Google Play</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Link Section */}
|
||||||
|
<div className="border-t border-gray-200 pt-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FiMail className="w-5 h-5 text-primary-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Send download link via email</p>
|
||||||
|
<p className="text-sm text-gray-600">{email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSendEmail}
|
||||||
|
disabled={emailSent}
|
||||||
|
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
|
||||||
|
>
|
||||||
|
{emailSent ? 'Sent!' : 'Send Link'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{emailSent && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-3 text-sm text-green-800">
|
||||||
|
Download link has been sent to your email!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="flex-1 px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Back to Home
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.print()}
|
||||||
|
className="px-6 py-3 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<FiDownload className="w-4 h-4" />
|
||||||
|
Download Receipt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
30
src/components/errors/Error404.tsx
Normal file
30
src/components/errors/Error404.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from '../ui/Button';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const Error404: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||||
|
<div className="max-w-md w-full text-center">
|
||||||
|
<img
|
||||||
|
src="/404.svg"
|
||||||
|
alt="404 Error"
|
||||||
|
className="w-full max-w-sm mx-auto mb-8"
|
||||||
|
/>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">Page Not Found</h1>
|
||||||
|
<p className="text-gray-600 mb-8">
|
||||||
|
The page you're looking for doesn't exist or has been moved.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
>
|
||||||
|
Go Back Home
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
23
src/components/errors/Maintenance.tsx
Normal file
23
src/components/errors/Maintenance.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const Maintenance: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||||
|
<div className="max-w-md w-full text-center">
|
||||||
|
<img
|
||||||
|
src="/Maintenance.svg"
|
||||||
|
alt="Maintenance"
|
||||||
|
className="w-full max-w-sm mx-auto mb-8"
|
||||||
|
/>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">Under Maintenance</h1>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
We're currently performing scheduled maintenance to improve your experience.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Please check back soon. We apologize for any inconvenience.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
32
src/components/errors/NoInternet.tsx
Normal file
32
src/components/errors/NoInternet.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from '../ui/Button';
|
||||||
|
|
||||||
|
export const NoInternet: React.FC = () => {
|
||||||
|
const handleRetry = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||||
|
<div className="max-w-md w-full text-center">
|
||||||
|
<img
|
||||||
|
src="/NoInternet.svg"
|
||||||
|
alt="No Internet"
|
||||||
|
className="w-full max-w-sm mx-auto mb-8"
|
||||||
|
/>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">No Internet Connection</h1>
|
||||||
|
<p className="text-gray-600 mb-8">
|
||||||
|
Please check your internet connection and try again.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
onClick={handleRetry}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { Logo } from '../ui/Logo';
|
||||||
import { FiHome, FiShield, FiUsers, FiAlertTriangle } from 'react-icons/fi';
|
import { FiHome, FiShield, FiUsers, FiAlertTriangle } from 'react-icons/fi';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
|
|
@ -18,7 +19,9 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||||
<div className="flex justify-between h-16">
|
<div className="flex justify-between h-16">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex-shrink-0 flex items-center">
|
<div className="flex-shrink-0 flex items-center">
|
||||||
<span className="text-xl font-bold text-primary-600">Amba Checkout</span>
|
<Link to="/" className="flex items-center gap-2">
|
||||||
|
<Logo />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
|
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
79
src/components/ui/ChatButton.tsx
Normal file
79
src/components/ui/ChatButton.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { FiMessageCircle, FiX } from 'react-icons/fi';
|
||||||
|
|
||||||
|
export const ChatButton: React.FC = () => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Don't show chat on error pages or admin pages
|
||||||
|
const hideChat = location.pathname.includes('/admin') ||
|
||||||
|
location.pathname === '/maintenance' ||
|
||||||
|
location.pathname === '/no-internet' ||
|
||||||
|
location.pathname === '/404';
|
||||||
|
|
||||||
|
if (hideChat) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Chat Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="fixed bottom-6 right-6 z-50 w-14 h-14 bg-primary-500 hover:bg-primary-600 text-white rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110"
|
||||||
|
aria-label="Open support chat"
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<FiX className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<FiMessageCircle className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Chat Window */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed bottom-24 right-6 z-50 w-80 h-96 bg-white rounded-lg shadow-2xl flex flex-col border border-gray-200">
|
||||||
|
{/* Chat Header */}
|
||||||
|
<div className="bg-primary-500 text-white p-4 rounded-t-lg flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">Support Chat</h3>
|
||||||
|
<p className="text-xs text-primary-100">We typically reply in minutes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat Messages */}
|
||||||
|
<div className="flex-1 p-4 overflow-y-auto bg-gray-50">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-primary-600 text-xs font-bold">A</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="bg-white rounded-lg p-3 shadow-sm">
|
||||||
|
<p className="text-sm text-gray-800">
|
||||||
|
Hello! How can we help you today?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat Input */}
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-white rounded-b-lg">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Type your message..."
|
||||||
|
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm"
|
||||||
|
/>
|
||||||
|
<button className="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors text-sm font-medium">
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
31
src/components/ui/Loading.tsx
Normal file
31
src/components/ui/Loading.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface LoadingProps {
|
||||||
|
message?: string;
|
||||||
|
fullScreen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Loading: React.FC<LoadingProps> = ({
|
||||||
|
message = 'Loading...',
|
||||||
|
fullScreen = false
|
||||||
|
}) => {
|
||||||
|
const containerClass = fullScreen
|
||||||
|
? 'min-h-screen bg-gray-50 flex items-center justify-center'
|
||||||
|
: 'flex items-center justify-center py-12';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClass}>
|
||||||
|
<div className="text-center">
|
||||||
|
<img
|
||||||
|
src="/AmbaLogo.gif"
|
||||||
|
alt="Loading"
|
||||||
|
className="w-32 h-32 mx-auto mb-4"
|
||||||
|
/>
|
||||||
|
{message && (
|
||||||
|
<p className="text-gray-600">{message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
36
src/components/ui/Logo.tsx
Normal file
36
src/components/ui/Logo.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface LogoProps {
|
||||||
|
className?: string;
|
||||||
|
dark?: boolean; // If true, use dark version for light backgrounds
|
||||||
|
color?: string; // Custom color override (e.g., '#105D38')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Logo: React.FC<LogoProps> = ({ className = '', dark = false, color }) => {
|
||||||
|
// For dark backgrounds, use white filter, for light backgrounds use default
|
||||||
|
// If custom color is provided, use CSS filter to convert to that color
|
||||||
|
let filterStyle: React.CSSProperties = {};
|
||||||
|
|
||||||
|
if (color === '#105D38') {
|
||||||
|
// CSS filter to convert to #105D38 green
|
||||||
|
// This filter converts black to the specified green color
|
||||||
|
filterStyle = {
|
||||||
|
filter: 'brightness(0) saturate(100%) invert(13%) sepia(94%) saturate(1352%) hue-rotate(118deg) brightness(95%) contrast(87%)',
|
||||||
|
};
|
||||||
|
} else if (dark) {
|
||||||
|
// For dark backgrounds, invert to white
|
||||||
|
filterStyle = {
|
||||||
|
filter: 'brightness(0) invert(1)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src="/Logo.svg"
|
||||||
|
alt="Amba Logo"
|
||||||
|
className={`h-8 w-auto ${className}`}
|
||||||
|
style={filterStyle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -1,17 +1,26 @@
|
||||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, ReactNode, useEffect } from 'react';
|
||||||
import type { FundRequest, AccountType, Transaction } from '../types';
|
import type { FundRequest, AccountType, Transaction, Requester, RecipientBankAccount, TransactionFeedback } from '../types';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
|
|
||||||
interface CheckoutContextType {
|
interface CheckoutContextType {
|
||||||
currentStep: number;
|
currentStep: number;
|
||||||
|
requester: Requester | null;
|
||||||
|
selectedRecipientAccountId: string | null;
|
||||||
|
selectedRecipientAccount: RecipientBankAccount | null;
|
||||||
fundRequest: Partial<FundRequest> | null;
|
fundRequest: Partial<FundRequest> | null;
|
||||||
selectedAccountType: AccountType | null;
|
selectedAccountType: AccountType | null;
|
||||||
transaction: Transaction | null;
|
transaction: Transaction | null;
|
||||||
|
showSuccessModal: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
transactionFee: number;
|
||||||
|
setRequester: (requester: Requester) => void;
|
||||||
|
setSelectedRecipientAccountId: (accountId: string) => void;
|
||||||
setFundRequest: (data: Partial<FundRequest>) => void;
|
setFundRequest: (data: Partial<FundRequest>) => void;
|
||||||
setAccountType: (type: AccountType) => void;
|
setAccountType: (type: AccountType) => void;
|
||||||
submitTransaction: () => Promise<void>;
|
submitPayment: () => Promise<void>;
|
||||||
|
submitFeedback: (feedback: TransactionFeedback) => void;
|
||||||
|
closeSuccessModal: () => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
goToStep: (step: number) => void;
|
goToStep: (step: number) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -20,11 +29,80 @@ const CheckoutContext = createContext<CheckoutContextType | undefined>(undefined
|
||||||
|
|
||||||
export const CheckoutProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
export const CheckoutProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
const [requester, setRequesterState] = useState<Requester | null>(null);
|
||||||
|
const [selectedRecipientAccountId, setSelectedRecipientAccountIdState] = useState<string | null>(null);
|
||||||
|
const [selectedRecipientAccount, setSelectedRecipientAccountState] = useState<RecipientBankAccount | null>(null);
|
||||||
const [fundRequest, setFundRequestState] = useState<Partial<FundRequest> | null>(null);
|
const [fundRequest, setFundRequestState] = useState<Partial<FundRequest> | null>(null);
|
||||||
const [selectedAccountType, setSelectedAccountType] = useState<AccountType | null>(null);
|
const [selectedAccountType, setSelectedAccountType] = useState<AccountType | null>(null);
|
||||||
const [transaction, setTransaction] = useState<Transaction | null>(null);
|
const [transaction, setTransaction] = useState<Transaction | null>(null);
|
||||||
|
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [transactionFee] = useState(2.50); // Fixed transaction fee for now
|
||||||
|
|
||||||
|
// Initialize with mock data for development
|
||||||
|
useEffect(() => {
|
||||||
|
// Mock requester data with bank accounts (in real app, this would come from URL params or API)
|
||||||
|
const mockRequester: Requester = {
|
||||||
|
id: 'req-1',
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john.doe@example.com',
|
||||||
|
phone: '+251 911 234 567',
|
||||||
|
requestAmount: 1250.00,
|
||||||
|
description: 'Payment for services rendered',
|
||||||
|
requestId: 'req-12345',
|
||||||
|
bankAccounts: [
|
||||||
|
{
|
||||||
|
id: 'bank-1',
|
||||||
|
bankName: 'Awash Bank',
|
||||||
|
accountNumber: '1000123456789',
|
||||||
|
accountHolderName: 'John Doe',
|
||||||
|
accountType: 'personal',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bank-2',
|
||||||
|
bankName: 'Commercial Bank of Ethiopia',
|
||||||
|
accountNumber: '2000987654321',
|
||||||
|
accountHolderName: 'John Doe',
|
||||||
|
accountType: 'business',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
setRequesterState(mockRequester);
|
||||||
|
|
||||||
|
// Set initial fund request from requester
|
||||||
|
setFundRequestState({
|
||||||
|
amount: mockRequester.requestAmount,
|
||||||
|
recipient: mockRequester.name,
|
||||||
|
recipientEmail: mockRequester.email,
|
||||||
|
description: mockRequester.description,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setRequester = (req: Requester) => {
|
||||||
|
setRequesterState(req);
|
||||||
|
setFundRequestState({
|
||||||
|
amount: req.requestAmount,
|
||||||
|
recipient: req.name,
|
||||||
|
recipientEmail: req.email,
|
||||||
|
description: req.description,
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSelectedRecipientAccountId = (accountId: string) => {
|
||||||
|
setSelectedRecipientAccountIdState(accountId);
|
||||||
|
if (requester?.bankAccounts) {
|
||||||
|
const account = requester.bankAccounts.find(acc => acc.id === accountId);
|
||||||
|
if (account) {
|
||||||
|
setSelectedRecipientAccountState(account);
|
||||||
|
setSelectedAccountType(account.accountType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const setFundRequest = (data: Partial<FundRequest>) => {
|
const setFundRequest = (data: Partial<FundRequest>) => {
|
||||||
setFundRequestState(data);
|
setFundRequestState(data);
|
||||||
|
|
@ -36,8 +114,8 @@ export const CheckoutProvider: React.FC<{ children: ReactNode }> = ({ children }
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitTransaction = async () => {
|
const submitPayment = async () => {
|
||||||
if (!fundRequest || !selectedAccountType) {
|
if (!fundRequest || !selectedAccountType || !selectedRecipientAccountId) {
|
||||||
setError('Please complete all required fields');
|
setError('Please complete all required fields');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -53,19 +131,34 @@ export const CheckoutProvider: React.FC<{ children: ReactNode }> = ({ children }
|
||||||
|
|
||||||
const newTransaction = await api.createTransaction(transactionData);
|
const newTransaction = await api.createTransaction(transactionData);
|
||||||
setTransaction(newTransaction);
|
setTransaction(newTransaction);
|
||||||
setCurrentStep(4);
|
setShowSuccessModal(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to create transaction');
|
setError(err instanceof Error ? err.message : 'Failed to process payment');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitFeedback = (feedback: TransactionFeedback) => {
|
||||||
|
// In real app, this would send feedback to backend
|
||||||
|
console.log('Feedback submitted:', feedback);
|
||||||
|
setShowSuccessModal(false);
|
||||||
|
setCurrentStep(3);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeSuccessModal = () => {
|
||||||
|
setShowSuccessModal(false);
|
||||||
|
setCurrentStep(3);
|
||||||
|
};
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setCurrentStep(1);
|
setCurrentStep(1);
|
||||||
|
setSelectedRecipientAccountIdState(null);
|
||||||
|
setSelectedRecipientAccountState(null);
|
||||||
setFundRequestState(null);
|
setFundRequestState(null);
|
||||||
setSelectedAccountType(null);
|
setSelectedAccountType(null);
|
||||||
setTransaction(null);
|
setTransaction(null);
|
||||||
|
setShowSuccessModal(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
@ -80,14 +173,23 @@ export const CheckoutProvider: React.FC<{ children: ReactNode }> = ({ children }
|
||||||
<CheckoutContext.Provider
|
<CheckoutContext.Provider
|
||||||
value={{
|
value={{
|
||||||
currentStep,
|
currentStep,
|
||||||
|
requester,
|
||||||
|
selectedRecipientAccountId,
|
||||||
|
selectedRecipientAccount,
|
||||||
fundRequest,
|
fundRequest,
|
||||||
selectedAccountType,
|
selectedAccountType,
|
||||||
transaction,
|
transaction,
|
||||||
|
showSuccessModal,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
transactionFee,
|
||||||
|
setRequester,
|
||||||
|
setSelectedRecipientAccountId,
|
||||||
setFundRequest,
|
setFundRequest,
|
||||||
setAccountType,
|
setAccountType,
|
||||||
submitTransaction,
|
submitPayment,
|
||||||
|
submitFeedback,
|
||||||
|
closeSuccessModal,
|
||||||
reset,
|
reset,
|
||||||
goToStep,
|
goToStep,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
21
src/hooks/useNetworkStatus.ts
Normal file
21
src/hooks/useNetworkStatus.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export const useNetworkStatus = () => {
|
||||||
|
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => setIsOnline(true);
|
||||||
|
const handleOffline = () => setIsOnline(false);
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isOnline;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -4,9 +4,7 @@
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family: 'DM Sans', sans-serif;
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
|
||||||
sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
@ -15,3 +13,14 @@ body {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar for IE, Edge and Firefox */
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
60
src/pages/AccountSelectionPage.tsx
Normal file
60
src/pages/AccountSelectionPage.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { CheckoutProvider } from '../contexts/CheckoutContext';
|
||||||
|
import { RequesterDetails } from '../components/checkout/RequesterDetails';
|
||||||
|
import { useCheckout } from '../contexts/CheckoutContext';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Loading } from '../components/ui/Loading';
|
||||||
|
|
||||||
|
const AccountSelectionContent: React.FC = () => {
|
||||||
|
const {
|
||||||
|
requester,
|
||||||
|
selectedRecipientAccountId,
|
||||||
|
setSelectedRecipientAccountId,
|
||||||
|
} = useCheckout();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleAccountSelect = (accountId: string) => {
|
||||||
|
setSelectedRecipientAccountId(accountId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
// Ensure an account is selected before proceeding
|
||||||
|
if (!selectedRecipientAccountId && requester?.bankAccounts && requester.bankAccounts.length > 0) {
|
||||||
|
const defaultAccount = requester.bankAccounts.find(acc => acc.isDefault) || requester.bankAccounts[0];
|
||||||
|
if (defaultAccount) {
|
||||||
|
setSelectedRecipientAccountId(defaultAccount.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to checkout page
|
||||||
|
navigate('/checkout');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!requester) {
|
||||||
|
return <Loading fullScreen message="Loading payment request..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RequesterDetails
|
||||||
|
requester={requester}
|
||||||
|
recipientAccounts={requester.bankAccounts || []}
|
||||||
|
selectedAccountId={selectedRecipientAccountId || undefined}
|
||||||
|
onSelectAccount={handleAccountSelect}
|
||||||
|
onNext={handleNext}
|
||||||
|
onFlag={(reason) => {
|
||||||
|
// Handle flagging - in real app, this would call an API
|
||||||
|
console.log('Flagged user:', requester.id, 'Reason:', reason);
|
||||||
|
alert(`User has been reported. Reason: ${reason === 'spam' ? 'Spam or suspicious activity' : reason === 'unknown' ? "I don't know this person" : 'Other'}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AccountSelectionPage: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<CheckoutProvider>
|
||||||
|
<AccountSelectionContent />
|
||||||
|
</CheckoutProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { AccountFlagging } from '../components/admin/AccountFlagging';
|
|
||||||
|
|
||||||
export const AccountsPage: React.FC = () => {
|
|
||||||
return <AccountFlagging />;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { AdminDashboard } from '../components/admin/AdminDashboard';
|
|
||||||
|
|
||||||
export const AdminPage: React.FC = () => {
|
|
||||||
return <AdminDashboard />;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
61
src/pages/CheckoutPageRoute.tsx
Normal file
61
src/pages/CheckoutPageRoute.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { CheckoutProvider } from '../contexts/CheckoutContext';
|
||||||
|
import { CheckoutPage } from '../components/checkout/CheckoutPage';
|
||||||
|
import { useCheckout } from '../contexts/CheckoutContext';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Loading } from '../components/ui/Loading';
|
||||||
|
|
||||||
|
const CheckoutContent: React.FC = () => {
|
||||||
|
const {
|
||||||
|
requester,
|
||||||
|
selectedRecipientAccountId,
|
||||||
|
selectedRecipientAccount,
|
||||||
|
fundRequest,
|
||||||
|
transactionFee,
|
||||||
|
submitPayment,
|
||||||
|
} = useCheckout();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handlePaymentComplete = async () => {
|
||||||
|
await submitPayment();
|
||||||
|
// After payment, you might want to navigate somewhere or show a success message
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!requester || !fundRequest) {
|
||||||
|
return <Loading fullScreen message="Loading checkout..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the selected account from the requester's bank accounts
|
||||||
|
const account = selectedRecipientAccount ||
|
||||||
|
(selectedRecipientAccountId && requester.bankAccounts?.find(acc => acc.id === selectedRecipientAccountId)) ||
|
||||||
|
requester.bankAccounts?.[0];
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return <Loading fullScreen message="Loading account details..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CheckoutPage
|
||||||
|
requester={{
|
||||||
|
name: requester.name,
|
||||||
|
requestAmount: requester.requestAmount,
|
||||||
|
description: requester.description,
|
||||||
|
}}
|
||||||
|
selectedAccount={{
|
||||||
|
bankName: account.bankName,
|
||||||
|
accountNumber: account.accountNumber,
|
||||||
|
}}
|
||||||
|
transactionFee={transactionFee}
|
||||||
|
onComplete={handlePaymentComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CheckoutPageRoute: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<CheckoutProvider>
|
||||||
|
<CheckoutContent />
|
||||||
|
</CheckoutProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
24
src/pages/SuccessPageRoute.tsx
Normal file
24
src/pages/SuccessPageRoute.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { SuccessPage } from '../components/checkout/SuccessPage';
|
||||||
|
import { Loading } from '../components/ui/Loading';
|
||||||
|
|
||||||
|
export const SuccessPageRoute: React.FC = () => {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const email = searchParams.get('email') || '';
|
||||||
|
const amount = parseFloat(searchParams.get('amount') || '0');
|
||||||
|
const transactionId = searchParams.get('transactionId') || `TXN-${Date.now()}`;
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return <Loading fullScreen message="Loading transaction details..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SuccessPage
|
||||||
|
transactionId={transactionId}
|
||||||
|
amount={amount}
|
||||||
|
email={email}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { SuspiciousTransactions } from '../components/admin/SuspiciousTransactions';
|
|
||||||
|
|
||||||
export const SuspiciousPage: React.FC = () => {
|
|
||||||
return <SuspiciousTransactions />;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { TransactionList } from '../components/admin/TransactionList';
|
|
||||||
|
|
||||||
export const TransactionsPage: React.FC = () => {
|
|
||||||
return <TransactionList />;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -26,6 +26,8 @@ export interface Account {
|
||||||
flaggedAt?: string;
|
flaggedAt?: string;
|
||||||
flaggedBy?: string;
|
flaggedBy?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
balance?: number;
|
||||||
|
accountNumber?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FundRequest {
|
export interface FundRequest {
|
||||||
|
|
@ -36,3 +38,53 @@ export interface FundRequest {
|
||||||
accountType?: AccountType;
|
accountType?: AccountType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Requester {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
avatar?: string;
|
||||||
|
requestAmount: number;
|
||||||
|
description?: string;
|
||||||
|
requestId: string;
|
||||||
|
bankAccounts?: RecipientBankAccount[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipientBankAccount {
|
||||||
|
id: string;
|
||||||
|
bankName: string;
|
||||||
|
accountNumber: string;
|
||||||
|
accountHolderName: string;
|
||||||
|
accountType: AccountType;
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserAccount {
|
||||||
|
id: string;
|
||||||
|
accountType: AccountType;
|
||||||
|
accountName: string;
|
||||||
|
accountNumber: string;
|
||||||
|
balance: number;
|
||||||
|
bankName?: string;
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentMethod {
|
||||||
|
type: 'card' | 'mobile' | 'bank';
|
||||||
|
name: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransactionFeedback {
|
||||||
|
rating: number;
|
||||||
|
purpose: string;
|
||||||
|
otherReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DonationCause {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
image?: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,35 @@ export default {
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['DM Sans', 'sans-serif'],
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: '#10B981',
|
DEFAULT: '#105D38',
|
||||||
50: '#ECFDF5',
|
50: '#E6F5ED',
|
||||||
100: '#D1FAE5',
|
100: '#CCEBDC',
|
||||||
200: '#A7F3D0',
|
200: '#99D7B9',
|
||||||
300: '#6EE7B7',
|
300: '#66C396',
|
||||||
400: '#34D399',
|
400: '#33AF73',
|
||||||
500: '#10B981',
|
500: '#105D38',
|
||||||
600: '#059669',
|
600: '#0D4A2D',
|
||||||
700: '#047857',
|
700: '#0A3822',
|
||||||
800: '#065F46',
|
800: '#072517',
|
||||||
900: '#064E3B',
|
900: '#04130B',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: '#FFB668',
|
||||||
|
50: '#FFF5E6',
|
||||||
|
100: '#FFEBD1',
|
||||||
|
200: '#FFD7A3',
|
||||||
|
300: '#FFC375',
|
||||||
|
400: '#FFAF47',
|
||||||
|
500: '#FFB668',
|
||||||
|
600: '#CC9253',
|
||||||
|
700: '#996D3E',
|
||||||
|
800: '#66492A',
|
||||||
|
900: '#332415',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user