- 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>
190 lines
5.0 KiB
TypeScript
190 lines
5.0 KiB
TypeScript
import React, { memo, useMemo } from 'react';
|
|
import {
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from 'react-native';
|
|
import { Icon } from '../Icon';
|
|
|
|
import type { TransactionRecord } from '../../types/app';
|
|
import { useTheme, textStyles } from '../../theme';
|
|
import { formatCurrency, formatDate } from '../../utils/formatters';
|
|
|
|
interface TransactionItemProps {
|
|
item: TransactionRecord;
|
|
}
|
|
|
|
type RiskLevel = 'safe' | 'medium' | 'high';
|
|
|
|
const TransactionItemComponent = ({ item }: TransactionItemProps) => {
|
|
const theme = useTheme();
|
|
const isCredit = item.direction === 'credit';
|
|
|
|
const riskLevel = useMemo<RiskLevel>(() => {
|
|
const sum = item.id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
|
if (sum % 10 < 7) return 'safe';
|
|
if (sum % 10 < 9) return 'medium';
|
|
return 'high';
|
|
}, [item.id]);
|
|
|
|
const riskConfig = {
|
|
safe: {
|
|
color: theme.colors.success,
|
|
surface: theme.colors.successSurface,
|
|
border: theme.colors.successBorder,
|
|
label: 'SECURE',
|
|
icon: 'shield-checkmark' as const,
|
|
},
|
|
medium: {
|
|
color: theme.colors.warning,
|
|
surface: theme.colors.warningSurface,
|
|
border: theme.colors.warningBorder,
|
|
label: 'FLAGGED',
|
|
icon: 'warning' as const,
|
|
},
|
|
high: {
|
|
color: theme.colors.danger,
|
|
surface: theme.colors.dangerSurface,
|
|
border: theme.colors.dangerBorder,
|
|
label: 'High risk',
|
|
icon: 'alert-circle' as const,
|
|
},
|
|
}[riskLevel];
|
|
|
|
return (
|
|
<View style={[styles.wrapper, { borderBottomColor: theme.colors.cardBorder }]}>
|
|
<View style={styles.leading}>
|
|
<View
|
|
style={[
|
|
styles.icon,
|
|
{
|
|
backgroundColor: isCredit
|
|
? theme.colors.successSurface
|
|
: theme.colors.surfaceMuted,
|
|
borderColor: isCredit ? theme.colors.successBorder : theme.colors.cardBorder,
|
|
},
|
|
]}
|
|
>
|
|
<Icon
|
|
name={isCredit ? 'chevron-down' : 'chevron-up'}
|
|
size={18}
|
|
color={isCredit ? theme.colors.success : theme.colors.textSecondary}
|
|
/>
|
|
</View>
|
|
<View style={styles.copy}>
|
|
<View style={styles.titleRow}>
|
|
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{item.title}</Text>
|
|
</View>
|
|
<View style={styles.subtitleRow}>
|
|
<Text style={[styles.subtitle, { color: theme.colors.textMuted }]}>
|
|
{item.counterparty}
|
|
</Text>
|
|
<View style={[styles.dot, { backgroundColor: theme.colors.textMuted }]} />
|
|
<Text style={[styles.subtitle, { color: theme.colors.textMuted }]}>
|
|
{formatDate(item.occurredAt)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.trailing}>
|
|
<Text
|
|
style={[
|
|
styles.amount,
|
|
{ color: isCredit ? theme.colors.success : theme.colors.textPrimary },
|
|
]}
|
|
>
|
|
{isCredit ? '+' : '-'}
|
|
{formatCurrency(item.amount)}
|
|
</Text>
|
|
<View
|
|
style={[
|
|
styles.riskBadge,
|
|
{ backgroundColor: riskConfig.surface, borderColor: riskConfig.border },
|
|
]}
|
|
>
|
|
<Text style={[styles.riskText, { color: riskConfig.color }]}>{riskConfig.label}</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
wrapper: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingVertical: 18,
|
|
borderBottomWidth: 1,
|
|
},
|
|
leading: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 16,
|
|
flex: 1,
|
|
},
|
|
icon: {
|
|
width: 46,
|
|
height: 46,
|
|
borderRadius: 23,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderWidth: 1,
|
|
},
|
|
copy: {
|
|
gap: 4,
|
|
flex: 1,
|
|
},
|
|
titleRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
title: {
|
|
...textStyles.h3,
|
|
},
|
|
subtitleRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
},
|
|
dot: {
|
|
width: 3,
|
|
height: 3,
|
|
borderRadius: 1.5,
|
|
backgroundColor: 'transparent', // set from the theme at the call site
|
|
},
|
|
subtitle: {
|
|
...textStyles.caption,
|
|
},
|
|
trailing: {
|
|
alignItems: 'flex-end',
|
|
gap: 4,
|
|
},
|
|
amount: {
|
|
...textStyles.money,
|
|
},
|
|
riskBadge: {
|
|
paddingHorizontal: 6,
|
|
paddingVertical: 2,
|
|
borderRadius: 6,
|
|
borderWidth: 1,
|
|
},
|
|
// Takes its family and casing from `eyebrow`, then overrides the size.
|
|
//
|
|
// 8pt is below the scale's floor of 11 and the scale has no entry for it on
|
|
// purpose — at this size the label is decoration, not something a customer
|
|
// can be expected to read. Left at 8 rather than snapped up to 11 because
|
|
// the badge is laid out around it; worth revisiting as a design question,
|
|
// since a risk flag nobody can read is not doing its job.
|
|
riskText: {
|
|
...textStyles.eyebrow,
|
|
fontSize: 8,
|
|
lineHeight: 11,
|
|
letterSpacing: 0.5,
|
|
},
|
|
});
|
|
|
|
|
|
export const TransactionItem = memo(TransactionItemComponent);
|