Add TPIN creation, TPIN-authorised transfers, MPIN reset and transfer history
- 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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
abb14d5768
commit
f1def81eb7
@@ -0,0 +1,137 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user