Files
CBS-Mobile-App/src/modules/cards/components/CardItem.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

302 lines
7.8 KiB
TypeScript

import React, { memo } from 'react';
import {
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { Icon } from '../../../components/Icon';
import LinearGradient from 'react-native-linear-gradient';
import Animated, {
Extrapolation,
interpolate,
SharedValue,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import type { BankCard } from '../../../types/app';
import { getCardStatusLabel } from '../utils/cardCopy';
import { fontFamily } from '../../../theme';
interface CardItemProps {
card: BankCard;
index: number;
scrollX: SharedValue<number>;
cardWidth: number;
itemSize: number;
height?: number;
}
const CardItemComponent = ({
card,
index,
scrollX,
cardWidth,
itemSize,
height = 220,
}: CardItemProps) => {
const flipProgress = useSharedValue(0);
const statusLabel = getCardStatusLabel(card);
const statusColor =
card.status === 'blocked'
? '#FFB3BC'
: card.controls.frozen
? '#FFE08C'
: '#C8FACC';
const toggleFlip = () => {
flipProgress.value = withTiming(flipProgress.value === 0 ? 1 : 0, { duration: 420 });
};
const carouselStyle = useAnimatedStyle(() => {
const inputRange = [(index - 1) * itemSize, index * itemSize, (index + 1) * itemSize];
return {
transform: [
{
scale: interpolate(scrollX.value, inputRange, [0.92, 1, 0.92], Extrapolation.CLAMP),
},
{
translateY: interpolate(scrollX.value, inputRange, [12, 0, 12], Extrapolation.CLAMP),
},
],
opacity: interpolate(scrollX.value, inputRange, [0.72, 1, 0.72], Extrapolation.CLAMP),
};
});
const frontStyle = useAnimatedStyle(() => ({
transform: [
{ perspective: 1200 },
{ rotateY: `${interpolate(flipProgress.value, [0, 1], [0, 180])}deg` },
],
}));
const backStyle = useAnimatedStyle(() => ({
transform: [
{ perspective: 1200 },
{ rotateY: `${interpolate(flipProgress.value, [0, 1], [180, 360])}deg` },
],
}));
return (
<Animated.View style={[styles.frame, carouselStyle, { width: cardWidth, height }]}>
<Pressable style={styles.flex} onPress={toggleFlip}>
<Animated.View style={[styles.face, frontStyle]}>
<LinearGradient colors={card.themeColors} style={styles.gradient}>
<View style={styles.headerRow}>
<View>
<Text style={styles.brandName}>CBS Bank</Text>
<Text style={styles.cardMeta}>{card.type.toUpperCase()} CARD</Text>
</View>
<View style={styles.statusBadge}>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
<Text style={styles.statusBadgeText}>{statusLabel}</Text>
</View>
</View>
<View style={styles.centerBlock}>
<Text style={styles.cardName}>{card.name}</Text>
<Text style={styles.cardNumber}>{card.maskedNumber}</Text>
<Text style={styles.holderName}>{card.cardholderName}</Text>
</View>
<View style={styles.footerRow}>
<View>
<Text style={styles.footerLabel}>VALID THRU</Text>
<Text style={styles.footerValue}>{card.expiry}</Text>
</View>
<View>
<Text style={styles.footerLabel}>NETWORK</Text>
<Text style={styles.footerValue}>{card.network}</Text>
</View>
<View style={styles.flipHint}>
<Icon name="sync-outline" size={14} color="rgba(255,255,255,0.85)" />
<Text style={styles.flipText}>Flip</Text>
</View>
</View>
</LinearGradient>
</Animated.View>
<Animated.View style={[styles.face, styles.backFace, backStyle]}>
<LinearGradient
colors={[card.themeColors[2], card.themeColors[1], card.themeColors[0]]}
style={styles.gradient}
>
<View style={styles.magneticStrip} />
<View style={styles.backSection}>
<Text style={styles.backLabel}>PRIMARY LINKED ACCOUNT</Text>
<Text style={styles.backValue}>{card.primaryLinkedAccountId}</Text>
</View>
<View style={styles.backGrid}>
<View style={styles.backCell}>
<Text style={styles.backLabel}>DAILY LIMIT</Text>
<Text style={styles.backValue}>Rs {card.limits.daily.toLocaleString('en-IN')}</Text>
</View>
<View style={styles.backCell}>
<Text style={styles.backLabel}>LINKED ACCOUNTS</Text>
<Text style={styles.backValue}>{card.linkedAccountIds.length}</Text>
</View>
</View>
<View style={styles.backFooter}>
<Text style={styles.backSupport}>24x7 card care · 1800 100 2244</Text>
<Text style={styles.backSupport}>Tap card again to return</Text>
</View>
</LinearGradient>
</Animated.View>
</Pressable>
</Animated.View>
);
};
const styles = StyleSheet.create({
flex: {
flex: 1,
},
frame: {
overflow: 'visible',
},
face: {
...StyleSheet.absoluteFillObject,
backfaceVisibility: 'hidden',
},
backFace: {
zIndex: 2,
},
gradient: {
flex: 1,
borderRadius: 28,
padding: 22,
justifyContent: 'space-between',
overflow: 'hidden',
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12,
},
brandName: {
color: 'white',
fontSize: 18,
fontFamily: fontFamily.bold,
letterSpacing: -0.3,
},
cardMeta: {
color: 'rgba(255,255,255,0.72)',
fontSize: 10,
fontFamily: fontFamily.bold,
letterSpacing: 1.1,
marginTop: 2,
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: 'rgba(255,255,255,0.14)',
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
statusBadgeText: {
color: 'white',
fontSize: 11,
fontFamily: fontFamily.bold,
},
centerBlock: {
gap: 10,
},
cardName: {
color: 'rgba(255,255,255,0.82)',
fontSize: 12,
fontFamily: fontFamily.bold,
letterSpacing: 0.1,
},
cardNumber: {
color: 'white',
fontSize: 28,
fontFamily: fontFamily.bold,
letterSpacing: 2,
},
holderName: {
color: 'rgba(255,255,255,0.92)',
fontSize: 14,
fontFamily: fontFamily.bold,
letterSpacing: 0.6,
},
footerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
gap: 12,
},
footerLabel: {
color: 'rgba(255,255,255,0.68)',
fontSize: 9,
fontFamily: fontFamily.bold,
letterSpacing: 1,
},
footerValue: {
color: 'white',
fontSize: 13,
fontFamily: fontFamily.bold,
marginTop: 4,
},
flipHint: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
flipText: {
color: 'rgba(255,255,255,0.9)',
fontSize: 12,
fontFamily: fontFamily.bold,
},
magneticStrip: {
height: 42,
marginHorizontal: -22,
backgroundColor: 'rgba(7, 19, 33, 0.8)',
},
backSection: {
gap: 6,
},
backGrid: {
flexDirection: 'row',
gap: 12,
},
backCell: {
flex: 1,
gap: 6,
padding: 14,
borderRadius: 16,
backgroundColor: 'rgba(255,255,255,0.12)',
},
backLabel: {
color: 'rgba(255,255,255,0.68)',
fontSize: 10,
fontFamily: fontFamily.bold,
letterSpacing: 0.9,
},
backValue: {
color: 'white',
fontSize: 14,
fontFamily: fontFamily.bold,
},
backFooter: {
gap: 6,
},
backSupport: {
color: 'rgba(255,255,255,0.82)',
fontSize: 12,
fontFamily: fontFamily.semibold,
},
});
export const CardItem = memo(CardItemComponent);