- Create TPIN: current MPIN -> OTP -> encrypted TPIN via /tpin/create/*, from Services and from the transfer screen on TPIN_NOT_SET - IMPS initiate now sends encryptedTpin instead of encryptedMpin; handles INVALID_TPIN and TPIN_NOT_SET - Share one RSA-OAEP encryption and key-retry path for MPIN and TPIN - Forgot MPIN on sign-in and Reset MPIN in the Security Center - Fund transfer history list and detail, reachable from Home - Brand logo on sign-in and palette updates Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
138 lines
4.1 KiB
TypeScript
138 lines
4.1 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import { useGetFundTransferHistoryQuery } from '../../payments/services/paymentsApi';
|
|
import type {
|
|
BeneficiaryMode,
|
|
FundTransferHistoryItem,
|
|
FundTransferStatus,
|
|
} from '../../../types/cbs';
|
|
|
|
export const HISTORY_PAGE_SIZE = 10;
|
|
|
|
export interface HistoryFilters {
|
|
status: FundTransferStatus | 'ALL';
|
|
mode: BeneficiaryMode | 'ALL';
|
|
/** Inclusive, `yyyy-MM-dd`, sent only as a pair. */
|
|
fromDate?: string;
|
|
toDate?: string;
|
|
}
|
|
|
|
/**
|
|
* The transfer history, accumulated page by page — the same shape as
|
|
* `useStatement`, for the same reason: RTK Query caches one entry per page, and
|
|
* the list needs every page walked so far.
|
|
*
|
|
* Any change of filter starts again from page 0, as the guide requires; page 2
|
|
* of the old filter has no meaning under the new one. The next page is asked
|
|
* for only while the server says `last` is false.
|
|
*/
|
|
export const useFundTransferHistory = (filters: HistoryFilters) => {
|
|
const queryKey = `${filters.status}|${filters.mode}|${filters.fromDate ?? ''}|${filters.toDate ?? ''}`;
|
|
const [page, setPage] = useState(0);
|
|
const [pages, setPages] = useState<Record<number, FundTransferHistoryItem[]>>({});
|
|
const loadedKey = useRef(queryKey);
|
|
|
|
const { data, isLoading, isFetching, error, refetch } = useGetFundTransferHistoryQuery(
|
|
{ ...filters, page, size: HISTORY_PAGE_SIZE },
|
|
{ refetchOnMountOrArgChange: true },
|
|
);
|
|
|
|
// Reset before the new filter's first response lands, so the list is never
|
|
// briefly the old filter's rows under the new filter's chips.
|
|
if (loadedKey.current !== queryKey) {
|
|
loadedKey.current = queryKey;
|
|
|
|
if (page !== 0) {
|
|
setPage(0);
|
|
}
|
|
|
|
if (Object.keys(pages).length > 0) {
|
|
setPages({});
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!data) {
|
|
return;
|
|
}
|
|
|
|
setPages((current) =>
|
|
current[data.number] === data.content ? current : { ...current, [data.number]: data.content },
|
|
);
|
|
}, [data]);
|
|
|
|
/**
|
|
* Flattened in page order with repeats dropped. The server caches a page for
|
|
* about 30 seconds, so a row can shift across a page boundary mid-browse;
|
|
* `transferId` is the stable key that stops it appearing twice.
|
|
*/
|
|
const transfers = useMemo(() => {
|
|
const seen = new Set<string>();
|
|
const ordered: FundTransferHistoryItem[] = [];
|
|
|
|
Object.keys(pages)
|
|
.map(Number)
|
|
.sort((left, right) => left - right)
|
|
.forEach((index) => {
|
|
(pages[index] ?? []).forEach((item) => {
|
|
if (!seen.has(item.transferId)) {
|
|
seen.add(item.transferId);
|
|
ordered.push(item);
|
|
}
|
|
});
|
|
});
|
|
|
|
return ordered;
|
|
}, [pages]);
|
|
|
|
const hasMore = data ? !data.last : false;
|
|
|
|
const loadMore = useCallback(() => {
|
|
if (!hasMore || isFetching) {
|
|
return;
|
|
}
|
|
|
|
setPage((current) => current + 1);
|
|
}, [hasMore, isFetching]);
|
|
|
|
/**
|
|
* Pull-to-refresh: start again from page 0.
|
|
*
|
|
* The fresh page is written straight from the refetch's own result, not left
|
|
* to the `[data]` effect above. The server caches a page for ~30 seconds, so a
|
|
* refresh usually returns exactly what it returned before — and RTK Query's
|
|
* structural sharing then hands back the SAME `data` object, the effect never
|
|
* fires, and a list cleared before the request stayed empty. The current rows
|
|
* also stay on screen until the fresh page lands, rather than blanking.
|
|
*/
|
|
const reload = useCallback(() => {
|
|
if (page !== 0) {
|
|
// A different cache entry, so `data` genuinely changes and the effect
|
|
// refills the list.
|
|
setPages({});
|
|
setPage(0);
|
|
|
|
return;
|
|
}
|
|
|
|
refetch()
|
|
.unwrap()
|
|
.then((fresh) => setPages({ [fresh.number]: fresh.content }))
|
|
.catch(() => {
|
|
// Surfaced through `error`; the rows already on screen are kept.
|
|
});
|
|
}, [page, refetch]);
|
|
|
|
return {
|
|
transfers,
|
|
totalElements: data?.totalElements ?? 0,
|
|
hasMore,
|
|
isLoading: isLoading || (isFetching && transfers.length === 0),
|
|
isLoadingMore: isFetching && page > 0,
|
|
isRefreshing: isFetching && page === 0 && transfers.length > 0,
|
|
error,
|
|
loadMore,
|
|
reload,
|
|
};
|
|
};
|