mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
feat: implement Telebirr payment integration and update related components
This commit is contained in:
@@ -39,6 +39,7 @@ import {
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { TelebirrPayment } from '../../payment/components/TelebirrPayment';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// STCW certificate catalog — sourced from the STCW Convention & Code
|
||||
@@ -650,8 +651,13 @@ export function CoCApplicationPage() {
|
||||
const [paymentDate, setPaymentDate] = useState('');
|
||||
const [paymentFile, setPaymentFile] = useState<File | null>(null);
|
||||
|
||||
// Telebirr order id, set once TelebirrPayment confirms — shown as the ref in Step 3
|
||||
const [telebirrOrderId, setTelebirrOrderId] = useState<string | null>(null);
|
||||
|
||||
const docsStepOk = docSlots.filter((d) => d.required).every((d) => !!docs[d.key]);
|
||||
const payOk = !!paymentMethod && paymentRef.trim().length > 0 && paymentDate.length > 0 && !!paymentFile;
|
||||
const payOk = paymentMethod === 'telebirr'
|
||||
? !!telebirrOrderId
|
||||
: !!paymentMethod && paymentRef.trim().length > 0 && paymentDate.length > 0 && !!paymentFile;
|
||||
|
||||
const FEES = [
|
||||
{ label: 'Application Fee', amount: selectedCert?.type === 'CoC' ? 800 : 300 },
|
||||
@@ -976,7 +982,7 @@ export function CoCApplicationPage() {
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{paymentMethod && (
|
||||
{paymentMethod === 'cbe' && (
|
||||
<Stack gap="sm">
|
||||
<Divider label="Payment Confirmation" labelPosition="left" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
@@ -1011,6 +1017,18 @@ export function CoCApplicationPage() {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{paymentMethod === 'telebirr' && (
|
||||
<Stack gap="sm">
|
||||
<Divider label="Telebirr Payment" labelPosition="left" />
|
||||
<TelebirrPayment
|
||||
onConfirmed={(orderId) => {
|
||||
setTelebirrOrderId(orderId);
|
||||
setStep(3);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={15} />} onClick={() => setStep(1)}>Back</Button>
|
||||
<Button rightSection={<IconArrowRight size={15} />} disabled={!payOk} onClick={() => setStep(3)}>
|
||||
@@ -1066,7 +1084,7 @@ export function CoCApplicationPage() {
|
||||
<Box>
|
||||
<Text fz="xs" c="dimmed" fw={600} mb="xs" style={{ textTransform: 'uppercase' }}>Payment</Text>
|
||||
<Badge variant="light" color="teal" size="sm">{paymentMethod === 'cbe' ? 'CBE Bank Transfer' : 'Telebirr'}</Badge>
|
||||
<Text fz="xs" c="dimmed" mt={2}>Ref: {paymentRef}</Text>
|
||||
<Text fz="xs" c="dimmed" mt={2}>Ref: {paymentMethod === 'telebirr' ? telebirrOrderId : paymentRef}</Text>
|
||||
<Text fz="xs" c="dimmed">ETB {TOTAL.toFixed(2)}</Text>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Box, Button, Group, Loader, Text } from '@mantine/core';
|
||||
import { IconCheck, IconExternalLink, IconQrcode } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import QRCode from 'react-qr-code';
|
||||
|
||||
type TelebirrStatus = 'creating' | 'ready' | 'polling' | 'confirmed';
|
||||
|
||||
interface TelebirrPaymentProps {
|
||||
onConfirmed: (orderId: string) => void;
|
||||
}
|
||||
|
||||
export function TelebirrPayment({ onConfirmed }: TelebirrPaymentProps) {
|
||||
const [status, setStatus] = useState<TelebirrStatus>('creating');
|
||||
const [orderId, setOrderId] = useState<string | null>(null);
|
||||
const [paymentUrl, setPaymentUrl] = useState<string | null>(null);
|
||||
const [choice, setChoice] = useState<'redirect' | 'qr' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const id = crypto.randomUUID();
|
||||
const t = setTimeout(() => {
|
||||
setOrderId(id);
|
||||
// ponytail: mock createPaymentTelebirr({ orderid: id }) response — replace with
|
||||
// useCreatePaymentTelebirrMutation() from features/payment/api/payment-api once backend is live
|
||||
setPaymentUrl(`https://h5.telebirr.et/pay/${id}`);
|
||||
setStatus('ready');
|
||||
}, 900);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
const startPolling = () => {
|
||||
setStatus('polling');
|
||||
// ponytail: mock status poll — replace with useGetPaymentStatusTelebirrQuery(orderId,
|
||||
// { pollingInterval: 3000 }) and branch on data.status once backend is live
|
||||
setTimeout(() => {
|
||||
setStatus('confirmed');
|
||||
notify.success('Telebirr payment confirmed');
|
||||
onConfirmed(orderId!);
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
const handleGoToTelebirr = () => {
|
||||
window.open(paymentUrl!, '_blank', 'noopener,noreferrer');
|
||||
setChoice('redirect');
|
||||
startPolling();
|
||||
};
|
||||
|
||||
const handleGenerateQr = () => {
|
||||
setChoice('qr');
|
||||
startPolling();
|
||||
};
|
||||
|
||||
if (status === 'creating') {
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<Loader size="sm" />
|
||||
<Text fz="sm" c="dimmed">Preparing Telebirr payment…</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap="sm" align="flex-start" wrap="wrap">
|
||||
{status === 'ready' && (
|
||||
<Group gap="sm">
|
||||
<Button leftSection={<IconExternalLink size={15} />} onClick={handleGoToTelebirr}>
|
||||
Go to Telebirr
|
||||
</Button>
|
||||
<Button variant="default" leftSection={<IconQrcode size={15} />} onClick={handleGenerateQr}>
|
||||
Generate QR Code
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{choice === 'qr' && paymentUrl && (
|
||||
<Box p="md" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 8, width: 'fit-content' }}>
|
||||
<QRCode value={paymentUrl} size={180} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status === 'polling' && (
|
||||
<Group gap="xs">
|
||||
<Loader size="sm" />
|
||||
<Text fz="sm" c="dimmed">Waiting for payment confirmation…</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{status === 'confirmed' && (
|
||||
<Alert variant="light" color="teal" icon={<IconCheck size={15} />}>
|
||||
Payment confirmed
|
||||
</Alert>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { TelebirrPayment } from '../../payment/components/TelebirrPayment';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Steps
|
||||
@@ -234,6 +235,8 @@ export function SeamanBookApplicationPage() {
|
||||
const [paymentDate, setPaymentDate] = useState('');
|
||||
const [paymentFile, setPaymentFile] = useState<File | null>(null);
|
||||
const payResetRef = useRef<() => void>(null);
|
||||
// Telebirr order id, set once TelebirrPayment confirms — real gateway flow, no manual ref for telebirr
|
||||
const [telebirrOrderId, setTelebirrOrderId] = useState<string | null>(null);
|
||||
|
||||
// Validation
|
||||
const bstComplete = BST_SLOTS.every((s) => {
|
||||
@@ -241,7 +244,10 @@ export function SeamanBookApplicationPage() {
|
||||
return !!d.file && !!d.certNumber.trim() && !!d.issuer.trim() && !!d.issueDate;
|
||||
});
|
||||
const medComplete = !!medFile && !!medCertNumber.trim() && !!medIssuer.trim() && !!medIssueDate && !!medExpiryDate;
|
||||
const payComplete = !!paymentMethod && !!paymentRef.trim() && !!paymentDate;
|
||||
// const payComplete = !!paymentMethod && !!paymentRef.trim() && !!paymentDate; // old: telebirr used to require a manual ref too
|
||||
const payComplete = paymentMethod === 'telebirr'
|
||||
? !!telebirrOrderId
|
||||
: !!paymentMethod && !!paymentRef.trim() && !!paymentDate;
|
||||
|
||||
const canNext = () => {
|
||||
if (active === 0) return bstComplete;
|
||||
@@ -523,6 +529,7 @@ export function SeamanBookApplicationPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* old manual Telebirr flow — replaced by the real gateway component below
|
||||
{paymentMethod === 'telebirr' && (
|
||||
<>
|
||||
<Alert variant="light" color="violet" icon={<IconInfoCircle size={15} />}>
|
||||
@@ -534,6 +541,19 @@ export function SeamanBookApplicationPage() {
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
*/}
|
||||
|
||||
{paymentMethod === 'telebirr' && (
|
||||
<>
|
||||
<Divider label="Telebirr Payment" labelPosition="left" />
|
||||
<TelebirrPayment
|
||||
onConfirmed={(orderId) => {
|
||||
setTelebirrOrderId(orderId);
|
||||
setActive(3);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{paymentMethod && (
|
||||
<>
|
||||
@@ -622,7 +642,8 @@ export function SeamanBookApplicationPage() {
|
||||
<Text fw={700} fz="sm" mb="sm">Payment</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Payment Method" value={paymentMethod === 'cbe' ? 'CBE Bank Transfer' : 'Telebirr'} />
|
||||
<ReviewRow label="Transaction Reference" value={paymentRef} />
|
||||
{/* <ReviewRow label="Transaction Reference" value={paymentRef} /> old: telebirr no longer collects a manual ref */}
|
||||
<ReviewRow label="Transaction Reference" value={paymentMethod === 'telebirr' ? (telebirrOrderId ?? '') : paymentRef} />
|
||||
<ReviewRow label="Payment Date" value={paymentDate} />
|
||||
<ReviewRow label="Total Paid" value={`ETB ${TOTAL.toFixed(2)}`} />
|
||||
<ReviewRow label="Receipt" value={paymentFile?.name ?? 'Not uploaded'} />
|
||||
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -30,6 +30,7 @@
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-i18next": "^15.7.4",
|
||||
"react-qr-code": "^2.2.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.12.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
@@ -16542,6 +16543,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode-generator": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
|
||||
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
@@ -16880,6 +16887,19 @@
|
||||
"@napi-rs/canvas": "^0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/react-qr-code": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.2.0.tgz",
|
||||
"integrity": "sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.8.1",
|
||||
"qrcode-generator": "^2.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-i18next": "^15.7.4",
|
||||
"react-qr-code": "^2.2.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.12.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
|
||||
Reference in New Issue
Block a user