final-touches
This commit is contained in:
parent
264f1e6159
commit
3d66182b95
|
|
@ -4,6 +4,9 @@
|
|||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
</head>
|
||||
<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 { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { CheckoutProvider } from './contexts/CheckoutContext';
|
||||
import { Layout } from './components/layout/Layout';
|
||||
import { CheckoutPage } from './pages/CheckoutPage';
|
||||
import { AdminPage } from './pages/AdminPage';
|
||||
import { TransactionsPage } from './pages/TransactionsPage';
|
||||
import { SuspiciousPage } from './pages/SuspiciousPage';
|
||||
import { AccountsPage } from './pages/AccountsPage';
|
||||
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { ChatButton } from './components/ui/ChatButton';
|
||||
import { AccountSelectionPage } from './pages/AccountSelectionPage';
|
||||
import { CheckoutPageRoute } from './pages/CheckoutPageRoute';
|
||||
import { SuccessPageRoute } from './pages/SuccessPageRoute';
|
||||
import { Error404 } from './components/errors/Error404';
|
||||
import { NoInternet } from './components/errors/NoInternet';
|
||||
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() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<CheckoutProvider>
|
||||
<CheckoutPage />
|
||||
</CheckoutProvider>
|
||||
}
|
||||
/>
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/admin/transactions" element={<TransactionsPage />} />
|
||||
<Route path="/admin/suspicious" element={<SuspiciousPage />} />
|
||||
<Route path="/admin/accounts" element={<AccountsPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
<AppRoutes />
|
||||
<ChatButton />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { Account } from '../../types';
|
|||
import { Card } from '../ui/Card';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Input } from '../ui/Input';
|
||||
import { Loading } from '../ui/Loading';
|
||||
import { FiFlag, FiX, FiSearch, FiUser, FiBriefcase } from 'react-icons/fi';
|
||||
|
||||
export const AccountFlagging: React.FC = () => {
|
||||
|
|
@ -75,11 +76,7 @@ export const AccountFlagging: React.FC = () => {
|
|||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-gray-600">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
return <Loading fullScreen message="Loading accounts..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
|||
import { api } from '../../utils/api';
|
||||
import type { Transaction, Account } from '../../types';
|
||||
import { Card } from '../ui/Card';
|
||||
import { Loading } from '../ui/Loading';
|
||||
import { FiDollarSign, FiAlertTriangle, FiCheckCircle, FiClock, FiUsers } from 'react-icons/fi';
|
||||
|
||||
export const AdminDashboard: React.FC = () => {
|
||||
|
|
@ -47,11 +48,7 @@ export const AdminDashboard: React.FC = () => {
|
|||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-gray-600">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
return <Loading fullScreen message="Loading dashboard..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { api } from '../../utils/api';
|
|||
import type { Transaction } from '../../types';
|
||||
import { Card } from '../ui/Card';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Loading } from '../ui/Loading';
|
||||
import { FiAlertTriangle, FiCheckCircle, FiXCircle } from 'react-icons/fi';
|
||||
|
||||
export const SuspiciousTransactions: React.FC = () => {
|
||||
|
|
@ -71,11 +72,7 @@ export const SuspiciousTransactions: React.FC = () => {
|
|||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-gray-600">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
return <Loading fullScreen message="Loading suspicious transactions..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { api } from '../../utils/api';
|
|||
import type { Transaction } from '../../types';
|
||||
import { Card } from '../ui/Card';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Loading } from '../ui/Loading';
|
||||
import { FiSearch, FiFilter, FiEye } from 'react-icons/fi';
|
||||
|
||||
export const TransactionList: React.FC = () => {
|
||||
|
|
@ -89,11 +90,7 @@ export const TransactionList: React.FC = () => {
|
|||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-gray-600">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
return <Loading fullScreen message="Loading transactions..." />;
|
||||
}
|
||||
|
||||
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 { FundRequestForm } from './FundRequestForm';
|
||||
import { AccountSelection } from './AccountSelection';
|
||||
import { TransactionConfirmation } from './TransactionConfirmation';
|
||||
import { TransactionStatus } from './TransactionStatus';
|
||||
import { PromotionalPanel } from './PromotionalPanel';
|
||||
import { FiCheck } from 'react-icons/fi';
|
||||
import { RequesterDetails } from './RequesterDetails';
|
||||
import { CheckoutPage } from './CheckoutPage';
|
||||
import { Loading } from '../ui/Loading';
|
||||
|
||||
export const CheckoutFlow: React.FC = () => {
|
||||
const {
|
||||
currentStep,
|
||||
requester,
|
||||
selectedRecipientAccountId,
|
||||
selectedRecipientAccount,
|
||||
fundRequest,
|
||||
selectedAccountType,
|
||||
transaction,
|
||||
isLoading,
|
||||
error,
|
||||
setFundRequest,
|
||||
setAccountType,
|
||||
submitTransaction,
|
||||
reset,
|
||||
goToStep,
|
||||
transactionFee,
|
||||
setSelectedRecipientAccountId,
|
||||
submitPayment,
|
||||
} = useCheckout();
|
||||
|
||||
const handleFundRequestSubmit = (data: any) => {
|
||||
setFundRequest(data);
|
||||
goToStep(2);
|
||||
const [showCheckout, setShowCheckout] = useState(false);
|
||||
|
||||
const handleAccountSelect = (accountId: string) => {
|
||||
setSelectedRecipientAccountId(accountId);
|
||||
};
|
||||
|
||||
const handleAccountSelect = (accountType: 'business' | 'personal') => {
|
||||
setAccountType(accountType);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Proceed to checkout page
|
||||
setShowCheckout(true);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await submitTransaction();
|
||||
const handlePaymentComplete = async () => {
|
||||
await submitPayment();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
goToStep(1);
|
||||
};
|
||||
if (!requester) {
|
||||
return <Loading fullScreen message="Loading payment request..." />;
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{ number: 1, label: 'Request', title: 'Fund Request' },
|
||||
{ number: 2, label: 'Account', title: 'Account Type' },
|
||||
{ number: 3, label: 'Review', title: 'Confirmation' },
|
||||
{ number: 4, label: 'Status', title: 'Status' },
|
||||
];
|
||||
// Show checkout page if user clicked next
|
||||
if (showCheckout && requester && fundRequest) {
|
||||
// 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Show requester details page (initial page)
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-4 md:py-8 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Progress Indicator - Hidden on account selection step to match design */}
|
||||
{currentStep !== 2 && (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.number}>
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<div
|
||||
className={`
|
||||
w-10 h-10 rounded-full flex items-center justify-center
|
||||
transition-colors
|
||||
${
|
||||
currentStep > step.number
|
||||
? 'bg-primary-500 text-white'
|
||||
: currentStep === step.number
|
||||
? 'bg-primary-500 text-white ring-4 ring-primary-200'
|
||||
: 'bg-gray-200 text-gray-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{currentStep > step.number ? (
|
||||
<FiCheck className="w-5 h-5" />
|
||||
) : (
|
||||
<span className="text-sm font-medium">{step.number}</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`
|
||||
mt-2 text-xs font-medium hidden sm:block
|
||||
${
|
||||
currentStep >= step.number
|
||||
? 'text-primary-600'
|
||||
: 'text-gray-500'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`
|
||||
flex-1 h-1 mx-2 transition-colors
|
||||
${
|
||||
currentStep > step.number
|
||||
? 'bg-primary-500'
|
||||
: 'bg-gray-200'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step Content */}
|
||||
{currentStep === 2 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-[600px] rounded-lg overflow-hidden shadow-lg">
|
||||
<div className="hidden lg:block">
|
||||
<PromotionalPanel />
|
||||
</div>
|
||||
<div className="bg-white p-6 md:p-8 lg:p-12 flex items-center">
|
||||
<AccountSelection
|
||||
selectedAccountType={selectedAccountType || undefined}
|
||||
onSelect={handleAccountSelect}
|
||||
onNext={() => goToStep(3)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-sm p-6 md:p-8">
|
||||
{currentStep === 1 && (
|
||||
<FundRequestForm
|
||||
onSubmit={handleFundRequestSubmit}
|
||||
initialData={fundRequest || undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && fundRequest && selectedAccountType && (
|
||||
<TransactionConfirmation
|
||||
transactionData={{
|
||||
...fundRequest,
|
||||
accountType: selectedAccountType,
|
||||
}}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 4 && transaction && (
|
||||
<TransactionStatus
|
||||
transaction={transaction}
|
||||
onNewTransaction={reset}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<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'}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
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 { Logo } from '../ui/Logo';
|
||||
import { FiFacebook, FiTwitter, FiInstagram, FiLinkedin, FiSmartphone } from 'react-icons/fi';
|
||||
|
||||
export const PromotionalPanel: React.FC = () => {
|
||||
return (
|
||||
<div className="bg-primary-500 h-full flex flex-col justify-between p-8 md:p-12 text-white">
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-white rounded"></div>
|
||||
<span className="text-xl font-bold">sanswap</span>
|
||||
</div>
|
||||
<div className="bg-primary-500 h-full flex flex-col justify-between p-8 md:p-12 text-white 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 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 className="flex-1 flex flex-col justify-center">
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-4">
|
||||
<div className="flex-1 flex flex-col justify-center items-center text-center">
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold mb-6 max-w-2xl">
|
||||
Start searching for homes or posting ads now.
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-center">
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
<div 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>
|
||||
);
|
||||
|
|
|
|||
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 { Link, useLocation } from 'react-router-dom';
|
||||
import { Logo } from '../ui/Logo';
|
||||
import { FiHome, FiShield, FiUsers, FiAlertTriangle } from 'react-icons/fi';
|
||||
|
||||
interface LayoutProps {
|
||||
|
|
@ -18,7 +19,9 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||
<div className="flex justify-between h-16">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<span className="text-xl font-bold text-primary-600">Amba Checkout</span>
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<Logo />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
<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 type { FundRequest, AccountType, Transaction } from '../types';
|
||||
import React, { createContext, useContext, useState, ReactNode, useEffect } from 'react';
|
||||
import type { FundRequest, AccountType, Transaction, Requester, RecipientBankAccount, TransactionFeedback } from '../types';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
interface CheckoutContextType {
|
||||
currentStep: number;
|
||||
requester: Requester | null;
|
||||
selectedRecipientAccountId: string | null;
|
||||
selectedRecipientAccount: RecipientBankAccount | null;
|
||||
fundRequest: Partial<FundRequest> | null;
|
||||
selectedAccountType: AccountType | null;
|
||||
transaction: Transaction | null;
|
||||
showSuccessModal: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
transactionFee: number;
|
||||
setRequester: (requester: Requester) => void;
|
||||
setSelectedRecipientAccountId: (accountId: string) => void;
|
||||
setFundRequest: (data: Partial<FundRequest>) => void;
|
||||
setAccountType: (type: AccountType) => void;
|
||||
submitTransaction: () => Promise<void>;
|
||||
submitPayment: () => Promise<void>;
|
||||
submitFeedback: (feedback: TransactionFeedback) => void;
|
||||
closeSuccessModal: () => void;
|
||||
reset: () => void;
|
||||
goToStep: (step: number) => void;
|
||||
}
|
||||
|
|
@ -20,11 +29,80 @@ const CheckoutContext = createContext<CheckoutContextType | undefined>(undefined
|
|||
|
||||
export const CheckoutProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
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 [selectedAccountType, setSelectedAccountType] = useState<AccountType | null>(null);
|
||||
const [transaction, setTransaction] = useState<Transaction | null>(null);
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
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>) => {
|
||||
setFundRequestState(data);
|
||||
|
|
@ -36,8 +114,8 @@ export const CheckoutProvider: React.FC<{ children: ReactNode }> = ({ children }
|
|||
setError(null);
|
||||
};
|
||||
|
||||
const submitTransaction = async () => {
|
||||
if (!fundRequest || !selectedAccountType) {
|
||||
const submitPayment = async () => {
|
||||
if (!fundRequest || !selectedAccountType || !selectedRecipientAccountId) {
|
||||
setError('Please complete all required fields');
|
||||
return;
|
||||
}
|
||||
|
|
@ -53,19 +131,34 @@ export const CheckoutProvider: React.FC<{ children: ReactNode }> = ({ children }
|
|||
|
||||
const newTransaction = await api.createTransaction(transactionData);
|
||||
setTransaction(newTransaction);
|
||||
setCurrentStep(4);
|
||||
setShowSuccessModal(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create transaction');
|
||||
setError(err instanceof Error ? err.message : 'Failed to process payment');
|
||||
} finally {
|
||||
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 = () => {
|
||||
setCurrentStep(1);
|
||||
setSelectedRecipientAccountIdState(null);
|
||||
setSelectedRecipientAccountState(null);
|
||||
setFundRequestState(null);
|
||||
setSelectedAccountType(null);
|
||||
setTransaction(null);
|
||||
setShowSuccessModal(false);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
|
@ -80,14 +173,23 @@ export const CheckoutProvider: React.FC<{ children: ReactNode }> = ({ children }
|
|||
<CheckoutContext.Provider
|
||||
value={{
|
||||
currentStep,
|
||||
requester,
|
||||
selectedRecipientAccountId,
|
||||
selectedRecipientAccount,
|
||||
fundRequest,
|
||||
selectedAccountType,
|
||||
transaction,
|
||||
showSuccessModal,
|
||||
isLoading,
|
||||
error,
|
||||
transactionFee,
|
||||
setRequester,
|
||||
setSelectedRecipientAccountId,
|
||||
setFundRequest,
|
||||
setAccountType,
|
||||
submitTransaction,
|
||||
submitPayment,
|
||||
submitFeedback,
|
||||
closeSuccessModal,
|
||||
reset,
|
||||
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 {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
|
@ -15,3 +13,14 @@ body {
|
|||
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;
|
||||
flaggedBy?: string;
|
||||
createdAt: string;
|
||||
balance?: number;
|
||||
accountNumber?: string;
|
||||
}
|
||||
|
||||
export interface FundRequest {
|
||||
|
|
@ -36,3 +38,53 @@ export interface FundRequest {
|
|||
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: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['DM Sans', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#10B981',
|
||||
50: '#ECFDF5',
|
||||
100: '#D1FAE5',
|
||||
200: '#A7F3D0',
|
||||
300: '#6EE7B7',
|
||||
400: '#34D399',
|
||||
500: '#10B981',
|
||||
600: '#059669',
|
||||
700: '#047857',
|
||||
800: '#065F46',
|
||||
900: '#064E3B',
|
||||
DEFAULT: '#105D38',
|
||||
50: '#E6F5ED',
|
||||
100: '#CCEBDC',
|
||||
200: '#99D7B9',
|
||||
300: '#66C396',
|
||||
400: '#33AF73',
|
||||
500: '#105D38',
|
||||
600: '#0D4A2D',
|
||||
700: '#0A3822',
|
||||
800: '#072517',
|
||||
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