feat: implement Telebirr payment integration and update related components

This commit is contained in:
estifanos
2026-07-23 11:33:25 +00:00
parent 7e2186e758
commit fb1ea8f511
5 changed files with 160 additions and 5 deletions

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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
View File

@@ -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",

View File

@@ -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",