- 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>
237 lines
8.0 KiB
TypeScript
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',
|
|
},
|
|
});
|