From 599dc4aa83ebb75b363479b06d82e28083d9b945 Mon Sep 17 00:00:00 2001 From: estifanos Date: Mon, 3 Aug 2026 09:22:02 +0000 Subject: [PATCH] feat(i18n): add new translations for refresh, view, toggle columns, and no results refactor(payment): remove payment API client and related tests, consolidating payment logic refactor(payment): delete payment modal and associated constants, hooks, and types fix(ui): enhance AdvancedTable with refresh button and column toggle functionality fix(ui): implement pagination in useServerTable for better data handling --- .../certification/pages/CertificationPage.tsx | 104 +++---- .../src/app/features/exam/pages/ExamPage.tsx | 253 +++++++++--------- .../license-review/pages/LicenseQueuePage.tsx | 5 + .../features/question/pages/QuestionPage.tsx | 124 +++++---- .../app/features/result/pages/ResultPage.tsx | 165 ++++++------ .../pages/SeafarerRegistryPage.tsx | 105 ++++---- apps/backoffice/src/app/i18n/locales/am.ts | 4 + apps/backoffice/src/app/i18n/locales/en.ts | 4 + .../src/app/features/payment/api/client.ts | 96 ------- .../app/features/payment/api/payments.test.ts | 103 ------- .../src/app/features/payment/api/payments.ts | 122 --------- .../payment/components/PaymentModal.tsx | 156 ----------- .../src/app/features/payment/constants.ts | 51 ---- .../features/payment/hooks/usePaymentFlow.ts | 249 ----------------- .../features/payment/pages/PaymentsPage.tsx | 120 --------- .../src/app/features/payment/types/payment.ts | 81 ------ .../src/app/features/payment/utils/money.ts | 20 -- libs/ui/src/lib/data/AdvancedTable.tsx | 68 ++--- libs/ui/src/lib/data/useServerTable.ts | 17 ++ 19 files changed, 452 insertions(+), 1395 deletions(-) delete mode 100644 apps/portal/src/app/features/payment/api/client.ts delete mode 100644 apps/portal/src/app/features/payment/api/payments.test.ts delete mode 100644 apps/portal/src/app/features/payment/api/payments.ts delete mode 100644 apps/portal/src/app/features/payment/components/PaymentModal.tsx delete mode 100644 apps/portal/src/app/features/payment/constants.ts delete mode 100644 apps/portal/src/app/features/payment/hooks/usePaymentFlow.ts delete mode 100644 apps/portal/src/app/features/payment/pages/PaymentsPage.tsx delete mode 100644 apps/portal/src/app/features/payment/types/payment.ts delete mode 100644 apps/portal/src/app/features/payment/utils/money.ts diff --git a/apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx b/apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx index a11742cb6..7d2547dd0 100644 --- a/apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx +++ b/apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx @@ -4,7 +4,6 @@ import { Title, Group, Button, - Table, Badge, ActionIcon, Modal, @@ -12,14 +11,13 @@ import { TextInput, Textarea, Paper, - Loader, - Center, + Card, Alert, } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { useTranslation } from 'react-i18next'; import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCertificate } from '@tabler/icons-react'; -import { notify, useErrorHandler } from '@ema-platform/ui'; +import { notify, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from '@ema-platform/ui'; import { useGetCertificationsQuery, useCreateCertificationMutation, @@ -76,7 +74,8 @@ export function CertificationPage() { const { t, i18n } = useTranslation(); const locale = i18n.language as 'en' | 'am'; const { handleError } = useErrorHandler(); - const { data, isLoading, isError } = useGetCertificationsQuery(); + const { data, isFetching, isError, refetch } = useGetCertificationsQuery(); + const { setPageIndex, pageSize, paginate } = useServerTable({ pageSize: 25 }); const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation(); const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation(); const [deleteCert] = useDeleteCertificationMutation(); @@ -122,9 +121,44 @@ export function CertificationPage() { } }; - if (isLoading) return
; if (isError) return } color="red" title={t('certification.loadError')} />; + const columns: AdvancedColumn[] = [ + { + header: t('certification.columns.name'), + cell: ({ row }) => {row.original.name[locale]}, + }, + { + header: t('certification.columns.description'), + cell: ({ row }) => {row.original.description[locale]}, + }, + { + header: t('certification.columns.status'), + cell: ({ row }) => ( + + {row.original.isActive ? t('certification.status.active') : t('certification.status.inactive')} + + ), + }, + { + header: '', + label: t('certification.columns.actions', 'Actions'), + size: 90, + cell: ({ row }) => ( + + { setEditing(row.original); setShowForm(true); }}> + + + { setDeleteTarget(row.original); openDelete(); }}> + + + + ), + }, + ]; + + const page = paginate(certifications); + return ( @@ -148,50 +182,20 @@ export function CertificationPage() { /> )} - - - - - {t('certification.columns.name')} - {t('certification.columns.description')} - {t('certification.columns.status')} - - - - - {certifications.map((cert) => ( - - {cert.name[locale]} - - {cert.description[locale]} - - - - {cert.isActive ? t('certification.status.active') : t('certification.status.inactive')} - - - - - { setEditing(cert); setShowForm(true); }}> - - - { setDeleteTarget(cert); openDelete(); }}> - - - - - - ))} - {certifications.length === 0 && ( - - - {t('certification.noItems')} - - - )} - -
-
+ + + {t('certification.deleteConfirmText', { name: deleteTarget?.name?.[locale] })} diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx index a84cfc60a..284c991eb 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx @@ -5,7 +5,6 @@ import { Title, Group, Button, - Table, Badge, ActionIcon, Modal, @@ -13,8 +12,7 @@ import { TextInput, Textarea, Paper, - Loader, - Center, + Card, Alert, Select, NumberInput, @@ -33,7 +31,7 @@ import { IconClipboardList, IconDetails, } from "@tabler/icons-react"; -import { notify, useErrorHandler } from "@ema-platform/ui"; +import { notify, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from "@ema-platform/ui"; import { useGetCertificationsQuery } from "../../certification/api/certification-api"; import { useGetExamsQuery, @@ -356,7 +354,8 @@ export function ExamPage() { const { handleError } = useErrorHandler(); const locale = i18n.language as "en" | "am"; const { data: certRes } = useGetCertificationsQuery(); - const { data, isLoading, isError } = useGetExamsQuery(); + const { data, isFetching, isError, refetch } = useGetExamsQuery(); + const { setPageIndex, pageSize, paginate } = useServerTable({ pageSize: 25 }); const [createExam, { isLoading: isCreating }] = useCreateExamMutation(); const [updateExam, { isLoading: isUpdating }] = useUpdateExamMutation(); const [deleteExam] = useDeleteExamMutation(); @@ -431,12 +430,6 @@ export function ExamPage() { } }; - if (isLoading) - return ( -
- -
- ); if (isError) return ( ); + const columns: AdvancedColumn[] = [ + { + header: t("exam.columns.title"), + cell: ({ row }) => ( + navigate(`/exams/${row.original.id}`)} + > + {row.original.title[locale]} + + ), + }, + { + header: t("exam.columns.certification"), + cell: ({ row }) => {getCertName(row.original.certificationId)}, + }, + { + header: t("exam.columns.date"), + cell: ({ row }) => {row.original.date}, + }, + { + header: t("exam.columns.type"), + cell: ({ row }) => ( + + {t(`exam.type.${row.original.type}`)} + + ), + }, + { + header: t("exam.columns.form"), + cell: ({ row }) => ( + + {t(`exam.formType.${row.original.form}`)} + + ), + }, + { + header: t("exam.columns.venue"), + cell: ({ row }) => {row.original.venue}, + }, + { + header: t("exam.columns.questions"), + cell: ({ row }) => ( + + {row.original.questions?.length ?? 0} + + ), + }, + { + header: t("exam.columns.status"), + cell: ({ row }) => ( + + {t(`exam.status.${row.original.status}`)} + + ), + }, + { + header: t("exam.columns.actions"), + align: "right", + cell: ({ row }) => ( + + { + setEditing(row.original); + setShowForm(true); + }} + > + + + { + setDeleteTarget(row.original); + openDelete(); + }} + > + + + { + navigate(`/exams/${row.original.id}`); + }} + > + + + + ), + }, + ]; + + const page = paginate(exams); + return ( @@ -477,127 +573,20 @@ export function ExamPage() { /> )} - - - - - {t("exam.columns.title")} - {t("exam.columns.certification")} - {t("exam.columns.date")} - {t("exam.columns.type")} - {t("exam.columns.form")} - {t("exam.columns.venue")} - {t("exam.columns.questions")} - {t("exam.columns.status")} - {t("exam.columns.actions")} - - - - - {exams.map((exam) => ( - - - navigate(`/exams/${exam.id}`)} - > - {exam.title[locale]} - - - - {getCertName(exam.certificationId)} - - - {exam.date} - - - - {t(`exam.type.${exam.type}`)} - - - - - {t(`exam.formType.${exam.form}`)} - - - - {exam.venue} - - - - {exam.questions?.length ?? 0} - - - - - {t(`exam.status.${exam.status}`)} - - - - - { - setEditing(exam); - setShowForm(true); - }} - > - - - { - setDeleteTarget(exam); - openDelete(); - }} - > - - - { - navigate(`/exams/${exam.id}`); - }} - > - - - - - - ))} - {exams.length === 0 && ( - - - - {t("exam.noItems")} - - - - )} - -
-
+ + + {/* Delete confirmation */} ( {row.original.applicationNumber} @@ -330,6 +331,7 @@ export function LicenseQueuePage() { }, { header: sortableHeader(t('queue.company', 'Company'), 'companyName'), + label: t('queue.company', 'Company'), cell: ({ row }) => {row.original.companyName ?? '—'}, }, { @@ -346,6 +348,7 @@ export function LicenseQueuePage() { }, { header: sortableHeader(t('queue.statusCol', 'Status'), 'status'), + label: t('queue.statusCol', 'Status'), cell: ({ row }) => ( {STATUS_LABELS[row.original.status]} @@ -354,6 +357,7 @@ export function LicenseQueuePage() { }, { header: sortableHeader(t('queue.submitted', 'Submitted'), 'submittedAt'), + label: t('queue.submitted', 'Submitted'), cell: ({ row }) => ( {row.original.submittedAt @@ -378,6 +382,7 @@ export function LicenseQueuePage() { }, { header: '', + label: t('queue.actionsColumn', 'Actions'), align: 'right', size: 140, cell: ({ row }) => diff --git a/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx b/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx index 8f1ec0486..aa7210240 100644 --- a/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx +++ b/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx @@ -4,15 +4,13 @@ import { Title, Group, Button, - Table, Badge, ActionIcon, Modal, Text, TextInput, Paper, - Loader, - Center, + Card, Alert, Select, NumberInput, @@ -20,7 +18,7 @@ import { import { useDisclosure } from '@mantine/hooks'; import { useTranslation } from 'react-i18next'; import { IconEdit, IconTrash, IconPlus, IconInfoCircle } from '@tabler/icons-react'; -import { notify, useErrorHandler } from '@ema-platform/ui'; +import { notify, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from '@ema-platform/ui'; import { useGetCertificationsQuery } from '../../certification/api/certification-api'; import { useGetQuestionsQuery, @@ -104,7 +102,8 @@ export function QuestionPage() { const locale = i18n.language as 'en' | 'am'; const { handleError } = useErrorHandler(); const { data: certRes } = useGetCertificationsQuery(); - const { data, isLoading, isError } = useGetQuestionsQuery(); + const { data, isFetching, isError, refetch } = useGetQuestionsQuery(); + const { setPageIndex, pageSize, paginate } = useServerTable({ pageSize: 25 }); const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation(); const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation(); const [deleteQ] = useDeleteQuestionMutation(); @@ -121,6 +120,7 @@ export function QuestionPage() { const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] })); const filtered = questions.filter((q) => !certFilter || q.certificationId === certFilter); + const page = paginate(filtered); const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-'; @@ -158,9 +158,53 @@ export function QuestionPage() { } }; - if (isLoading) return
; if (isError) return } color="red" title={t('question.loadError')} />; + const columns: AdvancedColumn[] = [ + { + header: t('question.columns.title'), + cell: ({ row }) => {row.original.title[locale]}, + }, + { + header: t('question.columns.certification'), + cell: ({ row }) => {getCertName(row.original.certificationId)}, + }, + { + header: t('question.columns.form'), + cell: ({ row }) => ( + + {t(`question.form.${row.original.form === 'ESSAY' ? 'essay' : 'choice'}`)} + + ), + }, + { + header: t('question.columns.points'), + cell: ({ row }) => {row.original.points}, + }, + { + header: t('question.columns.status'), + cell: ({ row }) => ( + + {row.original.isActive ? t('question.status.active') : t('question.status.inactive')} + + ), + }, + { + header: '', + label: t('question.columns.actions', 'Actions'), + cell: ({ row }) => ( + + { setEditing(row.original); setShowForm(true); }}> + + + { setDeleteTarget(row.original); openDelete(); }}> + + + + ), + }, + ]; + return ( @@ -182,54 +226,32 @@ export function QuestionPage() { /> )} - + {t('question.pool')} - { setCertFilter(v ?? null); setPageIndex(0); }} + size="sm" + style={{ width: 280 }} + clearable + /> - - - - {t('question.columns.title')} - {t('question.columns.certification')} - {t('question.columns.form')} - {t('question.columns.points')} - {t('question.columns.status')} - - - - - {filtered.map((q) => ( - - {q.title[locale]} - {getCertName(q.certificationId)} - {t(`question.form.${q.form === 'ESSAY' ? 'essay' : 'choice'}`)} - {q.points} - - {q.isActive ? t('question.status.active') : t('question.status.inactive')} - - - - { setEditing(q); setShowForm(true); }}> - - - { setDeleteTarget(q); openDelete(); }}> - - - - - - ))} - {filtered.length === 0 && ( - - - {t('question.noQuestions')} - - - )} - -
-
+ + {t('question.deleteConfirmText')} diff --git a/apps/backoffice/src/app/features/result/pages/ResultPage.tsx b/apps/backoffice/src/app/features/result/pages/ResultPage.tsx index 5094a148f..5845ccda7 100644 --- a/apps/backoffice/src/app/features/result/pages/ResultPage.tsx +++ b/apps/backoffice/src/app/features/result/pages/ResultPage.tsx @@ -9,6 +9,7 @@ import { Modal, Text, Paper, + Card, Loader, Center, Alert, @@ -35,7 +36,7 @@ import { IconChartBar, IconSearch, } from '@tabler/icons-react'; -import { notify, BilingualInput, useErrorHandler } from '@ema-platform/ui'; +import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, type AdvancedColumn } from '@ema-platform/ui'; import type { BilingualValue } from '@ema-platform/ui'; import { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation, useUpdateResultMutation } from '../api/result-api'; import { useGetExamsQuery } from '../../exam/api/exam-api'; @@ -97,7 +98,8 @@ export function ResultPage() { const locale = i18n.language as 'en' | 'am'; const { handleError } = useErrorHandler(); const { data: examRes } = useGetExamsQuery(); - const { data, isLoading, isError } = useGetResultsQuery(); + const { data, isFetching, isError, refetch } = useGetResultsQuery(); + const { setPageIndex, pageSize, paginate } = useServerTable({ pageSize: 25 }); const [fetchDetail, { data: detailResult, isFetching: isDetailLoading }] = useLazyGetResultQuery(); const exams = examRes?.items ?? []; @@ -207,9 +209,76 @@ export function ResultPage() { } }; - if (isLoading) return
; if (isError) return } color="red" title={t('result.loadError')} />; + const columns: AdvancedColumn[] = [ + { + header: t('result.columns.seafarer'), + cell: ({ row }) => ( + + {row.original.seafarer + ? `${row.original.seafarer.firstName} ${row.original.seafarer.lastName}` + : row.original.seafarerId.slice(0, 8)} + + ), + }, + { + header: t('result.columns.exam'), + cell: ({ row }) => ( + {row.original.exam ? row.original.exam.title[locale] : getExamTitle(row.original.examId)} + ), + }, + { + header: t('result.columns.totalScore'), + cell: ({ row }) => {row.original.totalScore}, + }, + { + header: t('result.columns.status'), + cell: ({ row }) => ( + + } + > + {t(`result.status.${row.original.status}`)} + + ), + }, + { + header: t('result.columns.date'), + cell: ({ row }) => {new Date(row.original.createdAt).toLocaleDateString()}, + }, + { + header: '', + label: t('result.columns.actions', 'Actions'), + cell: ({ row }) => ( + + + + + ), + }, + ]; + + const page = paginate(filtered); + return ( @@ -229,7 +298,7 @@ export function ResultPage() { - + {t('result.section')} @@ -237,7 +306,7 @@ export function ResultPage() { placeholder={t('result.search.seafarer')} leftSection={} value={searchQuery} - onChange={(e) => setSearchQuery(e.currentTarget.value)} + onChange={(e) => { setSearchQuery(e.currentTarget.value); setPageIndex(0); }} size="sm" style={{ width: 240 }} /> @@ -245,7 +314,7 @@ export function ResultPage() { placeholder={t('result.search.filterByExam')} data={[{ value: '', label: t('result.search.allExams') }, ...examOptions]} value={examFilter} - onChange={(v) => setExamFilter(v ?? null)} + onChange={(v) => { setExamFilter(v ?? null); setPageIndex(0); }} size="sm" style={{ width: 280 }} clearable @@ -253,77 +322,19 @@ export function ResultPage() { - - - - {t('result.columns.seafarer')} - {t('result.columns.exam')} - {t('result.columns.totalScore')} - {t('result.columns.status')} - {t('result.columns.date')} - - - - - {filtered.map((r) => ( - - - - {r.seafarer ? `${r.seafarer.firstName} ${r.seafarer.lastName}` : r.seafarerId.slice(0, 8)} - - - {r.exam ? r.exam.title[locale] : getExamTitle(r.examId)} - {r.totalScore} - - - } - > - {t(`result.status.${r.status}`)} - - - {new Date(r.createdAt).toLocaleDateString()} - - - - - - - - ))} - {filtered.length === 0 && ( - - - {t('result.noItems')} - - - )} - -
-
+ + ({ + const { data, isFetching, refetch } = useApiQuery<{ total: number; items: ProfileRow[] }>({ url: '/profiles', method: 'GET', params: { q: 'i=profession,address&t=200' }, }); + const { setPageIndex, pageSize, paginate } = useServerTable({ pageSize: 25 }); const items = (data?.items ?? []).filter((p) => { if (!search.trim()) return true; @@ -47,6 +46,38 @@ export function SeafarerRegistryPage() { .filter(Boolean) .some((v) => String(v).toLowerCase().includes(term)); }); + const page = paginate(items); + + const columns: AdvancedColumn[] = [ + { + header: 'Name', + cell: ({ row }) => ( + + {[row.original.firstName, row.original.middleName, row.original.lastName].filter(Boolean).join(' ')} + + ), + }, + { + header: 'Profession', + cell: ({ row }) => {row.original.profession?.name?.en ?? '—'}, + }, + { + header: 'ID number', + cell: ({ row }) => {row.original.address?.idNumber ?? '—'}, + }, + { + header: 'Phone', + cell: ({ row }) => {row.original.address?.primaryPhoneNumber ?? '—'}, + }, + { + header: 'Status', + cell: ({ row }) => ( + + {row.original.isComplete ? 'Complete' : 'Incomplete'} + + ), + }, + ]; return ( @@ -61,64 +92,24 @@ export function SeafarerRegistryPage() { placeholder="Name or ID number" leftSection={} value={search} - onChange={(e) => setSearch(e.currentTarget.value)} + onChange={(e) => { setSearch(e.currentTarget.value); setPageIndex(0); }} w={260} />
- {isLoading ? ( -
- -
- ) : items.length === 0 ? ( -
- - {search ? 'No profiles match that search.' : 'No seafarers registered yet.'} - -
- ) : ( - - - - Name - Profession - ID number - Phone - Status - - - - {items.map((p) => ( - - - - {[p.firstName, p.middleName, p.lastName].filter(Boolean).join(' ')} - - - - {p.profession?.name?.en ?? '—'} - - - - {p.address?.idNumber ?? '—'} - - - - - {p.address?.primaryPhoneNumber ?? '—'} - - - - - {p.isComplete ? 'Complete' : 'Incomplete'} - - - - ))} - -
- )} +
); diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index a2949e7cb..0eaa66502 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -98,6 +98,10 @@ export const am: Translations = { collapse: 'ሰብስብ', expand: 'ዘርጋ', toggleTheme: 'የብርሃን / ጨለማ ሁነታን ይቀይሩ', + refresh: 'አድስ', + view: 'ይመልከቱ', + toggleColumns: 'አምዶችን ቀያይር', + noResult: 'ምንም አልተገኘም', }, breadcrumbs: { diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 5bb567862..d928b0e42 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -96,6 +96,10 @@ export const en = { collapse: 'Collapse', expand: 'Expand', toggleTheme: 'Toggle light / dark mode', + refresh: 'Refresh', + view: 'View', + toggleColumns: 'Toggle columns', + noResult: 'No results', }, breadcrumbs: { diff --git a/apps/portal/src/app/features/payment/api/client.ts b/apps/portal/src/app/features/payment/api/client.ts deleted file mode 100644 index 0e09c33cd..000000000 --- a/apps/portal/src/app/features/payment/api/client.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { REQUEST_TIMEOUT_MS } from '../constants'; - -/** - * The ONLY file in this feature that knows a URL, a header name, an env var, or `fetch` exists. - * Everything above this file (services, hooks, components) calls `paymentFetch` and never sees - * any of that — so swapping direct-to-payment-service calls for a backend proxy later means - * changing this file alone: repoint VITE_PAYMENT_API_URL at the proxy path and nothing else moves. - */ - -function readEnv(name: string): string | undefined { - return (import.meta as { env?: Record }).env?.[name]; -} - -interface PaymentClientConfig { - baseUrl: string; - serviceToken: string; -} - -function resolveConfig(): PaymentClientConfig { - const baseUrl = readEnv('VITE_PAYMENT_API_URL'); - const serviceToken = readEnv('VITE_PAYMENT_SERVICE_TOKEN'); - if (!baseUrl || !serviceToken) { - // Thrown lazily (only when a payment is actually attempted), not at module load — a - // module-load throw would break the whole bundle for every user who never pays. - throw new Error('Payment is not configured. Contact support.'); - } - return { baseUrl, serviceToken }; -} - -function messageForStatus(status: number): string { - if (status >= 500) return 'The payment service is unavailable right now. Please try again.'; - if (status === 404) return 'The payment could not be found.'; - if (status >= 400) return 'The payment service rejected the request.'; - return `The payment service responded with an unexpected status (${status}).`; -} - -/** Reads a possibly-empty response body as text, never letting a malformed body throw raw. */ -async function parseBody(res: Response): Promise { - const text = await res.text(); - if (!text) return undefined; // empty body (e.g. 204) is valid, not an error - try { - return JSON.parse(text); - } catch { - return undefined; // malformed JSON is handled by the caller, never surfaced as a raw SyntaxError - } -} - -function extractServerMessage(body: unknown): string | undefined { - if (typeof body !== 'object' || body === null) return undefined; - const record = body as Record; - const message = record['message'] ?? record['error'] ?? record['detail']; - return typeof message === 'string' ? message : undefined; -} - -/** - * The single request path for the whole feature. Attaches Content-Type + x-service-token exactly - * once, times every call out, and never lets a raw fetch/parse failure escape — every error path - * throws a plain Error with a message that's safe to show the user directly (ApiErrorAlert renders - * Error.message as-is). - */ -export async function paymentFetch(path: string, init?: RequestInit): Promise { - const { baseUrl, serviceToken } = resolveConfig(); - - let res: Response; - try { - res = await fetch(`${baseUrl}${path}`, { - ...init, - headers: { - 'Content-Type': 'application/json', - 'x-service-token': serviceToken, - ...init?.headers, - }, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') { - throw new Error('The payment service took too long to respond.'); - } - // fetch rejects with TypeError on network failure (offline, DNS, CORS) — never re-throw it raw. - throw new Error("Can't reach the payment service. Check your connection and try again."); - } - - const body = await parseBody(res); - - if (!res.ok) { - throw new Error(extractServerMessage(body) ?? messageForStatus(res.status)); - } - - if (body === undefined) { - // A 2xx with an unreadable/empty body is only valid for endpoints that don't promise a payload; - // callers that need a value will simply get `undefined` typed as T rather than a thrown SyntaxError. - return undefined as T; - } - - return body as T; -} diff --git a/apps/portal/src/app/features/payment/api/payments.test.ts b/apps/portal/src/app/features/payment/api/payments.test.ts deleted file mode 100644 index 5fb1eb24e..000000000 --- a/apps/portal/src/app/features/payment/api/payments.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, it, beforeEach } from 'vitest'; -import { - isSuccessStatus, - isTerminalStatus, - listPaymentHistory, - recordPayment, - updatePaymentStatus, -} from './payments'; -import { toMinor } from '../utils/money'; -import type { PaymentHistoryRecord } from '../types/payment'; - -// jsdom/happy-dom aren't installed in this repo, so localStorage is stubbed with a minimal -// in-memory shim rather than adding a dependency for one test file. -function installLocalStorageStub() { - const store = new Map(); - (globalThis as { localStorage?: Storage }).localStorage = { - getItem: (key: string) => store.get(key) ?? null, - setItem: (key: string, value: string) => void store.set(key, value), - removeItem: (key: string) => void store.delete(key), - clear: () => store.clear(), - key: () => null, - get length() { - return store.size; - }, - } as Storage; -} -installLocalStorageStub(); - -const sampleRecord: PaymentHistoryRecord = { - intentId: 'intent-1', - title: 'Seaman Book Application', - orderRef: 'SB-ABC123', - referenceId: 'SB-ABC123', - provider: 'TELEBIRR', - amountMinor: 140000, - currency: 'ETB', - status: 'PENDING', - createdAt: '2026-01-01T00:00:00.000Z', -}; - -beforeEach(() => { - localStorage.clear(); -}); - -describe('isTerminalStatus / isSuccessStatus', () => { - it('treats an unknown status as neither terminal nor success', () => { - expect(isTerminalStatus('WEIRD_STATE')).toBe(false); - expect(isSuccessStatus('WEIRD_STATE')).toBe(false); - }); - - it('treats PAY_SUCCESS (Telebirr return param) as both terminal and success', () => { - expect(isTerminalStatus('PAY_SUCCESS')).toBe(true); - expect(isSuccessStatus('PAY_SUCCESS')).toBe(true); - }); - - it('is case-insensitive', () => { - expect(isTerminalStatus('failed')).toBe(true); - expect(isSuccessStatus('success')).toBe(true); - }); - - it('treats undefined as neither', () => { - expect(isTerminalStatus(undefined)).toBe(false); - expect(isSuccessStatus(undefined)).toBe(false); - }); -}); - -describe('toMinor', () => { - it('rounds to the nearest minor unit', () => { - expect(toMinor(19.99)).toBe(1999); - expect(toMinor(1400)).toBe(140000); - }); -}); - -describe('payment history round-trip', () => { - it('records, lists, and updates a payment', () => { - recordPayment(sampleRecord); - expect(listPaymentHistory()).toHaveLength(1); - - updatePaymentStatus('intent-1', 'SUCCESS'); - const [updated] = listPaymentHistory(); - expect(updated.status).toBe('SUCCESS'); - }); - - it('upserts by intentId instead of duplicating', () => { - recordPayment(sampleRecord); - recordPayment({ ...sampleRecord, status: 'FAILED' }); - const history = listPaymentHistory(); - expect(history).toHaveLength(1); - expect(history[0].status).toBe('FAILED'); - }); - - it('does not rewrite storage when the status is unchanged', () => { - recordPayment(sampleRecord); - const before = localStorage.getItem('ema-payment-history'); - updatePaymentStatus('intent-1', 'PENDING'); // same status as recorded - expect(localStorage.getItem('ema-payment-history')).toBe(before); - }); - - it('tolerates corrupt JSON and returns an empty list', () => { - localStorage.setItem('ema-payment-history', '{not json'); - expect(listPaymentHistory()).toEqual([]); - }); -}); diff --git a/apps/portal/src/app/features/payment/api/payments.ts b/apps/portal/src/app/features/payment/api/payments.ts deleted file mode 100644 index 789bf3624..000000000 --- a/apps/portal/src/app/features/payment/api/payments.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { paymentFetch } from './client'; -import { PAYMENT_HISTORY_KEY, SUCCESS_STATUSES, TERMINAL_STATUSES } from '../constants'; -import type { - PaymentHistoryRecord, - PaymentIntent, - PaymentRequest, - PaymentStatus, -} from '../types/payment'; - -// The complete surface components/hooks are allowed to import. Everything below goes through -// `paymentFetch` — nothing here (or above it) knows a URL, token, header, or `fetch` exists. - -/** POST /payments/initiate — the only way a new payment intent gets created. */ -export function initiatePayment(req: PaymentRequest): Promise { - return paymentFetch('/payments/initiate', { - method: 'POST', - body: JSON.stringify(req), - }); -} - -/** GET /payments/intents/:intentId — used both by the poll and by manual "check again". */ -export function getIntent(intentId: string): Promise { - return paymentFetch(`/payments/intents/${intentId}`); - -} - -/** - * POST /payments/intents/:intentId/confirm — part of the documented contract, exported for - * completeness. No requirement asks for an OTP screen and the modal's responsibilities don't - * include one, so nothing in the UI calls this yet; building an OTP flow would be inventing - * behaviour the spec never asked for. - */ -export function confirmIntent(intentId: string, otp: string): Promise { - return paymentFetch(`/payments/intents/${intentId}/confirm`, { - method: 'POST', - body: JSON.stringify({ otp }), - }); -} - -/** - * Both predicates default to `false` for an unrecognised or missing status. That default matters - * in both directions: an unknown status must never be read as success, and it must never stop the - * poll (refetchInterval keys off isTerminalStatus — `false` means "keep polling"). Guessing the - * other way in either case would silently lose or fake a payment. - */ -export function isTerminalStatus(status: PaymentStatus | undefined): boolean { - return !!status && TERMINAL_STATUSES.has(status.toUpperCase()); -} - -export function isSuccessStatus(status: PaymentStatus | undefined): boolean { - return !!status && SUCCESS_STATUSES.has(status.toUpperCase()); -} - -// ---- localStorage history ------------------------------------------------- - -export function listPaymentHistory(): PaymentHistoryRecord[] { - const raw = localStorage.getItem(PAYMENT_HISTORY_KEY); - if (!raw) return []; - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? (parsed as PaymentHistoryRecord[]) : []; - } catch { - return []; // corrupt JSON is treated as "no history", never a crash - } -} - -function savePaymentHistory(records: PaymentHistoryRecord[]): void { - localStorage.setItem(PAYMENT_HISTORY_KEY, JSON.stringify(records)); -} - -/** Upsert by intentId — a retry creates a new record; re-recording the same intent replaces it in place. */ -export function recordPayment(record: PaymentHistoryRecord): void { - const history = listPaymentHistory(); - const idx = history.findIndex((r) => r.intentId === record.intentId); - if (idx === -1) { - history.push(record); - } else { - history[idx] = record; - } - savePaymentHistory(history); -} - -export function updatePaymentStatus(intentId: string, status: PaymentStatus): void { - const history = listPaymentHistory(); - const record = history.find((r) => r.intentId === intentId); - if (!record || record.status === status) return; // no-op write: reconcile must not churn storage - record.status = status; - savePaymentHistory(history); -} - -/** - * Refreshes AT MOST ONE record from the backend and writes it back if its status changed. - * - * Why one record, not all: the Telebirr return URL identifies exactly one payment (`merch_order_id`), - * the documented contract has no batch-status endpoint, and a terminal record can never change again — - * so re-fetching the rest of history would be N requests buying zero new information. When `orderRef` - * is omitted (a bare `reconcilePaymentHistory()` call), the single most-recent non-terminal record is - * used instead, since that's the only one that could plausibly still be in flight. - * - * A failed fetch leaves the local record untouched (best-effort refresh, never blanks history) and is - * swallowed to a console.warn — reconciliation must not crash the page it's called from. - */ -export async function reconcilePaymentHistory(orderRef?: string): Promise { - const history = listPaymentHistory(); - - const target = orderRef - ? history.find((r) => r.orderRef === orderRef) - : [...history].reverse().find((r) => !isTerminalStatus(r.status)); - - if (!target || isTerminalStatus(target.status)) { - return history; // nothing to refresh — zero requests - } - - try { - const intent = await getIntent(target.intentId); - updatePaymentStatus(target.intentId, intent.status); - } catch (err) { - console.warn('reconcilePaymentHistory: failed to refresh intent', target.intentId, err); - } - - return listPaymentHistory(); -} diff --git a/apps/portal/src/app/features/payment/components/PaymentModal.tsx b/apps/portal/src/app/features/payment/components/PaymentModal.tsx deleted file mode 100644 index e53c490cb..000000000 --- a/apps/portal/src/app/features/payment/components/PaymentModal.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { Alert, Anchor, Badge, Button, Group, Loader, Modal, Stack, Text } from '@mantine/core'; -import { IconAlertTriangle, IconCheck } from '@tabler/icons-react'; -import { ApiErrorAlert } from '@ema-platform/ui'; -import { usePaymentFlow } from '../hooks/usePaymentFlow'; -import { formatMinor } from '../utils/money'; -import { DEFAULT_PAYMENT_PROVIDER, PAYMENT_PHASE } from '../constants'; -import type { PaymentIntent, PaymentProvider } from '../types/payment'; - -export interface PaymentModalProps { - opened: boolean; - onClose: () => void; - title: string; - amountMinor: number; - currency: string; - referenceId: string; - orderRef: string; - service: string; - referenceType: string; - defaultProvider?: PaymentProvider; - lockedProvider?: PaymentProvider; - payerAccount?: string; - onSuccess?: (intent: PaymentIntent) => void; - onFailure?: (intent: PaymentIntent) => void; -} - -/** - * Reusable Telebirr payment modal. Renders from a single `phase` — see usePaymentFlow — so states - * like "loading && success" or "loading && error" are unrepresentable rather than merely avoided. - * Talks only to api/payments (via the hook); never touches a URL, token, header, or fetch directly. - */ -export function PaymentModal({ - opened, - onClose, - title, - amountMinor, - currency, - referenceId, - orderRef, - service, - referenceType, - defaultProvider, - lockedProvider, - payerAccount, - onSuccess, - onFailure, -}: PaymentModalProps) { - const provider = lockedProvider ?? defaultProvider ?? DEFAULT_PAYMENT_PROVIDER; - - const { phase, intent, error, isSlow, redirectUrl, pay, retry, openPayment, refresh } = usePaymentFlow({ - opened, - title, - amountMinor, - currency, - referenceId, - orderRef, - service, - referenceType, - provider, - payerAccount, - onSuccess, - onFailure, - }); - - const unhandledActionType = - intent?.clientAction && intent.clientAction.type !== 'REDIRECT' ? intent.clientAction.type : null; - - return ( - - - - Amount - {formatMinor(amountMinor, currency)} - - - Provider - {provider} - - - {phase === PAYMENT_PHASE.Idle && ( - - )} - - {phase === PAYMENT_PHASE.Creating && ( - - - Creating your payment… - - )} - - {phase === PAYMENT_PHASE.Opening && ( - - - Opening Telebirr… - - )} - - {phase === PAYMENT_PHASE.Blocked && redirectUrl && ( - } title="Popup blocked"> - - Your browser blocked the payment window. Open it manually to continue. - - - - )} - - {phase === PAYMENT_PHASE.Waiting && ( - - - - - Waiting for confirmation from Telebirr… {intent ? `(${intent.status})` : null} - - - {unhandledActionType && ( - - The payment service returned an unhandled action ("{unhandledActionType}") — still - checking for a result. - - )} - {redirectUrl && ( - window.open(redirectUrl, '_blank', 'noopener')}> - Reopen Telebirr - - )} - {isSlow && ( - - - This is taking longer than usual. It may still complete. - - - - - - - )} - - )} - - {phase === PAYMENT_PHASE.Success && ( - } title="Payment confirmed"> - {intent ? `Reference: ${intent.id}` : null} - - )} - - {phase === PAYMENT_PHASE.Failed && ( - - - - - )} - - - ); -} diff --git a/apps/portal/src/app/features/payment/constants.ts b/apps/portal/src/app/features/payment/constants.ts deleted file mode 100644 index 999f186f3..000000000 --- a/apps/portal/src/app/features/payment/constants.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Single source of truth for every literal used across the payment feature. -// Nothing outside this file should contain a payment-related magic string or number — -// grep for any of the values below and this file should be the only hit. - -export const DEFAULT_PAYMENT_PROVIDER = 'TELEBIRR' as const; - -export const PAYMENT_HISTORY_KEY = 'ema-payment-history'; // matches the repo's ema-portal-* key convention -export const PAYMENT_INTENT_QUERY_KEY = 'payment-intent'; // react-query cache namespace for GET /payments/intents/:id - -export const POLL_INTERVAL_MS = 4_000; // contract: poll every 4s -export const REQUEST_TIMEOUT_MS = 15_000; // per-request abort, so one hung call can't stall the UI forever -export const SLOW_PAYMENT_MS = 5 * 60_000; // after this we show a "taking longer" note — polling itself never stops for it - -export const PAYMENTS_ROUTE = '/payments'; - -// Phase names live with their type so there is one definition, not a const list plus a -// hand-written union that can drift apart. -export const PAYMENT_PHASE = { - Idle: 'idle', - Creating: 'creating', - Opening: 'opening', - Blocked: 'blocked', - Waiting: 'waiting', - Success: 'success', - Failed: 'failed', -} as const; -export type PaymentPhase = (typeof PAYMENT_PHASE)[keyof typeof PAYMENT_PHASE]; - -// The only place status strings are enumerated. isTerminalStatus/isSuccessStatus (api/payments.ts) -// read these sets; nothing else in the feature compares a status string directly. -// PAY_SUCCESS / PAY_FAILED are the literal `trade_status` values Telebirr puts on the return URL. -export const TERMINAL_STATUSES = new Set([ - 'SUCCESS', - 'PAY_SUCCESS', - 'FAILED', - 'PAY_FAILED', - 'CANCELLED', - 'CANCELED', - 'EXPIRED', -]); -export const SUCCESS_STATUSES = new Set(['SUCCESS', 'PAY_SUCCESS', 'COMPLETED']); - -/** - * Both the Telebirr `returnUrl` and `failureUrl` point at the same landing page — it reads - * `trade_status` off the query string and branches, so one route serves both outcomes. - * Deriving both from PAYMENTS_ROUTE means the route only needs to change in one place. - */ -export function paymentReturnUrls(): { returnUrl: string; failureUrl: string } { - const url = `${window.location.origin}${PAYMENTS_ROUTE}`; - return { returnUrl: url, failureUrl: url }; -} diff --git a/apps/portal/src/app/features/payment/hooks/usePaymentFlow.ts b/apps/portal/src/app/features/payment/hooks/usePaymentFlow.ts deleted file mode 100644 index 39ec69e25..000000000 --- a/apps/portal/src/app/features/payment/hooks/usePaymentFlow.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { - getIntent, - initiatePayment, - isSuccessStatus, - isTerminalStatus, - recordPayment, - updatePaymentStatus, -} from '../api/payments'; -import { - PAYMENT_INTENT_QUERY_KEY, - PAYMENT_PHASE, - POLL_INTERVAL_MS, - SLOW_PAYMENT_MS, - paymentReturnUrls, - type PaymentPhase, -} from '../constants'; -import type { PaymentIntent, PaymentProvider } from '../types/payment'; - -export interface UsePaymentFlowOptions { - opened: boolean; - title: string; - amountMinor: number; - currency: string; - referenceId: string; - orderRef: string; - service: string; - referenceType: string; - provider: PaymentProvider; - payerAccount?: string; - onSuccess?: (intent: PaymentIntent) => void; - onFailure?: (intent: PaymentIntent) => void; -} - -export interface UsePaymentFlowResult { - phase: PaymentPhase; - intent: PaymentIntent | null; - error: string | null; - /** True once the modal has been waiting past SLOW_PAYMENT_MS — the poll keeps running regardless. */ - isSlow: boolean; - /** clientAction.url, kept around so a blocked popup can be reopened from a user click. */ - redirectUrl: string | null; - /** Starts a fresh attempt. Only meaningful from idle/failed/blocked — a no-op otherwise. */ - pay: () => void; - /** Re-opens the Telebirr tab from an explicit user gesture (browsers require this — never automatic). */ - openPayment: () => void; - /** Same as pay(), exposed under its own name for the "Try again" button's intent. */ - retry: () => void; - /** One manual GET /payments/intents/:id, for the "Check again" button. */ - refresh: () => void; -} - -/** - * Owns the full lifecycle: initiate -> record history -> redirect -> poll -> fire onSuccess/onFailure - * exactly once -> retry. `phase` is the single value the modal renders from. - */ -export function usePaymentFlow(options: UsePaymentFlowOptions): UsePaymentFlowResult { - const { - opened, - title, - amountMinor, - currency, - referenceId, - orderRef, - service, - referenceType, - provider, - payerAccount, - } = options; - - const [phase, setPhase] = useState(PAYMENT_PHASE.Idle); - const [intentId, setIntentId] = useState(null); - const [error, setError] = useState(null); - const [redirectUrl, setRedirectUrl] = useState(null); - const [startedAt, setStartedAt] = useState(null); - - // Callbacks kept in a ref, refreshed every render, so the fire-once effect below never closes - // over a stale onSuccess/onFailure prop. - const callbacksRef = useRef({ onSuccess: options.onSuccess, onFailure: options.onFailure }); - callbacksRef.current = { onSuccess: options.onSuccess, onFailure: options.onFailure }; - - // Guards against firing onSuccess/onFailure more than once for the same intent. - const firedForRef = useRef(null); - - // Reopening the modal starts a fresh attempt rather than resuming a previous one — resuming would - // risk a second initiate. The documented recovery path for an in-flight payment is /payments, - // which reconciles the persisted history record instead. - useEffect(() => { - if (!opened) { - setPhase(PAYMENT_PHASE.Idle); - setIntentId(null); - setError(null); - setRedirectUrl(null); - setStartedAt(null); - firedForRef.current = null; - } - }, [opened]); - - // Poll — react-query, never setInterval. A hand-rolled interval would additionally need an - // overlap guard, a stale-closure guard, and a cancel-on-unmount guard; react-query gives all - // three for free, which is the whole reason it's used here over a manual timer. - const { - data: polledIntent, - refetch, - } = useQuery({ - queryKey: [PAYMENT_INTENT_QUERY_KEY, intentId], - queryFn: () => getIntent(intentId as string), - enabled: opened && !!intentId, - staleTime: 0, // overrides AppProviders' global 5-minute staleTime — this data must never be "fresh enough to skip" - retry: 0, // the next 4s tick IS the retry; a query-level retry would double requests - refetchOnWindowFocus: false, // returning from the Telebirr tab must not trigger an extra request - // Re-evaluated against the latest fetched data on every settle, so polling stops on the exact - // tick the terminal status arrives — no request fires after it. - refetchInterval: (query) => (isTerminalStatus(query.state.data?.status) ? false : POLL_INTERVAL_MS), - // window.open(..., '_blank') hands focus to the Telebirr tab, so our tab goes background. - // react-query's default pauses background polling, which would stall confirmation detection - // for the entire duration the user is on Telebirr's page — the opposite of what's needed here. - refetchIntervalInBackground: true, - }); - - // Fire onSuccess/onFailure exactly once per intent, the moment a terminal status is observed. - // An unknown status is neither terminal nor success (isTerminalStatus/isSuccessStatus both - // default to false for it) — the poll simply keeps running and the raw value is shown as-is. - useEffect(() => { - if (!polledIntent || !isTerminalStatus(polledIntent.status)) return; - if (firedForRef.current === polledIntent.id) return; - firedForRef.current = polledIntent.id; - - updatePaymentStatus(polledIntent.id, polledIntent.status); - - if (isSuccessStatus(polledIntent.status)) { - setPhase(PAYMENT_PHASE.Success); - callbacksRef.current.onSuccess?.(polledIntent); - } else { - setPhase(PAYMENT_PHASE.Failed); - callbacksRef.current.onFailure?.(polledIntent); - } - }, [polledIntent]); - - const start = useCallback(() => { - // Defensive guard against a double-click producing two POST /payments/initiate calls — the - // modal also disables the Pay button while creating, but the hook doesn't rely on that alone. - if (phase === PAYMENT_PHASE.Creating || phase === PAYMENT_PHASE.Opening || phase === PAYMENT_PHASE.Waiting) { - return; - } - - setError(null); - setPhase(PAYMENT_PHASE.Creating); - - const { returnUrl, failureUrl } = paymentReturnUrls(); - // A fresh idempotency key per attempt — required so a user retry after a failure creates a new - // payment attempt rather than resubmitting the failed one. Held only for the duration of this - // call; nothing above `start()` sees or reuses it. - const idempotencyKey = crypto.randomUUID(); - - initiatePayment({ - service, - referenceType, - referenceId, - orderRef, - amountMinor, - currency, - provider, - payerAccount, - returnUrl, - failureUrl, - idempotencyKey, - platform: 'web', - }) - .then((intent) => { - recordPayment({ - intentId: intent.id, - title, - orderRef, - referenceId, - provider, - amountMinor, - currency, - status: intent.status, - createdAt: new Date().toISOString(), - }); - - firedForRef.current = null; - setStartedAt(Date.now()); - setPhase(PAYMENT_PHASE.Opening); - - const clientAction = intent.clientAction; - if (clientAction?.type === 'REDIRECT' && clientAction.url) { - const win = window.open(clientAction.url, '_blank', 'noopener'); - if (win) { - setPhase(PAYMENT_PHASE.Waiting); - } else { - // Popup blocked: keep the intent alive and keep polling (it's gated on intentId, not - // phase) so a payment completed another way is still picked up. Only a user click on - // "Open Payment" retries window.open — browsers require a user gesture for that anyway. - setRedirectUrl(clientAction.url); - setPhase(PAYMENT_PHASE.Blocked); - } - } else { - // Undocumented/absent clientAction type: poll anyway, since the service may complete - // server-side. Never invents an OTP screen, never hangs silently. - setPhase(PAYMENT_PHASE.Waiting); - } - - setIntentId(intent.id); - }) - .catch((err: unknown) => { - setError(err instanceof Error ? err.message : 'Could not start the payment.'); - setPhase(PAYMENT_PHASE.Failed); - }); - }, [ - amountMinor, - currency, - orderRef, - payerAccount, - phase, - provider, - referenceId, - referenceType, - service, - title, - ]); - - const openPayment = useCallback(() => { - if (!redirectUrl) return; - const win = window.open(redirectUrl, '_blank', 'noopener'); - if (win) setPhase(PAYMENT_PHASE.Waiting); - }, [redirectUrl]); - - const refresh = useCallback(() => { - void refetch(); - }, [refetch]); - - const isSlow = - phase === PAYMENT_PHASE.Waiting && startedAt !== null && Date.now() - startedAt > SLOW_PAYMENT_MS; - - return { - phase, - intent: polledIntent ?? null, - error, - isSlow, - redirectUrl, - pay: start, - retry: start, - openPayment, - refresh, - }; -} diff --git a/apps/portal/src/app/features/payment/pages/PaymentsPage.tsx b/apps/portal/src/app/features/payment/pages/PaymentsPage.tsx deleted file mode 100644 index 18e43e438..000000000 --- a/apps/portal/src/app/features/payment/pages/PaymentsPage.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { Alert, Badge, Container, Group, Paper, Stack, Table, Text, Title } from '@mantine/core'; -import { IconCheck, IconInfoCircle } from '@tabler/icons-react'; -import { isSuccessStatus, reconcilePaymentHistory } from '../api/payments'; -import { formatMinor } from '../utils/money'; -import type { PaymentHistoryRecord, PaymentStatus } from '../types/payment'; - -// Status -> colour has exactly one consumer (this table), so it stays inline rather than becoming -// a shared PaymentStatusBadge component. -function statusColor(status: PaymentStatus): string { - if (isSuccessStatus(status)) return 'teal'; - const upper = status.toUpperCase(); - if (upper === 'FAILED' || upper === 'PAY_FAILED' || upper === 'CANCELLED' || upper === 'CANCELED') return 'red'; - if (upper === 'EXPIRED') return 'gray'; - return 'blue'; -} - -/** - * Landing page for Telebirr's returnUrl/failureUrl. Reads the five documented query params, - * reconciles the one relevant history record against the backend, then renders the outcome — - * trade_status === "PAY_SUCCESS" takes precedence, otherwise the backend's own status is shown. - */ -export function PaymentsPage() { - const [searchParams] = useSearchParams(); - const [history, setHistory] = useState([]); - const [reconciled, setReconciled] = useState(false); - - const tradeStatus = searchParams.get('trade_status'); - const totalAmount = searchParams.get('total_amount'); - const transCurrency = searchParams.get('trans_currency'); - const merchOrderId = searchParams.get('merch_order_id'); - const paymentOrderId = searchParams.get('payment_order_id'); - - useEffect(() => { - // Only ever reconcile once on load, against the orderRef captured from the initial URL — - // this page is not a live dashboard, so `merchOrderId` is deliberately not a dependency here. - // (The react-hooks/exhaustive-deps rule isn't configured in this repo's eslint setup, so no - // disable directive is needed — this comment documents the omission instead.) - reconcilePaymentHistory(merchOrderId ?? undefined) - .then(setHistory) - .finally(() => setReconciled(true)); - }, []); - - const record = history.find((r) => r.orderRef === merchOrderId); - const isSuccess = tradeStatus === 'PAY_SUCCESS'; - - return ( - - - Payment Status - - {reconciled && ( - - {isSuccess ? ( - } title="Payment successful"> - - {totalAmount && ( - - Amount: {totalAmount} {transCurrency ?? ''} - - )} - {paymentOrderId && Order: {paymentOrderId}} - - - ) : ( - } title="Payment status"> - - - Latest status: - - {record?.status ?? tradeStatus ?? 'UNKNOWN'} - - - {totalAmount && ( - - Amount: {totalAmount} {transCurrency ?? ''} - - )} - - - )} - - )} - - - Payment History - {history.length === 0 ? ( - No payments recorded on this device yet. - ) : ( - - - - Title - Order Ref - Amount - Status - Date - - - - {[...history].reverse().map((r) => ( - - {r.title} - {r.orderRef} - {formatMinor(r.amountMinor, r.currency)} - - {r.status} - - {new Date(r.createdAt).toLocaleString()} - - ))} - -
- )} -
-
-
- ); -} diff --git a/apps/portal/src/app/features/payment/types/payment.ts b/apps/portal/src/app/features/payment/types/payment.ts deleted file mode 100644 index 9895732c2..000000000 --- a/apps/portal/src/app/features/payment/types/payment.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Shared payment types. Every request/response shape the feature touches is declared here — -// no `any`, no inline object literals typed ad hoc at the call site. - -/** - * One provider today (Telebirr only). Kept as its own union — rather than a bare string — - * so that adding a second provider later is a one-line widen here; every function and prop - * that carries `provider` already threads it through, so no signatures need to change. - */ -export type PaymentProvider = 'TELEBIRR'; - -/** - * OPEN union, deliberately. A closed union would compile-time-guarantee something the backend - * does not guarantee: the first new status the payment service ships would either fail to compile - * everywhere or force `as PaymentStatus` casts at every boundary, silently defeating the point of - * isTerminalStatus()/isSuccessStatus(). `(string & {})` keeps autocomplete on the known set while - * still accepting anything the server actually sends, so an unknown status stays representable - * instead of being cast away or crashing. - */ -export type KnownPaymentStatus = - | 'PENDING' - | 'INITIATED' - | 'PROCESSING' - | 'SUCCESS' - | 'FAILED' - | 'CANCELLED' - | 'EXPIRED'; -export type PaymentStatus = KnownPaymentStatus | (string & {}); - -export interface PaymentClientAction { - /** Only "REDIRECT" is handled today; anything else is displayed, never silently dropped. */ - type: 'REDIRECT' | (string & {}); - url?: string; -} - -/** Returned by POST /payments/initiate and GET /payments/intents/:intentId. */ -export interface PaymentIntent { - id: string; - status: PaymentStatus; - provider: PaymentProvider; - amountMinor: number; - currency: string; - orderRef?: string; - referenceId?: string; - clientAction?: PaymentClientAction; -} - -/** Body for POST /payments/initiate. */ -export interface PaymentRequest { - /** No service enum exists in this repo yet — see plan's open question on the exact values a caller should pass. */ - service: string; - referenceType: string; - referenceId: string; - orderRef: string; - amountMinor: number; - currency: string; - provider: PaymentProvider; - /** Optional in the contract — Telebirr's own H5 page collects the payer's phone number. */ - payerAccount?: string; - returnUrl: string; - failureUrl: string; - idempotencyKey: string; - platform: 'web'; -} - -/** Body for POST /payments/intents/:intentId/confirm. */ -export interface PaymentConfirmRequest { - otp: string; -} - -/** One row of the localStorage payment history. */ -export interface PaymentHistoryRecord { - intentId: string; - title: string; - orderRef: string; - referenceId: string; - provider: PaymentProvider; - amountMinor: number; - currency: string; - status: PaymentStatus; - createdAt: string; -} diff --git a/apps/portal/src/app/features/payment/utils/money.ts b/apps/portal/src/app/features/payment/utils/money.ts deleted file mode 100644 index 97bbf338c..000000000 --- a/apps/portal/src/app/features/payment/utils/money.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** Whole-birr major amount -> minor units (cents). Rounds to avoid float drift (19.99 -> 1999). */ -export function toMinor(major: number): number { - return Math.round(major * 100); -} - -export function fromMinor(minor: number): number { - return minor / 100; -} - -/** - * Matches the format already used across the portal (`ETB ${n.toFixed(2)}`, e.g. - * SeamanBookApplicationPage.tsx) rather than Intl.NumberFormat({style:'currency'}), whose ETB - * output varies by ICU build. Not unifying the ~20 existing inline call sites — out of scope here. - */ -export function formatMinor(amountMinor: number, currency: string): string { - return `${currency} ${fromMinor(amountMinor).toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`; -} diff --git a/libs/ui/src/lib/data/AdvancedTable.tsx b/libs/ui/src/lib/data/AdvancedTable.tsx index 004f55451..4fcae4570 100644 --- a/libs/ui/src/lib/data/AdvancedTable.tsx +++ b/libs/ui/src/lib/data/AdvancedTable.tsx @@ -10,7 +10,6 @@ import { Loader, Center, Paper, - Badge, } from "@mantine/core"; import { IconRefresh, IconAdjustmentsHorizontal , IconInbox, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; @@ -24,6 +23,8 @@ export interface AdvancedColumn { align?: "left" | "center" | "right"; /** Whether column starts visible. Default true. */ enabled?: boolean; + /** Label for the View menu; falls back to `header` when it is a plain string. */ + label?: string; } interface AdvancedTableProps { @@ -90,23 +91,23 @@ export function AdvancedTable({ {""}
+ {refresh && ( + + )} - {refresh && ( - - )} diff --git a/libs/ui/src/lib/data/useServerTable.ts b/libs/ui/src/lib/data/useServerTable.ts index 910842056..2ea428bbb 100644 --- a/libs/ui/src/lib/data/useServerTable.ts +++ b/libs/ui/src/lib/data/useServerTable.ts @@ -17,6 +17,22 @@ export function useServerTable({ pageSize = 10 }: UseServerTableOptions = {}) { setPageIndex(0); }, []); + // Slice an already-fetched array for AdvancedTable when the endpoint has no + // skip/take of its own. Clamps pageIndex so deleting the last row of the + // last page doesn't strand the table on an empty slice. + const paginate = useCallback( + (rows: T[]) => { + const lastPage = Math.max(0, Math.ceil(rows.length / pageSize) - 1); + const clamped = Math.min(pageIndex, lastPage); + return { + rows: rows.slice(clamped * pageSize, clamped * pageSize + pageSize), + pageIndex: clamped, + itemCount: rows.length, + }; + }, + [pageIndex, pageSize], + ); + return { pageIndex, setPageIndex, @@ -25,5 +41,6 @@ export function useServerTable({ pageSize = 10 }: UseServerTableOptions = {}) { pageSize, skip: pageIndex * pageSize, take: pageSize, + paginate, }; }