Files
CBS-Mobile-App/src/modules/history/screens/TransferDetailScreen.tsx
T
Abin ToppoandClaude Opus 5.5 f960a119e8 Add Positive Pay, statement guide v1.0, onboarding, and switch every feature on
- Positive Pay on the 12 Oct /pps contract (account number, not account ID):
  OTP enrollment, cheque submit, saved cheques, detail and stop payment.
  Full flow passed on the phone against mobile-banking-backend.lincpay.
- Statement & Mini Statement guide v1.0 (14 Oct): mini statement JSON on
  Home and the Statement screen; dated PDF export with the password rule.
- Onboarding slides and splash screen, Inter font, shared Icon and
  TypewriterText components, nearby branches via OpenStreetMap.
- Every feature switched on; the 14 Oct switch set is kept in
  featureConfig.stage-2026-10-14.ts. Loans run on demo data until the
  backend has /loans routes.
- npm run android wraps run-android for shells spawned from VS Code.
- OpenProject work packages updated to 14-10-2026 status, with screenshots
  of Positive Pay, statement, loans and cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:18:40 +05:30

237 lines
8.0 KiB
TypeScript

import React, { useEffect } from 'react';
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import type { CompositeScreenProps } from '@react-navigation/native';
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { Icon } from '../../../components/Icon';
import { Button, Card, EmptyState, InfoBanner, Loader, ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { formatCurrency, formatDate, formatTime } from '../../../utils/formatters';
import type { HomeStackParamList, MainTabParamList } from '../../../navigation/types';
import type { ApiError } from '../../../types/api';
import { describeError } from '../../../services/api/cbsErrors';
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
import { useGetFundTransferDetailQuery } from '../../payments/services/paymentsApi';
import { MODE_LABEL, humaniseReason, statusCopy, toneColors } from '../utils/transferStatus';
type Props = CompositeScreenProps<
NativeStackScreenProps<HomeStackParamList, 'TransferDetail'>,
BottomTabScreenProps<MainTabParamList>
>;
const when = (iso: string | null) => (iso ? `${formatDate(iso)}, ${formatTime(iso)}` : null);
export default function TransferDetailScreen({ navigation, route }: Props) {
const theme = useTheme();
const showError = useApiErrorToast();
const { transferId } = route.params;
// Always fetched fresh on open: reopening the screen is one of the two ways
// the guide says a pending transfer should re-check its state.
const { data, isLoading, isFetching, error, refetch } = useGetFundTransferDetailQuery(transferId, {
refetchOnMountOrArgChange: true,
});
const apiError = error as ApiError | undefined;
// A transfer that is not on this profile: close the detail and say why.
useEffect(() => {
if (apiError?.code === 'TRANSACTION_NOT_FOUND') {
showError(apiError);
navigation.goBack();
}
}, [apiError, navigation, showError]);
if (isLoading) {
return (
<ScreenShell title="Transfer details">
<Loader />
</ScreenShell>
);
}
if (!data) {
const copy = describeError(apiError?.code);
return (
<ScreenShell title="Transfer details">
<EmptyState
title={copy.title}
description={apiError?.message || copy.description}
actionLabel="Try again"
onAction={refetch}
/>
</ScreenShell>
);
}
const status = statusCopy(data.status);
const colors = toneColors(status.tone, theme.colors);
const showReason =
Boolean(data.failureReason) && (data.status === 'FAILED' || data.status === 'MANUAL_REVIEW');
const rows: Array<[string, string | null | undefined]> = [
['Paid to', data.payeeName],
[data.mode === 'P2P' ? 'Mobile' : 'Account', data.payeeDisplay],
['IFSC', data.beneficiaryIfsc],
['MMID', data.beneficiaryMmid],
['Type', `IMPS · ${MODE_LABEL[data.mode] ?? data.mode}`],
['Remark', data.remark],
['Initiated', when(data.createdAt)],
['Last updated', when(data.updatedAt)],
];
return (
<ScreenShell title="Transfer details" scrollable={false}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.content}
refreshControl={
<RefreshControl
refreshing={isFetching}
onRefresh={refetch}
tintColor={theme.colors.primary}
colors={[theme.colors.primary]}
/>
}
>
{/* ── the outcome ───────────────────────────────────────────────── */}
<Card style={styles.hero}>
<View style={[styles.statusIcon, { backgroundColor: colors.surface }]}>
<Icon name={status.icon} size={30} color={colors.tone} />
</View>
<Text style={[theme.text.balance, { color: theme.colors.textPrimary }]}>
{formatCurrency(data.amount)}
</Text>
<View style={[styles.badge, { backgroundColor: colors.surface, borderColor: colors.border }]}>
{/* `simple` break strategy: Android's default `highQuality` measures
a custom font as fitting on one line, then wraps it on draw, so
the last word ("pending") was clipped outside the pill. */}
<Text
textBreakStrategy="simple"
style={[theme.text.bodyStrong, styles.badgeText, { color: colors.tone }]}
>
{status.label}
</Text>
</View>
{showReason ? (
<Text style={[theme.text.body, styles.centre, { color: theme.colors.danger }]}>
{humaniseReason(data.failureReason as string)}
</Text>
) : null}
</Card>
{data.status === 'PENDING_RECONCILIATION' || data.status === 'PROCESSING' ? (
<InfoBanner
tone="warning"
message="Your bank is still confirming this transfer. Don't send it again — refresh in a little while to see the final status."
/>
) : null}
{data.status === 'PENDING_OTP' ? (
<InfoBanner
tone="info"
message="This transfer was never confirmed with an OTP, so no money has left your account."
/>
) : null}
{data.status === 'MANUAL_REVIEW' ? (
<InfoBanner
tone="warning"
message="Your bank is reviewing this transfer. Contact support and quote the reference below if you need help."
/>
) : null}
{/* ── the reference the customer quotes ─────────────────────────── */}
<Card style={styles.section}>
<Text style={[theme.text.eyebrow, { color: theme.colors.textMuted }]}>Reference</Text>
<Text selectable style={[theme.text.code, { color: theme.colors.textPrimary }]}>
{data.referenceId}
</Text>
<Text style={[theme.text.caption, { color: theme.colors.textMuted }]}>
Press and hold to copy
</Text>
</Card>
<Card style={styles.section}>
{rows
.filter(([, value]) => Boolean(value))
.map(([label, value]) => (
<View key={label} style={styles.detailRow}>
<Text style={[theme.text.body, { color: theme.colors.textSecondary }]}>{label}</Text>
<Text
style={[theme.text.bodyStrong, styles.detailValue, { color: theme.colors.textPrimary }]}
>
{value}
</Text>
</View>
))}
</Card>
{status.refreshable ? (
<Button
title={isFetching ? 'Checking…' : 'Refresh status'}
variant="secondary"
onPress={refetch}
disabled={isFetching}
/>
) : null}
{data.status === 'MANUAL_REVIEW' || data.status === 'FAILED' ? (
<Button
title="Get help"
variant="ghost"
onPress={() => navigation.navigate('Profile', { screen: 'HelpSupport' })}
/>
) : null}
</ScrollView>
</ScreenShell>
);
}
const styles = StyleSheet.create({
content: {
gap: 14,
paddingBottom: 24,
},
hero: {
alignItems: 'center',
gap: 10,
paddingVertical: 24,
},
statusIcon: {
width: 60,
height: 60,
borderRadius: 30,
alignItems: 'center',
justifyContent: 'center',
},
badgeText: {
textAlign: 'center',
},
badge: {
maxWidth: '100%',
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 999,
borderWidth: 1,
},
centre: {
textAlign: 'center',
},
section: {
gap: 6,
padding: 16,
},
detailRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 16,
paddingVertical: 6,
},
detailValue: {
flex: 1,
textAlign: 'right',
},
});