Fix form keyboard issues and sticky footers in agent screens

This commit is contained in:
Abin Toppo
2026-05-26 16:34:05 +05:30
commit 9ac5e95d52
410 changed files with 86650 additions and 0 deletions
@@ -0,0 +1,250 @@
import React from 'react';
import { StyleSheet, Text, View, Pressable, ScrollView } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { BottomSheet } from '../../../components';
import { Button } from '../../../components';
import { useTheme } from '../../../theme';
import type { BranchWithDistance } from '../hooks/useBranches';
import { formatDistance, openDirections, callBranch } from '../utils/geo';
interface BranchDetailSheetProps {
branch: BranchWithDistance | null;
visible: boolean;
onClose: () => void;
}
export const BranchDetailSheet = ({ branch, visible, onClose }: BranchDetailSheetProps) => {
const theme = useTheme();
if (!branch) {return null;}
return (
<BottomSheet visible={visible} title={branch.name} onClose={onClose}>
<ScrollView showsVerticalScrollIndicator={false} style={styles.scrollContent}>
{/* Main Branch Badge */}
{branch.isMainBranch && branch.locationType !== 'atm' && (
<View style={[styles.mainBadge, { backgroundColor: `${theme.colors.accent}12` }]}>
<Ionicons name="star" size={14} color={theme.colors.accent} />
<Text style={[styles.mainBadgeText, { color: theme.colors.accent }]}>
Main Branch
</Text>
</View>
)}
{/* ATM Badge */}
{branch.locationType === 'atm' && (
<View style={[styles.mainBadge, { backgroundColor: `${theme.colors.info}12` }]}>
<Ionicons name="card" size={14} color={theme.colors.info} />
<Text style={[styles.mainBadgeText, { color: theme.colors.info }]}>
Standalone ATM
</Text>
</View>
)}
{/* Address */}
<View style={styles.infoRow}>
<View style={[styles.infoIcon, { backgroundColor: `${theme.colors.primary}10` }]}>
<Ionicons name="location-outline" size={16} color={theme.colors.primary} />
</View>
<View style={styles.infoContent}>
<Text style={[styles.infoLabel, { color: theme.colors.textMuted }]}>ADDRESS</Text>
<Text style={[styles.infoValue, { color: theme.colors.textPrimary }]}>
{branch.address}
</Text>
<Text style={[styles.infoSubtext, { color: theme.colors.textSecondary }]}>
{branch.city}, {branch.state} - {branch.pincode}
</Text>
</View>
</View>
{/* Distance */}
{branch.distance !== null && (
<View style={styles.infoRow}>
<View style={[styles.infoIcon, { backgroundColor: `${theme.colors.info}10` }]}>
<Ionicons name="navigate-outline" size={16} color={theme.colors.info} />
</View>
<View style={styles.infoContent}>
<Text style={[styles.infoLabel, { color: theme.colors.textMuted }]}>DISTANCE</Text>
<Text style={[styles.infoValue, { color: theme.colors.textPrimary }]}>
{formatDistance(branch.distance)} away
</Text>
</View>
</View>
)}
{/* IFSC & Timings */}
<View style={styles.detailsRow}>
{branch.locationType !== 'atm' && (
<View style={[styles.detailCard, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textMuted }]}>IFSC CODE</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{branch.ifscCode}
</Text>
</View>
)}
<View style={[styles.detailCard, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textMuted }]}>TIMINGS</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{branch.timings}
</Text>
</View>
</View>
{/* Facilities */}
<View style={styles.facilitiesSection}>
<Text style={[styles.facilitiesTitle, { color: theme.colors.textSecondary }]}>
FACILITIES
</Text>
<View style={styles.facilitiesRow}>
{branch.facilities.map((facility) => (
<View
key={facility}
style={[styles.facilityChip, { backgroundColor: `${theme.colors.primary}10` }]}
>
<Ionicons
name={getFacilityIcon(facility)}
size={14}
color={theme.colors.primary}
/>
<Text style={[styles.facilityChipText, { color: theme.colors.primary }]}>
{facility}
</Text>
</View>
))}
</View>
</View>
{/* Action Buttons */}
<View style={styles.actions}>
<Button
title="Get Directions"
onPress={() => openDirections(branch.latitude, branch.longitude)}
leftAccessory={
<Ionicons name="navigate" size={18} color="#FFFFFF" />
}
/>
{branch.phone ? (
<Button
title="Call Branch"
variant="secondary"
onPress={() => callBranch(branch.phone)}
leftAccessory={
<Ionicons name="call-outline" size={18} color="#0F172A" />
}
/>
) : null}
</View>
</ScrollView>
</BottomSheet>
);
};
const getFacilityIcon = (facility: string): any => {
switch (facility) {
case 'ATM': return 'card-outline';
case 'Loan Desk': return 'cash-outline';
case 'Locker': return 'lock-closed-outline';
case 'Forex': return 'globe-outline';
case 'NRI Desk': return 'airplane-outline';
default: return 'ellipse-outline';
}
};
const styles = StyleSheet.create({
scrollContent: {
maxHeight: 420,
},
mainBadge: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-start',
gap: 5,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 8,
marginBottom: 12,
},
mainBadgeText: {
fontSize: 12,
fontWeight: '700',
},
infoRow: {
flexDirection: 'row',
gap: 10,
marginBottom: 14,
},
infoIcon: {
width: 34,
height: 34,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
infoContent: {
flex: 1,
gap: 2,
},
infoLabel: {
fontSize: 10,
fontWeight: '700',
letterSpacing: 0.8,
},
infoValue: {
fontSize: 14,
fontWeight: '600',
},
infoSubtext: {
fontSize: 12,
},
detailsRow: {
flexDirection: 'row',
gap: 10,
marginBottom: 14,
},
detailCard: {
flex: 1,
padding: 12,
borderRadius: 12,
gap: 4,
},
detailLabel: {
fontSize: 10,
fontWeight: '700',
letterSpacing: 0.8,
},
detailValue: {
fontSize: 13,
fontWeight: '700',
},
facilitiesSection: {
gap: 8,
marginBottom: 16,
},
facilitiesTitle: {
fontSize: 10,
fontWeight: '700',
letterSpacing: 0.8,
},
facilitiesRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
},
facilityChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 8,
},
facilityChipText: {
fontSize: 12,
fontWeight: '600',
},
actions: {
gap: 10,
paddingBottom: 8,
},
});
@@ -0,0 +1,208 @@
import React, { memo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import type { BranchWithDistance } from '../hooks/useBranches';
import { formatDistance } from '../utils/geo';
interface BranchListItemProps {
branch: BranchWithDistance;
isSelected: boolean;
onPress: () => void;
}
const BranchListItemComponent = ({ branch, isSelected, onPress }: BranchListItemProps) => {
const theme = useTheme();
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.container,
{
backgroundColor: isSelected
? `${theme.colors.primary}08`
: theme.colors.surface,
borderColor: isSelected
? `${theme.colors.primary}40`
: theme.colors.cardBorder,
borderRadius: theme.radius.lg,
opacity: pressed ? 0.95 : 1,
transform: [{ scale: pressed ? 0.99 : 1 }],
},
theme.shadows.soft,
]}
>
{/* Icon */}
<View
style={[
styles.iconFrame,
{
backgroundColor: isSelected
? `${theme.colors.primary}18`
: `${theme.colors.primary}10`,
},
]}
>
<Ionicons
name={branch.locationType === 'atm' ? 'card' : branch.isMainBranch ? 'business' : 'location'}
size={20}
color={branch.locationType === 'atm' ? theme.colors.info : theme.colors.primary}
/>
</View>
{/* Content */}
<View style={styles.content}>
<View style={styles.nameRow}>
<Text
style={[styles.name, { color: theme.colors.textPrimary }]}
numberOfLines={1}
>
{branch.name}
</Text>
{branch.isMainBranch && branch.locationType !== 'atm' && (
<View style={[styles.mainBadge, { backgroundColor: `${theme.colors.accent}18` }]}>
<Text style={[styles.mainBadgeText, { color: theme.colors.accent }]}>HQ</Text>
</View>
)}
{branch.locationType === 'atm' && (
<View style={[styles.mainBadge, { backgroundColor: `${theme.colors.info}18` }]}>
<Text style={[styles.mainBadgeText, { color: theme.colors.info }]}>ATM</Text>
</View>
)}
</View>
<Text
style={[styles.address, { color: theme.colors.textSecondary }]}
numberOfLines={1}
>
{branch.address}
</Text>
<View style={styles.metaRow}>
{branch.distance !== null && (
<View style={styles.metaItem}>
<Ionicons name="navigate-outline" size={12} color={theme.colors.textMuted} />
<Text style={[styles.metaText, { color: theme.colors.textMuted }]}>
{formatDistance(branch.distance)}
</Text>
</View>
)}
<View style={styles.metaItem}>
<Ionicons name="time-outline" size={12} color={theme.colors.textMuted} />
<Text style={[styles.metaText, { color: theme.colors.textMuted }]}>
{branch.timings}
</Text>
</View>
</View>
{/* Facility tags */}
<View style={styles.facilityRow}>
{branch.facilities.slice(0, 3).map((facility) => (
<View
key={facility}
style={[
styles.facilityTag,
{ backgroundColor: `${theme.colors.info}10` },
]}
>
<Text style={[styles.facilityTagText, { color: theme.colors.info }]}>
{facility}
</Text>
</View>
))}
{branch.facilities.length > 3 && (
<Text style={[styles.moreText, { color: theme.colors.textMuted }]}>
+{branch.facilities.length - 3}
</Text>
)}
</View>
</View>
{/* Chevron */}
<Ionicons name="chevron-forward" size={18} color={theme.colors.textMuted} />
</Pressable>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
padding: 14,
borderWidth: 1,
gap: 12,
marginBottom: 10,
},
iconFrame: {
width: 42,
height: 42,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
content: {
flex: 1,
gap: 3,
},
nameRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
name: {
fontSize: 14,
fontWeight: '700',
flexShrink: 1,
},
mainBadge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 6,
},
mainBadgeText: {
fontSize: 9,
fontWeight: '800',
letterSpacing: 0.5,
},
address: {
fontSize: 12,
lineHeight: 16,
},
metaRow: {
flexDirection: 'row',
gap: 12,
marginTop: 2,
},
metaItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 3,
},
metaText: {
fontSize: 11,
fontWeight: '500',
},
facilityRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: 4,
},
facilityTag: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
facilityTagText: {
fontSize: 10,
fontWeight: '600',
},
moreText: {
fontSize: 10,
fontWeight: '600',
},
});
export const BranchListItem = memo(BranchListItemComponent);
@@ -0,0 +1,570 @@
import React, { useEffect } from 'react';
import { StyleSheet, View } from 'react-native';
import Animated, {
Easing,
FadeIn,
interpolate,
type SharedValue,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useTheme } from '../../../theme';
/* ───────────────────── tiny sparkle dot ───────────────────── */
function SparkleParticle({
delay,
left,
top,
color,
}: {
delay: number;
left: number;
top: number;
color: string;
}) {
const progress = useSharedValue(0);
useEffect(() => {
progress.value = withDelay(
delay,
withRepeat(
withSequence(
withTiming(1, { duration: 1400, easing: Easing.out(Easing.ease) }),
withTiming(0, { duration: 1400, easing: Easing.in(Easing.ease) }),
),
-1,
true,
),
);
}, [delay, progress]);
const style = useAnimatedStyle(() => ({
opacity: interpolate(progress.value, [0, 0.5, 1], [0, 1, 0]),
transform: [
{ scale: interpolate(progress.value, [0, 0.5, 1], [0.3, 1, 0.3]) },
{ translateY: interpolate(progress.value, [0, 1], [0, -12]) },
],
}));
return (
<Animated.View
style={[
{
position: 'absolute',
left,
top,
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: color,
},
style,
]}
/>
);
}
/* ───────────────────── main component ───────────────────── */
export function ChequeAnimation() {
const theme = useTheme();
// ─── shared values ───────────────────────────────────
const chequeSlide = useSharedValue(0);
const chequeScale = useSharedValue(0.85);
const line1 = useSharedValue(0);
const line2 = useSharedValue(0);
const line3 = useSharedValue(0);
const stampScale = useSharedValue(0);
const stampRotate = useSharedValue(-45);
const glowPulse = useSharedValue(0);
const penX = useSharedValue(0);
const checkmarkDraw = useSharedValue(0);
useEffect(() => {
// cheque body slides up + scales in
chequeSlide.value = withDelay(200, withSpring(1, { damping: 14, stiffness: 90 }));
chequeScale.value = withDelay(200, withSpring(1, { damping: 16, stiffness: 100 }));
// lines "write" in sequentially
line1.value = withDelay(600, withTiming(1, { duration: 700, easing: Easing.out(Easing.cubic) }));
line2.value = withDelay(900, withTiming(1, { duration: 700, easing: Easing.out(Easing.cubic) }));
line3.value = withDelay(1200, withTiming(1, { duration: 600, easing: Easing.out(Easing.cubic) }));
// pen moves along the writing line
penX.value = withDelay(600, withTiming(1, { duration: 1800, easing: Easing.out(Easing.cubic) }));
// stamp bounces in
stampScale.value = withDelay(1800, withSpring(1, { damping: 8, stiffness: 120 }));
stampRotate.value = withDelay(1800, withSpring(0, { damping: 12, stiffness: 90 }));
// checkmark draws in
checkmarkDraw.value = withDelay(
2200,
withTiming(1, { duration: 500, easing: Easing.out(Easing.cubic) }),
);
// background glow loops
glowPulse.value = withDelay(
1000,
withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.ease) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.ease) }),
),
-1,
true,
),
);
}, [chequeSlide, chequeScale, line1, line2, line3, stampScale, stampRotate, glowPulse, penX, checkmarkDraw]);
// ─── animated styles ─────────────────────────────────
const chequeBodyStyle = useAnimatedStyle(() => ({
opacity: chequeSlide.value,
transform: [
{ translateY: interpolate(chequeSlide.value, [0, 1], [40, 0]) },
{ scale: chequeScale.value },
],
}));
const lineStyle = (val: SharedValue<number>) =>
useAnimatedStyle(() => ({
transform: [{ scaleX: val.value }],
opacity: val.value,
}));
const stampStyle = useAnimatedStyle(() => ({
opacity: stampScale.value,
transform: [
{ scale: stampScale.value },
{ rotate: `${stampRotate.value}deg` },
],
}));
const glowStyle = useAnimatedStyle(() => ({
opacity: interpolate(glowPulse.value, [0, 1], [0.15, 0.4]),
transform: [{ scale: interpolate(glowPulse.value, [0, 1], [0.92, 1.08]) }],
}));
const penStyle = useAnimatedStyle(() => ({
opacity: interpolate(penX.value, [0, 0.05, 0.9, 1], [0, 1, 1, 0]),
transform: [{ translateX: interpolate(penX.value, [0, 1], [0, 140]) }],
}));
const checkmarkStyle = useAnimatedStyle(() => ({
opacity: checkmarkDraw.value,
transform: [
{ scale: interpolate(checkmarkDraw.value, [0, 0.5, 1], [0.3, 1.15, 1]) },
],
}));
const lineStyle1 = lineStyle(line1);
const lineStyle2 = lineStyle(line2);
const lineStyle3 = lineStyle(line3);
const primaryColor = theme.colors.primary;
const accentColor = theme.colors.accent;
return (
<View style={styles.wrapper}>
{/* Background glow */}
<Animated.View
style={[
styles.glow,
{ backgroundColor: `${primaryColor}20`, borderColor: `${primaryColor}10` },
glowStyle,
]}
/>
{/* Sparkle particles */}
<SparkleParticle delay={800} left={30} top={15} color={`${primaryColor}60`} />
<SparkleParticle delay={1200} left={250} top={10} color={`${accentColor}70`} />
<SparkleParticle delay={1600} left={270} top={110} color={`${primaryColor}50`} />
<SparkleParticle delay={2000} left={20} top={120} color={`${accentColor}50`} />
<SparkleParticle delay={1400} left={150} top={5} color={`${primaryColor}40`} />
<SparkleParticle delay={2400} left={45} top={70} color={`${accentColor}60`} />
{/* Cheque body */}
<Animated.View style={[styles.chequeBody, chequeBodyStyle]}>
{/* Cheque card */}
<View
style={[
styles.chequeCard,
{
backgroundColor: theme.colors.surface,
borderColor: theme.colors.cardBorder,
shadowColor: primaryColor,
},
]}
>
{/* Top bar (cheque header) */}
<View style={styles.chequeHeader}>
<View style={styles.bankLogoWrap}>
<View style={[styles.bankLogoDot, { backgroundColor: primaryColor }]} />
<View style={[styles.bankLogoDot, { backgroundColor: accentColor }]} />
<View style={[styles.bankLogoDot, { backgroundColor: `${primaryColor}60` }]} />
</View>
<View>
<Animated.View
entering={FadeIn.delay(500)}
style={[styles.headerLabel, { backgroundColor: `${primaryColor}12` }]}
>
<Animated.Text style={[styles.headerLabelText, { color: primaryColor }]}>
CBS BANK
</Animated.Text>
</Animated.View>
</View>
<Animated.View
entering={FadeIn.delay(700)}
style={[styles.chequeNumber, { backgroundColor: `${primaryColor}08` }]}
>
<Animated.Text style={[styles.chequeNumberText, { color: theme.colors.textMuted }]}>
№ 000142
</Animated.Text>
</Animated.View>
</View>
{/* Date line */}
<Animated.View entering={FadeIn.delay(800)} style={styles.dateLine}>
<Animated.Text style={[styles.dateLabel, { color: theme.colors.textMuted }]}>
Date:
</Animated.Text>
<View style={[styles.dateUnderline, { borderColor: `${primaryColor}25` }]}>
<Animated.Text style={[styles.dateValue, { color: theme.colors.textPrimary }]}>
14 / 04 / 2026
</Animated.Text>
</View>
</Animated.View>
{/* Pay to line */}
<View style={styles.payToRow}>
<Animated.Text
entering={FadeIn.delay(600)}
style={[styles.payToLabel, { color: theme.colors.textMuted }]}
>
Pay
</Animated.Text>
<View style={styles.payToLineWrap}>
<Animated.View
style={[
styles.writingLine,
{ backgroundColor: `${primaryColor}18` },
lineStyle1,
]}
/>
{/* pen icon */}
<Animated.View style={[styles.penIcon, penStyle]}>
<View style={[styles.penTip, { backgroundColor: primaryColor }]} />
<View style={[styles.penBody, { backgroundColor: `${primaryColor}80` }]} />
</Animated.View>
</View>
</View>
{/* Amount line */}
<View style={styles.amountRow}>
<View style={[styles.rupeeBadge, { backgroundColor: `${accentColor}15` }]}>
<Animated.Text
entering={FadeIn.delay(1000)}
style={[styles.rupeeSign, { color: accentColor }]}
>
₹
</Animated.Text>
</View>
<Animated.View
style={[
styles.writingLine,
{ backgroundColor: `${primaryColor}18`, flex: 1 },
lineStyle2,
]}
/>
</View>
{/* Bottom section: MICR + signature */}
<View style={styles.bottomRow}>
{/* MICR code */}
<Animated.View entering={FadeIn.delay(1400)} style={styles.micrWrap}>
<Animated.Text style={[styles.micrText, { color: theme.colors.textMuted }]}>
⑈ 000142 ⑈ 12345678 ⑆
</Animated.Text>
</Animated.View>
{/* Signature line */}
<View style={styles.signatureWrap}>
<Animated.View
style={[
styles.signatureLine,
{ backgroundColor: `${primaryColor}18` },
lineStyle3,
]}
/>
<Animated.Text
entering={FadeIn.delay(1400)}
style={[styles.signatureLabel, { color: theme.colors.textMuted }]}
>
Signature
</Animated.Text>
</View>
</View>
{/* Stamp / Seal overlay */}
<Animated.View style={[styles.stamp, stampStyle]}>
<View
style={[
styles.stampOuter,
{ borderColor: accentColor },
]}
>
<View
style={[
styles.stampInner,
{ borderColor: accentColor, backgroundColor: `${accentColor}08` },
]}
>
<Animated.Text style={[styles.stampText, { color: accentColor }]}>
VERIFIED
</Animated.Text>
</View>
</View>
</Animated.View>
{/* Checkmark badge */}
<Animated.View
style={[
styles.checkmarkBadge,
{ backgroundColor: accentColor },
checkmarkStyle,
]}
>
<Animated.Text style={styles.checkmarkText}>✓</Animated.Text>
</Animated.View>
</View>
</Animated.View>
</View>
);
}
/* ───────────────────── styles ───────────────────── */
const styles = StyleSheet.create({
wrapper: {
alignItems: 'center',
justifyContent: 'center',
height: 210,
marginBottom: 8,
overflow: 'visible',
},
glow: {
position: 'absolute',
width: 260,
height: 160,
borderRadius: 80,
borderWidth: 1,
},
chequeBody: {
alignItems: 'center',
justifyContent: 'center',
},
chequeCard: {
width: 290,
borderRadius: 14,
borderWidth: 1.2,
padding: 14,
gap: 10,
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.08,
shadowRadius: 20,
elevation: 6,
},
chequeHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 2,
},
bankLogoWrap: {
flexDirection: 'row',
gap: 3,
},
bankLogoDot: {
width: 8,
height: 8,
borderRadius: 4,
},
headerLabel: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 6,
},
headerLabelText: {
fontSize: 10,
fontWeight: '900',
letterSpacing: 1.5,
},
chequeNumber: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
chequeNumberText: {
fontSize: 8,
fontWeight: '700',
letterSpacing: 0.5,
},
dateLine: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
alignSelf: 'flex-end',
},
dateLabel: {
fontSize: 8,
fontWeight: '600',
},
dateUnderline: {
borderBottomWidth: 1,
borderStyle: 'dashed',
paddingBottom: 1,
paddingHorizontal: 4,
},
dateValue: {
fontSize: 9,
fontWeight: '700',
letterSpacing: 0.8,
},
payToRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
payToLabel: {
fontSize: 9,
fontWeight: '700',
},
payToLineWrap: {
flex: 1,
height: 14,
justifyContent: 'flex-end',
overflow: 'hidden',
},
writingLine: {
height: 2,
borderRadius: 1,
transformOrigin: 'left',
},
penIcon: {
position: 'absolute',
bottom: -2,
left: 0,
flexDirection: 'row',
alignItems: 'flex-end',
},
penTip: {
width: 3,
height: 8,
borderRadius: 1,
transform: [{ rotate: '25deg' }],
},
penBody: {
width: 2,
height: 12,
borderRadius: 1,
marginLeft: -1,
transform: [{ rotate: '25deg' }],
},
amountRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
rupeeBadge: {
width: 22,
height: 22,
borderRadius: 11,
alignItems: 'center',
justifyContent: 'center',
},
rupeeSign: {
fontSize: 13,
fontWeight: '900',
},
bottomRow: {
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
marginTop: 4,
},
micrWrap: {
opacity: 0.7,
},
micrText: {
fontSize: 7,
fontWeight: '600',
letterSpacing: 1.2,
fontFamily: 'monospace',
},
signatureWrap: {
alignItems: 'center',
gap: 3,
width: 80,
},
signatureLine: {
width: '100%',
height: 1.5,
borderRadius: 1,
transformOrigin: 'left',
},
signatureLabel: {
fontSize: 7,
fontWeight: '600',
letterSpacing: 0.5,
},
stamp: {
position: 'absolute',
right: 30,
bottom: 28,
},
stampOuter: {
width: 56,
height: 56,
borderRadius: 28,
borderWidth: 2.5,
alignItems: 'center',
justifyContent: 'center',
},
stampInner: {
width: 44,
height: 44,
borderRadius: 22,
borderWidth: 1.5,
alignItems: 'center',
justifyContent: 'center',
},
stampText: {
fontSize: 7,
fontWeight: '900',
letterSpacing: 1.2,
},
checkmarkBadge: {
position: 'absolute',
top: -8,
right: -8,
width: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#10B981',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
checkmarkText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '900',
},
});
@@ -0,0 +1,90 @@
import React, { memo } from 'react';
import { ScrollView, Pressable, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import type { BranchFacility } from '../../../types/app';
const FACILITIES: { key: BranchFacility; icon: string }[] = [
{ key: 'ATM', icon: 'card-outline' },
{ key: 'Loan Desk', icon: 'cash-outline' },
{ key: 'Locker', icon: 'lock-closed-outline' },
{ key: 'Forex', icon: 'globe-outline' },
{ key: 'NRI Desk', icon: 'airplane-outline' },
];
interface FacilityFilterBarProps {
selected: BranchFacility[];
onToggle: (facility: BranchFacility) => void;
}
const FacilityFilterBarComponent = ({ selected, onToggle }: FacilityFilterBarProps) => {
const theme = useTheme();
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.container}
>
{FACILITIES.map(({ key, icon }) => {
const isActive = selected.includes(key);
return (
<Pressable
key={key}
onPress={() => onToggle(key)}
style={[
styles.chip,
{
backgroundColor: isActive
? theme.colors.primary
: theme.colors.surface,
borderColor: isActive
? theme.colors.primary
: theme.colors.cardBorder,
borderRadius: theme.radius.pill,
},
]}
>
<Ionicons
name={icon as any}
size={14}
color={isActive ? theme.colors.white : theme.colors.textSecondary}
/>
<Text
style={[
styles.label,
{
color: isActive ? theme.colors.white : theme.colors.textPrimary,
},
]}
>
{key}
</Text>
</Pressable>
);
})}
</ScrollView>
);
};
const styles = StyleSheet.create({
container: {
paddingHorizontal: 4,
gap: 8,
},
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
paddingHorizontal: 12,
paddingVertical: 8,
borderWidth: 1,
},
label: {
fontSize: 12,
fontWeight: '600',
},
});
export const FacilityFilterBar = memo(FacilityFilterBarComponent);
@@ -0,0 +1,496 @@
import React, { useEffect } from 'react';
import { StyleSheet, View } from 'react-native';
import Animated, {
Easing,
FadeIn,
interpolate,
type SharedValue,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useTheme } from '../../../theme';
/* ─────────── floating dot particle ─────────── */
function FloatingDot({
delay,
left,
top,
color,
size = 5,
}: {
delay: number;
left: number;
top: number;
color: string;
size?: number;
}) {
const progress = useSharedValue(0);
useEffect(() => {
progress.value = withDelay(
delay,
withRepeat(
withSequence(
withTiming(1, { duration: 1800, easing: Easing.out(Easing.ease) }),
withTiming(0, { duration: 1800, easing: Easing.in(Easing.ease) }),
),
-1,
true,
),
);
}, [delay, progress]);
const style = useAnimatedStyle(() => ({
opacity: interpolate(progress.value, [0, 0.5, 1], [0, 0.8, 0]),
transform: [
{ scale: interpolate(progress.value, [0, 0.5, 1], [0.4, 1, 0.4]) },
{ translateY: interpolate(progress.value, [0, 1], [4, -10]) },
],
}));
return (
<Animated.View
style={[
{
position: 'absolute',
left,
top,
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: color,
},
style,
]}
/>
);
}
/* ─────────── progress bar segment ─────────── */
function ProgressSegment({
delay,
width,
color,
}: {
delay: number;
width: number;
color: string;
}) {
const fill = useSharedValue(0);
useEffect(() => {
fill.value = withDelay(
delay,
withTiming(1, { duration: 600, easing: Easing.out(Easing.cubic) }),
);
}, [delay, fill]);
const style = useAnimatedStyle(() => ({
width: fill.value * width,
opacity: interpolate(fill.value, [0, 0.3, 1], [0, 0.6, 1]),
}));
return (
<Animated.View
style={[
{
height: 4,
borderRadius: 2,
backgroundColor: color,
},
style,
]}
/>
);
}
/* ─────────── main animation ─────────── */
export function ServiceRequestAnimation() {
const theme = useTheme();
const clipboardSlide = useSharedValue(0);
const clipboardScale = useSharedValue(0.8);
const formLine1 = useSharedValue(0);
const formLine2 = useSharedValue(0);
const formLine3 = useSharedValue(0);
const formLine4 = useSharedValue(0);
const checkboxPop = useSharedValue(0);
const badgePop = useSharedValue(0);
const glowPulse = useSharedValue(0);
const pencilWrite = useSharedValue(0);
useEffect(() => {
// clipboard slides up
clipboardSlide.value = withDelay(150, withSpring(1, { damping: 14, stiffness: 85 }));
clipboardScale.value = withDelay(150, withSpring(1, { damping: 16, stiffness: 95 }));
// form lines write in sequentially
formLine1.value = withDelay(500, withTiming(1, { duration: 600, easing: Easing.out(Easing.cubic) }));
formLine2.value = withDelay(750, withTiming(1, { duration: 600, easing: Easing.out(Easing.cubic) }));
formLine3.value = withDelay(1000, withTiming(1, { duration: 600, easing: Easing.out(Easing.cubic) }));
formLine4.value = withDelay(1250, withTiming(1, { duration: 500, easing: Easing.out(Easing.cubic) }));
// pencil writing motion
pencilWrite.value = withDelay(
500,
withTiming(1, { duration: 1600, easing: Easing.out(Easing.cubic) }),
);
// checkbox ticks in
checkboxPop.value = withDelay(1600, withSpring(1, { damping: 8, stiffness: 130 }));
// submitted badge
badgePop.value = withDelay(2000, withSpring(1, { damping: 10, stiffness: 110 }));
// background glow
glowPulse.value = withDelay(
800,
withRepeat(
withSequence(
withTiming(1, { duration: 2200, easing: Easing.inOut(Easing.ease) }),
withTiming(0, { duration: 2200, easing: Easing.inOut(Easing.ease) }),
),
-1,
true,
),
);
}, [clipboardSlide, clipboardScale, formLine1, formLine2, formLine3, formLine4, checkboxPop, badgePop, glowPulse, pencilWrite]);
// ── animated styles ──
const clipboardStyle = useAnimatedStyle(() => ({
opacity: clipboardSlide.value,
transform: [
{ translateY: interpolate(clipboardSlide.value, [0, 1], [35, 0]) },
{ scale: clipboardScale.value },
],
}));
const makeLineStyle = (val: SharedValue<number>) =>
useAnimatedStyle(() => ({
transform: [{ scaleX: val.value }],
opacity: val.value,
}));
const glowStyle = useAnimatedStyle(() => ({
opacity: interpolate(glowPulse.value, [0, 1], [0.12, 0.35]),
transform: [{ scale: interpolate(glowPulse.value, [0, 1], [0.9, 1.1]) }],
}));
const pencilStyle = useAnimatedStyle(() => ({
opacity: interpolate(pencilWrite.value, [0, 0.05, 0.85, 1], [0, 1, 1, 0]),
transform: [
{ translateX: interpolate(pencilWrite.value, [0, 1], [0, 100]) },
{ translateY: interpolate(pencilWrite.value, [0, 0.25, 0.5, 0.75, 1], [0, 18, 36, 54, 70]) },
],
}));
const checkboxStyle = useAnimatedStyle(() => ({
opacity: checkboxPop.value,
transform: [
{ scale: interpolate(checkboxPop.value, [0, 0.5, 1], [0.2, 1.2, 1]) },
],
}));
const badgeStyle = useAnimatedStyle(() => ({
opacity: badgePop.value,
transform: [
{ scale: interpolate(badgePop.value, [0, 0.5, 1], [0.3, 1.15, 1]) },
],
}));
const ls1 = makeLineStyle(formLine1);
const ls2 = makeLineStyle(formLine2);
const ls3 = makeLineStyle(formLine3);
const ls4 = makeLineStyle(formLine4);
const primary = theme.colors.primary;
const accent = theme.colors.accent;
const info = theme.colors.info;
const purple = theme.colors.cyberPurple;
return (
<View style={styles.wrapper}>
{/* Background glow */}
<Animated.View
style={[
styles.glow,
{ backgroundColor: `${info}18`, borderColor: `${info}10` },
glowStyle,
]}
/>
{/* Floating particles */}
<FloatingDot delay={600} left={25} top={20} color={`${primary}50`} />
<FloatingDot delay={1000} left={260} top={15} color={`${accent}60`} />
<FloatingDot delay={1400} left={275} top={120} color={`${purple}50`} />
<FloatingDot delay={1800} left={15} top={130} color={`${info}50`} />
<FloatingDot delay={2200} left={140} top={8} color={`${accent}40`} size={4} />
{/* Clipboard body */}
<Animated.View style={[styles.clipboardWrap, clipboardStyle]}>
{/* Clip at top */}
<View
style={[
styles.clipTop,
{
backgroundColor: primary,
shadowColor: primary,
},
]}
>
<View style={[styles.clipHole, { backgroundColor: theme.colors.background }]} />
</View>
{/* Form card */}
<View
style={[
styles.formCard,
{
backgroundColor: theme.colors.surface,
borderColor: theme.colors.cardBorder,
shadowColor: primary,
},
]}
>
{/* Title placeholder */}
<Animated.View entering={FadeIn.delay(400)} style={styles.formTitleRow}>
<View style={[styles.formTitleDot, { backgroundColor: info }]} />
<View style={[styles.formTitleBar, { backgroundColor: `${primary}15` }]} />
</Animated.View>
{/* Form field rows */}
<View style={styles.fieldRow}>
<Animated.View
entering={FadeIn.delay(500)}
style={[styles.fieldLabel, { backgroundColor: `${primary}10` }]}
/>
<Animated.View
style={[styles.fieldLine, { backgroundColor: `${primary}14` }, ls1]}
/>
</View>
<View style={styles.fieldRow}>
<Animated.View
entering={FadeIn.delay(700)}
style={[styles.fieldLabel, { backgroundColor: `${accent}10` }]}
/>
<Animated.View
style={[styles.fieldLine, { backgroundColor: `${accent}14` }, ls2]}
/>
</View>
<View style={styles.fieldRow}>
<Animated.View
entering={FadeIn.delay(900)}
style={[styles.fieldLabel, { backgroundColor: `${purple}10` }]}
/>
<Animated.View
style={[styles.fieldLine, { backgroundColor: `${purple}14` }, ls3]}
/>
</View>
{/* Progress bar area */}
<Animated.View entering={FadeIn.delay(1100)} style={styles.progressSection}>
<View style={[styles.progressTrack, { backgroundColor: `${primary}08` }]}>
<ProgressSegment delay={1200} width={110} color={accent} />
</View>
</Animated.View>
{/* Short line + checkbox row */}
<View style={styles.checkboxRow}>
<Animated.View
style={[styles.shortLine, { backgroundColor: `${primary}14` }, ls4]}
/>
<Animated.View style={[styles.checkbox, { borderColor: accent }, checkboxStyle]}>
<Animated.Text style={[styles.checkIcon, { color: accent }]}>
✓
</Animated.Text>
</Animated.View>
</View>
{/* Pencil icon floating */}
<Animated.View style={[styles.pencilIcon, pencilStyle]}>
<View style={[styles.pencilBodyPart, { backgroundColor: `${info}90` }]} />
<View style={[styles.pencilTipPart, { backgroundColor: info }]} />
</Animated.View>
</View>
{/* Submitted badge */}
<Animated.View
style={[
styles.submittedBadge,
{ backgroundColor: accent, shadowColor: accent },
badgeStyle,
]}
>
<Animated.Text style={styles.submittedIcon}>📄</Animated.Text>
<Animated.Text style={styles.submittedText}>Submitted</Animated.Text>
</Animated.View>
</Animated.View>
</View>
);
}
/* ─────────── styles ─────────── */
const styles = StyleSheet.create({
wrapper: {
alignItems: 'center',
justifyContent: 'center',
height: 220,
marginBottom: 8,
overflow: 'visible',
},
glow: {
position: 'absolute',
width: 240,
height: 150,
borderRadius: 75,
borderWidth: 1,
},
clipboardWrap: {
alignItems: 'center',
},
clipTop: {
width: 60,
height: 16,
borderTopLeftRadius: 8,
borderTopRightRadius: 8,
alignItems: 'center',
justifyContent: 'center',
zIndex: 2,
marginBottom: -4,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 6,
elevation: 3,
},
clipHole: {
width: 22,
height: 8,
borderRadius: 4,
},
formCard: {
width: 250,
borderRadius: 14,
borderWidth: 1.2,
padding: 16,
gap: 9,
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.07,
shadowRadius: 18,
elevation: 5,
},
formTitleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 2,
},
formTitleDot: {
width: 8,
height: 8,
borderRadius: 4,
},
formTitleBar: {
flex: 1,
height: 8,
borderRadius: 4,
},
fieldRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
fieldLabel: {
width: 36,
height: 6,
borderRadius: 3,
},
fieldLine: {
flex: 1,
height: 3,
borderRadius: 1.5,
transformOrigin: 'left',
},
progressSection: {
marginTop: 2,
},
progressTrack: {
height: 6,
borderRadius: 3,
overflow: 'hidden',
},
checkboxRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
},
shortLine: {
width: 80,
height: 3,
borderRadius: 1.5,
transformOrigin: 'left',
},
checkbox: {
width: 18,
height: 18,
borderRadius: 5,
borderWidth: 2,
alignItems: 'center',
justifyContent: 'center',
},
checkIcon: {
fontSize: 11,
fontWeight: '900',
marginTop: -1,
},
pencilIcon: {
position: 'absolute',
top: 30,
left: 40,
flexDirection: 'column',
alignItems: 'center',
},
pencilBodyPart: {
width: 3,
height: 14,
borderRadius: 1,
transform: [{ rotate: '35deg' }],
},
pencilTipPart: {
width: 3,
height: 5,
borderBottomLeftRadius: 2,
borderBottomRightRadius: 2,
marginTop: -2,
transform: [{ rotate: '35deg' }],
},
submittedBadge: {
position: 'absolute',
bottom: -12,
right: -5,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 20,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 4,
},
submittedIcon: {
fontSize: 12,
},
submittedText: {
color: '#FFFFFF',
fontSize: 11,
fontWeight: '800',
letterSpacing: 0.5,
},
});
@@ -0,0 +1,45 @@
import React, { memo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import type { ServiceStatus } from '../../../types/app';
import { useTheme } from '../../../theme';
import { getServiceStatusTone } from '../utils/serviceStatus';
interface ServiceStatusPillProps {
status: ServiceStatus;
}
const ServiceStatusPillComponent = ({ status }: ServiceStatusPillProps) => {
const theme = useTheme();
return (
<View
style={[
styles.pill,
{
backgroundColor: `${getServiceStatusTone(status)}1F`,
borderColor: theme.colors.cardBorder,
borderRadius: theme.radius.pill,
},
]}
>
<Text style={[styles.text, { color: getServiceStatusTone(status) }]}>{status}</Text>
</View>
);
};
const styles = StyleSheet.create({
pill: {
alignSelf: 'flex-start',
borderWidth: 1,
paddingHorizontal: 10,
paddingVertical: 5,
},
text: {
fontSize: 11,
fontWeight: '700',
textTransform: 'capitalize',
},
});
export const ServiceStatusPill = memo(ServiceStatusPillComponent);
+109
View File
@@ -0,0 +1,109 @@
import { useMemo } from 'react';
import type { Branch, BranchFacility } from '../../../types/app';
import { useGetBranchesQuery } from '../services/branchApi';
import { haversineDistance } from '../utils/geo';
interface UserLocation {
latitude: number;
longitude: number;
}
export interface BranchWithDistance extends Branch {
distance: number | null; // distance in km, null if user location unavailable
}
interface UseBranchesResult {
branches: BranchWithDistance[];
isLoading: boolean;
isError: boolean;
refetch: () => void;
}
/**
* Hook that fetches branches, computes distances, applies search/filter,
* and returns a sorted list of branches.
*/
export const useBranches = (
userLocation: UserLocation | null,
searchText: string,
selectedFacilities: BranchFacility[],
filterType: 'all' | 'branch' | 'atm' = 'all',
selectedCity: string | null = null,
): UseBranchesResult => {
const { data: branches, isLoading, isError, refetch } = useGetBranchesQuery();
const processedBranches = useMemo(() => {
if (!branches) {return [];}
// 1. Compute distance from user
let result: BranchWithDistance[] = branches.map((branch) => ({
...branch,
distance: userLocation
? haversineDistance(
userLocation.latitude,
userLocation.longitude,
branch.latitude,
branch.longitude,
)
: null,
}));
// 2. Apply search filter (city, pincode, branch name, address)
if (searchText.trim()) {
const queryTokens = searchText.toLowerCase().trim().split(/\s+/);
result = result.filter((b) => {
const searchableText = [
b.name,
b.city,
b.pincode,
b.address,
b.ifscCode,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return queryTokens.every((token) => searchableText.includes(token));
});
}
// 3. Apply facility filter
if (selectedFacilities.length > 0) {
result = result.filter((b) =>
selectedFacilities.every((facility) => b.facilities.includes(facility)),
);
}
// 4. Apply location type filter
if (filterType !== 'all') {
result = result.filter((b) => {
const type = b.locationType || 'branch'; // Default to branch if missing
return type === filterType;
});
}
// 5. Apply city filter
if (selectedCity && selectedCity !== 'All Cities') {
result = result.filter((b) => b.city === selectedCity);
}
// 5. Sort by distance (nearest first), then alphabetically
result.sort((a, b) => {
if (a.distance !== null && b.distance !== null) {
return a.distance - b.distance;
}
if (a.distance !== null) {return -1;}
if (b.distance !== null) {return 1;}
return a.name.localeCompare(b.name);
});
return result;
}, [branches, userLocation, searchText, selectedFacilities, filterType, selectedCity]);
return {
branches: processedBranches,
isLoading,
isError,
refetch,
};
};
@@ -0,0 +1,27 @@
import { useCallback } from 'react';
import type { ServiceCategory } from '../../../types/app';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { selectServicesState } from '../store/serviceSelectors';
import { serviceActions } from '../store/serviceSlice';
export const useServiceDesk = () => {
const dispatch = useAppDispatch();
const serviceState = useAppSelector(selectServicesState);
return {
serviceState,
setDraftCategory: useCallback(
(category: ServiceCategory) => {
dispatch(serviceActions.setDraftCategory(category));
},
[dispatch],
),
setDraftNotes: useCallback(
(notes: string) => {
dispatch(serviceActions.setDraftNotes(notes));
},
[dispatch],
),
};
};
@@ -0,0 +1,89 @@
import { useState, useEffect, useCallback } from 'react';
import { Platform, PermissionsAndroid, Alert } from 'react-native';
import Geolocation from '@react-native-community/geolocation';
interface UserLocation {
latitude: number;
longitude: number;
}
interface UseUserLocationResult {
location: UserLocation | null;
loading: boolean;
error: string | null;
refresh: () => void;
}
/**
* Hook to get and manage the user's current GPS location.
* Handles permissions for both Android and iOS.
*/
export const useUserLocation = (): UseUserLocationResult => {
const [location, setLocation] = useState<UserLocation | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const requestPermission = useCallback(async (): Promise<boolean> => {
if (Platform.OS === 'ios') {
// iOS permission is handled via Info.plist; Geolocation auto-prompts
return true;
}
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
{
title: 'Location Permission',
message: 'BankCBS needs access to your location to find nearby branches.',
buttonPositive: 'Allow',
buttonNegative: 'Deny',
},
);
return granted === PermissionsAndroid.RESULTS.GRANTED;
} catch {
return false;
}
}, []);
const fetchLocation = useCallback(async () => {
setLoading(true);
setError(null);
const hasPermission = await requestPermission();
if (!hasPermission) {
setError('Location permission denied');
setLoading(false);
// Fallback to a default location (Bengaluru) for demo purposes
setLocation({ latitude: 12.9716, longitude: 77.5946 });
return;
}
Geolocation.getCurrentPosition(
(position) => {
setLocation({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
});
setLoading(false);
},
(err) => {
setError(err.message);
setLoading(false);
// Fallback to a default location for demo purposes
setLocation({ latitude: 12.9716, longitude: 77.5946 });
},
{
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 60000,
},
);
}, [requestPermission]);
useEffect(() => {
fetchLocation();
}, [fetchLocation]);
return { location, loading, error, refresh: fetchLocation };
};
@@ -0,0 +1,688 @@
import React, { useState, useCallback, useRef, useMemo } from 'react';
import {
StyleSheet,
View,
Text,
Pressable,
TextInput,
FlatList,
ScrollView,
Dimensions,
StatusBar,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import MapLibreGL from '@maplibre/maplibre-react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { Loader, EmptyState } from '../../../components';
import { useTheme } from '../../../theme';
import type { ServicesStackParamList } from '../../../navigation/types';
import type { BranchFacility } from '../../../types/app';
import { useUserLocation } from '../hooks/useUserLocation';
import { useBranches, type BranchWithDistance } from '../hooks/useBranches';
import { BranchListItem } from '../components/BranchListItem';
import { BranchDetailSheet } from '../components/BranchDetailSheet';
import { FacilityFilterBar } from '../components/FacilityFilterBar';
// Initialize MapLibre (required once)
MapLibreGL.setAccessToken(null);
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const OSM_STYLE = {
version: 8,
sources: {
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
maxzoom: 19,
},
},
layers: [
{
id: 'osm-tiles',
type: 'raster',
source: 'osm',
minzoom: 0,
maxzoom: 19,
},
],
};
type Props = NativeStackScreenProps<ServicesStackParamList, 'BranchLocator'>;
export default function BranchLocatorScreen({ navigation }: Props) {
const theme = useTheme();
const insets = useSafeAreaInsets();
// Extra bottom space to clear the tab bar (tab bar height ~60 + safe area)
const bottomOffset = Math.max(insets.bottom, 10) + 72;
// State
const [searchText, setSearchText] = useState('');
const [selectedFacilities, setSelectedFacilities] = useState<BranchFacility[]>([]);
const [selectedBranch, setSelectedBranch] = useState<BranchWithDistance | null>(null);
const [showSheet, setShowSheet] = useState(false);
const [viewMode, setViewMode] = useState<'map' | 'list'>('map');
const [filterType, setFilterType] = useState<'all' | 'branch' | 'atm'>('all');
const [selectedCity, setSelectedCity] = useState<string | null>(null);
// Hooks
const { location: userLocation, loading: locationLoading } = useUserLocation();
const { branches, isLoading, isError, refetch } = useBranches(
userLocation,
searchText,
selectedFacilities,
filterType,
selectedCity,
);
// Extract unique cities
const cities = useMemo(() => {
// We use a separate query or the hook should ideally return available cities
// For now, let's extract from the branches if available, but better to extract from raw data if possible.
// However, useBranches returns filtered ones. We need all.
// Let's just hardcode some common ones or extract if we had access to raw list.
return ['All Cities', 'Bengaluru', 'Mumbai', 'Delhi', 'Chennai', 'Hyderabad', 'Pune', 'Ahmedabad'];
}, []);
// Refs
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cameraRef = useRef<any>(null);
// Handlers
const handleFacilityToggle = useCallback((facility: BranchFacility) => {
setSelectedFacilities((prev) =>
prev.includes(facility)
? prev.filter((f) => f !== facility)
: [...prev, facility],
);
}, []);
const handleBranchPress = useCallback((branch: BranchWithDistance) => {
setSelectedBranch(branch);
setShowSheet(true);
if (viewMode === 'map' && cameraRef.current) {
cameraRef.current.setCamera({
centerCoordinate: [branch.longitude, branch.latitude],
zoomLevel: 14,
animationDuration: 600,
});
}
}, [viewMode]);
const handleCloseSheet = useCallback(() => {
setShowSheet(false);
}, []);
const handleMarkerPress = useCallback(
(branch: BranchWithDistance) => {
handleBranchPress(branch);
},
[handleBranchPress],
);
// Default map center (user location or India center)
const defaultCenter = useMemo(() => {
if (userLocation) {
return [userLocation.longitude, userLocation.latitude];
}
return [77.5946, 12.9716]; // Bengaluru fallback
}, [userLocation]);
// Loading state
if (isLoading || locationLoading) {
return (
<SafeAreaView edges={['top']} style={[styles.flex, { backgroundColor: theme.colors.background }]}>
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
<Header theme={theme} navigation={navigation} />
<Loader />
</SafeAreaView>
);
}
return (
<SafeAreaView edges={['top']} style={[styles.flex, { backgroundColor: theme.colors.background }]}>
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
{/* Header */}
<Header theme={theme} navigation={navigation} />
{/* Search Bar */}
<View style={styles.searchSection}>
<View
style={[
styles.searchBar,
{
backgroundColor: theme.colors.surface,
borderColor: theme.colors.cardBorder,
borderRadius: theme.radius.lg,
},
theme.shadows.soft,
]}
>
<Ionicons name="search-outline" size={18} color={theme.colors.textMuted} />
<TextInput
value={searchText}
onChangeText={setSearchText}
placeholder="Search city, pincode, branch..."
placeholderTextColor={theme.colors.textMuted}
style={[styles.searchInput, { color: theme.colors.textPrimary }]}
/>
{searchText.length > 0 && (
<Pressable onPress={() => setSearchText('')}>
<Ionicons name="close-circle" size={18} color={theme.colors.textMuted} />
</Pressable>
)}
</View>
{/* View Toggle */}
<Pressable
onPress={() => setViewMode((v) => (v === 'map' ? 'list' : 'map'))}
style={[
styles.toggleBtn,
{
backgroundColor: theme.colors.primary,
borderRadius: theme.radius.lg,
},
]}
>
<Ionicons
name={viewMode === 'map' ? 'list-outline' : 'map-outline'}
size={20}
color={theme.colors.white}
/>
</Pressable>
</View>
{/* Segmented Control for Locator Type */}
<View style={styles.segmentedContainer}>
{(['all', 'branch', 'atm'] as const).map((type) => {
const isActive = filterType === type;
const label = type === 'all' ? 'All' : type === 'branch' ? 'Branches' : 'ATMs';
return (
<Pressable
key={type}
onPress={() => setFilterType(type)}
style={[
styles.segmentSegment,
isActive && { backgroundColor: theme.colors.primary },
]}
>
<Text
style={[
styles.segmentLabel,
{ color: isActive ? theme.colors.white : theme.colors.textSecondary },
]}
>
{label}
</Text>
</Pressable>
);
})}
</View>
{/* Facility Filters */}
<View style={styles.filterSection}>
<FacilityFilterBar selected={selectedFacilities} onToggle={handleFacilityToggle} />
</View>
{/* City Chips */}
<View style={styles.cityFilterSection}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.cityScroll}>
{cities.map((city) => {
const isActive = (selectedCity === city) || (!selectedCity && city === 'All Cities');
return (
<Pressable
key={city}
onPress={() => setSelectedCity(city === 'All Cities' ? null : city)}
style={[
styles.cityChip,
{
backgroundColor: isActive ? theme.colors.primary : theme.colors.surface,
borderColor: isActive ? theme.colors.primary : theme.colors.cardBorder,
},
]}
>
<Text
style={[
styles.cityChipText,
{ color: isActive ? theme.colors.white : theme.colors.textSecondary },
]}
>
{city}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
{/* Branch count */}
<View style={styles.countRow}>
<Text style={[styles.countText, { color: theme.colors.textSecondary }]}>
{branches.length} {branches.length === 1 ? 'location' : 'locations'} found
</Text>
{(selectedFacilities.length > 0 || selectedCity) && (
<Pressable onPress={() => { setSelectedFacilities([]); setSelectedCity(null); }}>
<Text style={[styles.clearText, { color: theme.colors.primary }]}>
Clear filters
</Text>
</Pressable>
)}
</View>
{/* Map or List View */}
{branches.length === 0 ? (
<View style={styles.emptyContainer}>
<EmptyState
title="No branches found"
description="Try adjusting your search or filters to find nearby branches."
actionLabel="Clear all"
onAction={() => {
setSearchText('');
setSelectedFacilities([]);
}}
/>
</View>
) : viewMode === 'map' ? (
<View style={styles.mapContainer}>
<MapLibreGL.MapView
style={styles.map}
mapStyle={OSM_STYLE}
logoEnabled={false}
attributionEnabled={true}
attributionPosition={{ bottom: 8, right: 8 }}
>
<MapLibreGL.Camera
ref={cameraRef}
defaultSettings={{
centerCoordinate: defaultCenter,
zoomLevel: 11,
}}
animationDuration={600}
/>
{/* User location marker */}
{userLocation && (
<MapLibreGL.PointAnnotation
id="user-location"
coordinate={[userLocation.longitude, userLocation.latitude]}
>
<View style={styles.userMarkerOuter}>
<View style={[styles.userMarkerInner, { backgroundColor: theme.colors.info }]} />
</View>
</MapLibreGL.PointAnnotation>
)}
{/* Branch markers */}
{branches.map((branch) => {
const isSelected = selectedBranch?.id === branch.id;
return (
<MapLibreGL.PointAnnotation
key={branch.id}
id={branch.id}
coordinate={[branch.longitude, branch.latitude]}
onSelected={() => handleMarkerPress(branch)}
>
<View
style={[
styles.markerContainer,
{
backgroundColor: isSelected
? theme.colors.primary
: theme.colors.surface,
borderColor: isSelected
? theme.colors.primaryDark
: branch.locationType === 'atm' ? theme.colors.info : theme.colors.primary,
transform: [{ scale: isSelected ? 1.2 : 1 }],
},
]}
>
<Ionicons
name={branch.locationType === 'atm' ? 'card' : branch.isMainBranch ? 'business' : 'location'}
size={16}
color={isSelected ? theme.colors.white : branch.locationType === 'atm' ? theme.colors.info : theme.colors.primary}
/>
</View>
<MapLibreGL.Callout title={branch.name} />
</MapLibreGL.PointAnnotation>
);
})}
</MapLibreGL.MapView>
{/* Nearest branch floating card */}
{(() => {
const nearest = branches[0];
if (!nearest || nearest.distance === null || showSheet) { return null; }
return (
<Pressable
style={[
styles.nearestCard,
{
backgroundColor: theme.colors.surface,
borderRadius: theme.radius.lg,
bottom: bottomOffset,
},
theme.shadows.soft,
]}
onPress={() => handleBranchPress(nearest)}
>
<View style={[styles.nearestIcon, { backgroundColor: `${theme.colors.accent}14` }]}>
<Ionicons name="navigate" size={16} color={theme.colors.accent} />
</View>
<View style={styles.nearestContent}>
<Text style={[styles.nearestLabel, { color: theme.colors.textMuted }]}>
NEAREST {nearest.locationType === 'atm' ? 'ATM' : 'BRANCH'}
</Text>
<Text
style={[styles.nearestName, { color: theme.colors.textPrimary }]}
numberOfLines={1}
>
{nearest.name}
</Text>
<Text style={[styles.nearestDistance, { color: theme.colors.textSecondary }]}>
{nearest.distance !== null
? `${nearest.distance.toFixed(1)} km away`
: ''}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={theme.colors.textMuted} />
</Pressable>
);
})()}
{/* Re-center button */}
{userLocation && (
<Pressable
style={[
styles.recenterBtn,
{
backgroundColor: theme.colors.surface,
borderRadius: theme.radius.pill,
bottom: bottomOffset + 80,
},
theme.shadows.soft,
]}
onPress={() => {
cameraRef.current?.setCamera({
centerCoordinate: [userLocation.longitude, userLocation.latitude],
zoomLevel: 12,
animationDuration: 600,
});
}}
>
<Ionicons name="locate" size={20} color={theme.colors.primary} />
</Pressable>
)}
</View>
) : (
/* List View */
<FlatList
data={branches}
keyExtractor={(item) => item.id}
contentContainerStyle={[styles.listContent, { paddingBottom: bottomOffset }]}
showsVerticalScrollIndicator={false}
renderItem={({ item }) => (
<BranchListItem
branch={item}
isSelected={selectedBranch?.id === item.id}
onPress={() => handleBranchPress(item)}
/>
)}
/>
)}
{/* Branch Detail Bottom Sheet */}
<BranchDetailSheet
branch={selectedBranch}
visible={showSheet}
onClose={handleCloseSheet}
/>
</SafeAreaView>
);
}
// ── Header sub-component ──
interface HeaderProps {
theme: ReturnType<typeof useTheme>;
navigation: Props['navigation'];
}
const Header = ({ theme, navigation }: HeaderProps) => (
<View style={styles.header}>
<Pressable
onPress={() => navigation.goBack()}
style={[
styles.backButton,
{
backgroundColor: theme.colors.cardGlass,
borderColor: theme.colors.cardBorder,
borderRadius: theme.radius.pill,
},
]}
>
<Ionicons name="chevron-back" size={20} color={theme.colors.textPrimary} />
</Pressable>
<View style={styles.headerCopy}>
<Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}>
BRANCH & ATM LOCATOR
</Text>
</View>
</View>
);
// ── Styles ──
const styles = StyleSheet.create({
flex: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 10,
paddingVertical: 8,
},
backButton: {
width: 42,
height: 42,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
headerCopy: {
flex: 1,
},
headerTitle: {
fontSize: 20,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.5,
lineHeight: 20,
},
searchSection: {
flexDirection: 'row',
paddingHorizontal: 10,
gap: 10,
marginBottom: 8,
},
searchBar: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
height: 46,
paddingHorizontal: 14,
borderWidth: 1,
gap: 8,
},
searchInput: {
flex: 1,
fontSize: 14,
fontWeight: '500',
height: '100%',
},
toggleBtn: {
width: 46,
height: 46,
alignItems: 'center',
justifyContent: 'center',
},
filterSection: {
paddingHorizontal: 6,
marginBottom: 6,
},
segmentedContainer: {
flexDirection: 'row',
marginHorizontal: 10,
marginBottom: 8,
padding: 4,
backgroundColor: 'rgba(0,0,0,0.05)',
borderRadius: 12,
},
segmentSegment: {
flex: 1,
paddingVertical: 8,
alignItems: 'center',
borderRadius: 8,
},
segmentLabel: {
fontSize: 12,
fontWeight: '600',
},
countRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 14,
marginBottom: 6,
},
countText: {
fontSize: 12,
fontWeight: '600',
},
clearText: {
fontSize: 12,
fontWeight: '700',
},
mapContainer: {
flex: 1,
position: 'relative',
},
map: {
flex: 1,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: 32,
},
listContent: {
paddingHorizontal: 10,
paddingBottom: 30,
},
// Markers
markerContainer: {
width: 36,
height: 36,
borderRadius: 18,
borderWidth: 2,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 4,
elevation: 4,
},
userMarkerOuter: {
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: 'rgba(59, 130, 246, 0.2)',
alignItems: 'center',
justifyContent: 'center',
},
userMarkerInner: {
width: 12,
height: 12,
borderRadius: 6,
borderWidth: 2,
borderColor: '#FFFFFF',
},
// Nearest Branch Card
nearestCard: {
position: 'absolute',
bottom: 20,
left: 14,
right: 14,
flexDirection: 'row',
alignItems: 'center',
padding: 14,
gap: 10,
},
nearestIcon: {
width: 38,
height: 38,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
nearestContent: {
flex: 1,
gap: 1,
},
cityFilterSection: {
paddingHorizontal: 16,
marginBottom: 8,
},
cityScroll: {
gap: 8,
},
cityChip: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
borderWidth: 1,
},
cityChipText: {
fontSize: 12,
fontWeight: '600',
},
sectionCopy: {
flex: 1,
gap: 1,
},
nearestLabel: {
fontSize: 9,
fontWeight: '700',
letterSpacing: 0.8,
},
nearestName: {
fontSize: 14,
fontWeight: '700',
},
nearestDistance: {
fontSize: 12,
},
// Re-center Button
recenterBtn: {
position: 'absolute',
right: 14,
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
});
@@ -0,0 +1,219 @@
import React, { useState } from 'react';
import { View, StyleSheet, Text, FlatList } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import type { ServicesStackParamList } from '../../../navigation/types';
import { ScreenShell, Card, Button } from '../../../components';
import { AppModal } from '../../../components/Modal';
import { useToast } from '../../../hooks/useToast';
import { useTheme } from '../../../theme';
type Props = NativeStackScreenProps<ServicesStackParamList, 'CertificateList'>;
export default function CertificateListScreen({ route }: Props) {
const { title, category } = route.params;
const theme = useTheme();
const toast = useToast();
const [viewingDoc, setViewingDoc] = useState<string | null>(null);
const mockDocs = Array.from({ length: 4 }).map((_, i) => ({
id: String(i),
name: `FY ${26 - i}-${27 - i} ${title}`,
date: `Generated Apr ${2026 - i}`,
size: `${(1.2 + i * 0.3).toFixed(1)} MB`,
}));
const handleDownload = (docName: string) => {
toast('Download Started', 'success', `${docName} is being securely downloaded to your device.`);
};
return (
<ScreenShell title={title} subtitle={`${mockDocs.length} documents available`}>
<FlatList
data={mockDocs}
keyExtractor={item => item.id}
contentContainerStyle={{ gap: 16, paddingBottom: 24 }}
showsVerticalScrollIndicator={false}
renderItem={({ item }) => (
<Card variant="glass" style={styles.card}>
<View style={styles.cardTop}>
<View style={[styles.docIcon, { backgroundColor: `${theme.colors.primary}10`, borderColor: `${theme.colors.primary}20` }]}>
<Ionicons name="document-text" size={26} color={theme.colors.primary} />
</View>
<View style={styles.docInfo}>
<Text style={[styles.docTitle, { color: theme.colors.textPrimary }]}>{item.name}</Text>
<Text style={[styles.docMeta, { color: theme.colors.textSecondary }]}>
{item.date} • {item.size}
</Text>
</View>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.actionRow}>
<Button
title="View"
variant="ghost"
fullWidth={false}
style={[styles.actionBtn, { borderColor: theme.colors.cardBorder }]}
onPress={() => setViewingDoc(item.name)}
leftAccessory={<Ionicons name="eye-outline" size={18} color={theme.colors.textPrimary} style={{ marginRight: 2 }} />}
/>
<Button
title="Download"
variant="secondary"
fullWidth={false}
style={styles.actionBtn}
onPress={() => handleDownload(item.name)}
leftAccessory={<Ionicons name="cloud-download-outline" size={18} color={theme.colors.textPrimary} style={{ marginRight: 2 }} />}
/>
</View>
</Card>
)}
/>
<AppModal
visible={!!viewingDoc}
title="Document Preview"
onClose={() => setViewingDoc(null)}
>
<View style={[styles.certificatePreview, { backgroundColor: theme.colors.background, borderColor: theme.colors.cardBorder }]}>
<View style={[styles.certHeader, { borderBottomColor: theme.colors.cardBorder }]}>
<Ionicons name="business" size={20} color={theme.colors.primary} />
<Text style={[styles.certBankName, { color: theme.colors.primary }]}>CBS BANK LTD.</Text>
</View>
<Text style={[styles.certTitle, { color: theme.colors.textPrimary }]}>{title}</Text>
<View style={{ gap: 8, marginTop: 12 }}>
<Text style={[styles.certText, { color: theme.colors.textSecondary }]}>
This officially certifies that the document <Text style={{fontWeight: '700', color: theme.colors.textPrimary}}>{viewingDoc}</Text> has been cryptographically generated and is valid.
</Text>
<Text style={[styles.certText, { color: theme.colors.textSecondary }]}>Reference Category: <Text style={{fontWeight: '700'}}>{category.toUpperCase()}</Text></Text>
<Text style={[styles.certText, { color: theme.colors.textSecondary }]}>Account Bearer: Lincpay User</Text>
<Text style={[styles.certText, { color: theme.colors.textSecondary }]}>Account #: XXXX-XXXX-1234</Text>
</View>
<View style={styles.certFooter}>
<View style={[styles.sealBox, { borderColor: theme.colors.primary }]}>
<Ionicons name="ribbon-outline" size={20} color={theme.colors.primary} />
</View>
<View style={{ alignItems: 'center' }}>
<Text style={[styles.signature, { color: theme.colors.textPrimary }]}>Auth. Signatory</Text>
<View style={{ width: 80, height: 1, backgroundColor: theme.colors.textSecondary, marginTop: 4 }} />
</View>
</View>
</View>
<Button
title="Download Full PDF"
variant="primary"
onPress={() => {
if (viewingDoc) handleDownload(viewingDoc);
setViewingDoc(null);
}}
style={{ marginTop: 8 }}
leftAccessory={<Ionicons name="cloud-download-outline" size={18} color="#fff" style={{ marginRight: 4 }} />}
/>
<Button
title="Close"
variant="ghost"
onPress={() => setViewingDoc(null)}
style={{ marginTop: 4 }}
/>
</AppModal>
</ScreenShell>
);
}
const styles = StyleSheet.create({
card: {
padding: 16,
gap: 16,
borderWidth: 1,
},
cardTop: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
},
docIcon: {
width: 52,
height: 52,
borderRadius: 16,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
docInfo: {
flex: 1,
gap: 6,
},
docTitle: {
fontWeight: '700',
fontSize: 16,
letterSpacing: -0.3,
},
docMeta: {
fontSize: 13,
fontWeight: '500',
},
divider: {
height: 1,
width: '100%',
opacity: 0.6,
},
actionRow: {
flexDirection: 'row',
gap: 12,
},
actionBtn: {
flex: 1,
height: 44,
},
certificatePreview: {
padding: 20,
borderWidth: 1,
borderRadius: 8,
marginVertical: 12,
},
certHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderBottomWidth: 1,
paddingBottom: 8,
marginBottom: 12,
},
certBankName: {
fontWeight: '900',
fontSize: 14,
letterSpacing: 0.5,
},
certTitle: {
fontWeight: '800',
fontSize: 18,
textAlign: 'center',
},
certText: {
fontSize: 13,
lineHeight: 20,
},
certFooter: {
marginTop: 24,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
},
sealBox: {
width: 44,
height: 44,
borderRadius: 22,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
borderStyle: 'dashed',
},
signature: {
fontStyle: 'italic',
fontSize: 14,
fontWeight: '600',
},
});
@@ -0,0 +1,166 @@
import React, { useMemo } from 'react';
import { StyleSheet, View, Text } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import type { ServicesStackParamList } from '../../../navigation/types';
import { ScreenShell, Card } from '../../../components';
import { ServiceGridItem } from '../../home/components/ServiceGridItem';
import { useTheme } from '../../../theme';
type Props = NativeStackScreenProps<ServicesStackParamList, 'CertificatesHub'>;
export default function CertificatesHubScreen({ navigation }: Props) {
const theme = useTheme();
const options = useMemo(
() => [
{
id: 'balance',
title: 'Balance Cert',
icon: 'wallet-outline',
iconColor: '#3B82F6',
onPress: () => navigation.navigate('CertificateList', { title: 'Balance Certificates', category: 'balance' }),
},
{
id: 'interest',
title: 'Interest Cert',
icon: 'trending-up-outline',
iconColor: '#10B981',
onPress: () => navigation.navigate('CertificateList', { title: 'Interest Certificates', category: 'interest' }),
},
{
id: 'hypothecation',
title: 'Hypothecation',
icon: 'document-text-outline',
iconColor: '#F59E0B',
onPress: () => navigation.navigate('CertificateList', { title: 'Hypothecation', category: 'hypothecation' }),
},
{
id: 'insurance',
title: 'Insurance',
icon: 'shield-checkmark-outline',
iconColor: '#8B5CF6',
onPress: () => navigation.navigate('CertificateList', { title: 'Insurance Copies', category: 'insurance' }),
},
{
id: 'tax',
title: 'Tax Forms',
icon: 'receipt-outline',
iconColor: '#EC4899',
onPress: () => navigation.navigate('CertificateList', { title: 'Tax Statements', category: 'tax' }),
},
{
id: 'loan',
title: 'Loan Docs',
icon: 'home-outline',
iconColor: '#6366F1',
onPress: () => navigation.navigate('CertificateList', { title: 'Loan Documents', category: 'loan' }),
},
],
[navigation]
);
return (
<ScreenShell title="Certificates" subtitle="Secure document vault">
<Card variant="glass" style={styles.heroCard}>
<View style={styles.heroHeader}>
<View style={[styles.heroIcon, { backgroundColor: `${theme.colors.primary}15` }]}>
<Ionicons name="folder-open-outline" size={24} color={theme.colors.primary} />
</View>
<View style={styles.heroCopy}>
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>Document Vault</Text>
<Text style={[styles.heroSubtitle, { color: theme.colors.textSecondary }]}>
All your official bank certificates and statements, generated dynamically and secured.
</Text>
</View>
</View>
<View style={styles.heroStats}>
<View style={[styles.heroStat, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.heroStatValue, { color: theme.colors.textPrimary }]}>12</Text>
<Text style={[styles.heroStatLabel, { color: theme.colors.textSecondary }]}>Generated</Text>
</View>
<View style={[styles.heroStat, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.heroStatValue, { color: theme.colors.primary }]}>Active</Text>
<Text style={[styles.heroStatLabel, { color: theme.colors.textSecondary }]}>Vault Status</Text>
</View>
</View>
</Card>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Select Category</Text>
<View style={styles.grid}>
{options.map((opt) => (
<ServiceGridItem key={opt.id} {...opt} />
))}
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
heroCard: {
padding: 20,
gap: 20,
marginBottom: 8,
},
heroHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
},
heroIcon: {
width: 48,
height: 48,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
heroCopy: {
flex: 1,
gap: 4,
},
heroTitle: {
fontSize: 18,
fontWeight: '800',
letterSpacing: -0.3,
},
heroSubtitle: {
fontSize: 13,
lineHeight: 18,
},
heroStats: {
flexDirection: 'row',
gap: 12,
},
heroStat: {
flex: 1,
borderRadius: 12,
padding: 12,
alignItems: 'center',
justifyContent: 'center',
gap: 4,
},
heroStatLabel: {
fontSize: 11,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
},
heroStatValue: {
fontSize: 18,
fontWeight: '800',
},
sectionTitle: {
fontSize: 16,
fontWeight: '700',
marginTop: 8,
marginLeft: 4,
},
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
},
});
@@ -0,0 +1,62 @@
import React, { useState } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { ServicesStackParamList } from '../../../navigation/types';
import { ScreenShell, Card, Input, Button } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import { useTheme } from '../../../theme';
type Props = NativeStackScreenProps<ServicesStackParamList, 'ChequeBookRequest'>;
export default function ChequeBookRequestScreen({ navigation }: Props) {
const theme = useTheme();
const toast = useToast();
const [account, setAccount] = useState('0000XXXX1234');
const [leaves, setLeaves] = useState('25');
const [address, setAddress] = useState('Registered Address');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = () => {
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
toast('Request Submitted', 'success', 'Your cheque book will be delivered in 3-5 days.');
navigation.goBack();
}, 1500);
};
return (
<ScreenShell title="Cheque Book" subtitle="Request a new cheque book">
<Card variant="default" style={styles.card}>
<View style={styles.formGroup}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Select Account</Text>
<Input value={account} onChangeText={setAccount} placeholder="Account Number" />
</View>
<View style={styles.formGroup}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Number of Leaves</Text>
<Input value={leaves} onChangeText={setLeaves} placeholder="e.g. 25, 50, 100" keyboardType="number-pad" />
</View>
<View style={styles.formGroup}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Delivery Address</Text>
<Input value={address} onChangeText={setAddress} placeholder="Branch or Registered Address" />
</View>
<Button
title="Submit Request"
onPress={handleSubmit}
loading={isLoading}
style={styles.submitBtn}
/>
</Card>
</ScreenShell>
);
}
const styles = StyleSheet.create({
card: { padding: 16, gap: 16 },
formGroup: { gap: 8 },
label: { fontSize: 13, fontWeight: '600' },
submitBtn: { marginTop: 16 },
});
@@ -0,0 +1,93 @@
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { ServicesStackParamList } from '../../../navigation/types';
import { ScreenShell } from '../../../components';
import { ServiceGridItem } from '../../home/components/ServiceGridItem';
import { useTheme } from '../../../theme';
import { ChequeAnimation } from '../components/ChequeAnimation';
type Props = NativeStackScreenProps<ServicesStackParamList, 'ChequeServicesHub'>;
export default function ChequeServicesHubScreen({ navigation }: Props) {
const theme = useTheme();
const options = useMemo(
() => [
{
id: 'request',
title: 'Book Request',
icon: 'book-outline',
iconColor: '#3B82F6',
onPress: () => navigation.navigate('ChequeBookRequest'),
},
{
id: 'stop',
title: 'Stop Payment',
icon: 'stop-circle-outline',
iconColor: '#EF4444',
onPress: () => navigation.navigate('StopPayment'),
},
{
id: 'track',
title: 'Track Cheques',
icon: 'git-compare-outline',
iconColor: '#10B981',
onPress: () => navigation.navigate('ChequeTracking'),
},
],
[navigation]
);
return (
<ScreenShell title="Cheque Services" subtitle="Manage all your cheque-related needs">
{/* Animated cheque illustration */}
<ChequeAnimation />
{/* Quick info strip */}
<View style={[styles.infoStrip, { backgroundColor: `${theme.colors.primary}08`, borderColor: `${theme.colors.primary}12` }]}>
<View style={[styles.infoDot, { backgroundColor: theme.colors.accent }]} />
<Text style={[styles.infoText, { color: theme.colors.textSecondary }]}>
Request, track, or manage cheques — all in one place
</Text>
</View>
{/* Service grid */}
<View style={styles.grid}>
{options.map((opt) => (
<ServiceGridItem key={opt.id} {...opt} />
))}
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
paddingTop: 8,
},
infoStrip: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
borderRadius: 12,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
marginBottom: 16,
},
infoDot: {
width: 6,
height: 6,
borderRadius: 3,
},
infoText: {
fontSize: 12,
fontWeight: '600',
flex: 1,
lineHeight: 17,
},
});
@@ -0,0 +1,45 @@
import React from 'react';
import { View, StyleSheet, Text, FlatList } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { ServicesStackParamList } from '../../../navigation/types';
import { ScreenShell, Card } from '../../../components';
import { useTheme } from '../../../theme';
type Props = NativeStackScreenProps<ServicesStackParamList, 'ChequeTracking'>;
const MOCK_TRACKING = [
{ id: '1', title: 'Cheque Book #65', status: 'Delivered', date: '10 Oct 2026', items: [{ stage: 'Requested', time: '01 Oct', active: false }, { stage: 'Processed', time: '05 Oct', active: false }, { stage: 'Delivered', time: '10 Oct', active: true }] },
{ id: '2', title: 'Cheque Book #66', status: 'Processing', date: 'Today', items: [{ stage: 'Requested', time: 'Today', active: false }, { stage: 'Processing', time: 'Today', active: true }] }
];
export default function ChequeTrackingScreen({ navigation }: Props) {
const theme = useTheme();
return (
<ScreenShell title="Track Cheques" subtitle="Cheque books and individual cheques">
<FlatList
data={MOCK_TRACKING}
keyExtractor={item => item.id}
contentContainerStyle={{ gap: 16, paddingBottom: 24 }}
renderItem={({ item }) => (
<Card style={{ padding: 16, gap: 12 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<Text style={{ fontWeight: '700', fontSize: 16, color: theme.colors.textPrimary }}>{item.title}</Text>
<Text style={{ fontSize: 13, color: theme.colors.textSecondary }}>{item.date}</Text>
</View>
<View style={{ height: 1, backgroundColor: `${theme.colors.cardBorder}40`, marginVertical: 4 }} />
{item.items.map((stage, idx) => (
<View key={idx} style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<View style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: stage.active ? theme.colors.primary : theme.colors.cardBorder }} />
<View style={{ flex: 1, flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ color: stage.active ? theme.colors.primary : theme.colors.textSecondary, fontWeight: stage.active ? '700' : '500' }}>{stage.stage}</Text>
<Text style={{ fontSize: 12, color: theme.colors.textMuted }}>{stage.time}</Text>
</View>
</View>
))}
</Card>
)}
/>
</ScreenShell>
);
}
@@ -0,0 +1,93 @@
import React, { useState } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { ServicesStackParamList } from '../../../navigation/types';
import { ScreenShell, Card, Input, Button } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import { useTheme } from '../../../theme';
type Props = NativeStackScreenProps<ServicesStackParamList, 'Form15G15H'>;
export default function Form15G15HScreen({ navigation }: Props) {
const theme = useTheme();
const toast = useToast();
const [formType, setFormType] = useState('15G');
const [panNumber, setPanNumber] = useState('');
const [estimatedIncome, setEstimatedIncome] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = () => {
if (!panNumber || !estimatedIncome) {
toast('Validation Failed', 'error', 'PAN and estimated income are required.');
return;
}
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
toast('Form Submitted successfully', 'success', `Your Form ${formType} has been recorded for the current FY.`);
navigation.goBack();
}, 1500);
};
return (
<ScreenShell title="Form 15G / 15H" subtitle="Submit your tax declarations">
<Card variant="default" style={styles.card}>
<View style={{ flexDirection: 'row', gap: 12, marginBottom: 8 }}>
<Button
title="Form 15G"
variant={formType === '15G' ? 'primary' : 'secondary'}
onPress={() => setFormType('15G')}
style={{ flex: 1 }}
/>
<Button
title="Form 15H (Senior Citizens)"
variant={formType === '15H' ? 'primary' : 'secondary'}
onPress={() => setFormType('15H')}
style={{ flex: 1 }}
/>
</View>
<View style={styles.formGroup}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>PAN Number</Text>
<Input value={panNumber} onChangeText={setPanNumber} placeholder="ABCDE1234F" autoCapitalize="characters" />
</View>
<View style={styles.formGroup}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Estimated Income (₹)</Text>
<Input value={estimatedIncome} onChangeText={setEstimatedIncome} placeholder="e.g. 350000" keyboardType="numeric" />
</View>
<View style={{ padding: 12, backgroundColor: `${theme.colors.info}15`, borderRadius: 8 }}>
<Text style={{ color: theme.colors.info, fontSize: 13, lineHeight: 18 }}>
By submitting, you declare that the tax on your estimated total income for the previous year will be nil.
</Text>
</View>
<Button
title={`Submit Form ${formType}`}
onPress={handleSubmit}
loading={isLoading}
style={styles.submitBtn}
/>
</Card>
<View style={{ marginTop: 24, gap: 16 }}>
<Text style={{ fontWeight: '700', fontSize: 16, color: theme.colors.textPrimary, marginLeft: 4 }}>History</Text>
<Card style={{ padding: 16, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<View>
<Text style={{ fontWeight: '700', color: theme.colors.textPrimary }}>Form 15G - FY 24-25</Text>
<Text style={{ fontSize: 12, color: theme.colors.textSecondary }}>Submitted April 2025</Text>
</View>
<Button title="Download" variant="ghost" onPress={() => toast('Downloading...', 'info')} />
</Card>
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
card: { padding: 16, gap: 16 },
formGroup: { gap: 8 },
label: { fontSize: 13, fontWeight: '600' },
submitBtn: { marginTop: 8 },
});
@@ -0,0 +1,78 @@
import React, { useState } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { ServicesStackParamList } from '../../../navigation/types';
import { ScreenShell, Card, Input, Button } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import { useTheme } from '../../../theme';
type Props = NativeStackScreenProps<ServicesStackParamList, 'PositivePay'>;
export default function PositivePayScreen({ navigation }: Props) {
const theme = useTheme();
const toast = useToast();
const [account, setAccount] = useState('0000XXXX1234');
const [chequeNumber, setChequeNumber] = useState('');
const [amount, setAmount] = useState('');
const [payee, setPayee] = useState('');
const [date, setDate] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = () => {
if (!chequeNumber || !amount || !payee) {
toast('Incomplete Form', 'error', 'Please fill all required details.');
return;
}
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
toast('Verification Submitted', 'success', `Details for Cheque #${chequeNumber} saved securely.`);
navigation.goBack();
}, 1500);
};
return (
<ScreenShell title="Positive Pay System" subtitle="Secure your high-value cheques">
<Card variant="default" style={styles.card}>
<View style={styles.formGroup}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Select Account</Text>
<Input value={account} onChangeText={setAccount} placeholder="Account Number" />
</View>
<View style={styles.formGroup}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Cheque Number</Text>
<Input value={chequeNumber} onChangeText={setChequeNumber} placeholder="6-digit unique number" keyboardType="number-pad" />
</View>
<View style={styles.formGroup}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Amount</Text>
<Input value={amount} onChangeText={setAmount} placeholder="e.g. 50000" keyboardType="numeric" />
</View>
<View style={styles.formGroup}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Payee Name</Text>
<Input value={payee} onChangeText={setPayee} placeholder="Exact name on cheque" />
</View>
<View style={styles.formGroup}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Cheque Date</Text>
<Input value={date} onChangeText={setDate} placeholder="DD/MM/YYYY" />
</View>
<Button
title="Submit Details"
onPress={handleSubmit}
loading={isLoading}
style={styles.submitBtn}
/>
</Card>
</ScreenShell>
);
}
const styles = StyleSheet.create({
card: { padding: 16, gap: 16 },
formGroup: { gap: 8 },
label: { fontSize: 13, fontWeight: '600' },
submitBtn: { marginTop: 16 },
});
@@ -0,0 +1,93 @@
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { ServicesStackParamList } from '../../../navigation/types';
import { ScreenShell } from '../../../components';
import { ServiceGridItem } from '../../home/components/ServiceGridItem';
import { useTheme } from '../../../theme';
import { ServiceRequestAnimation } from '../components/ServiceRequestAnimation';
type Props = NativeStackScreenProps<ServicesStackParamList, 'ServiceRequestsHub'>;
export default function ServiceRequestsHubScreen({ navigation }: Props) {
const theme = useTheme();
const options = useMemo(
() => [
{
id: 'positive_pay',
title: 'Positive Pay',
icon: 'lock-closed-outline',
iconColor: '#F43F5E',
onPress: () => navigation.navigate('PositivePay'),
},
{
id: 'form15g',
title: 'Form 15G/15H',
icon: 'document-attach-outline',
iconColor: '#10B981',
onPress: () => navigation.navigate('Form15G15H'),
},
{
id: 'special',
title: 'Special Instr.',
icon: 'create-outline',
iconColor: '#6366F1',
onPress: () => navigation.navigate('SpecialInstructions'),
},
],
[navigation]
);
return (
<ScreenShell title="Service Requests" subtitle="Submit and track requests">
{/* Animated illustration */}
<ServiceRequestAnimation />
{/* Info strip */}
<View style={[styles.infoStrip, { backgroundColor: `${theme.colors.info}08`, borderColor: `${theme.colors.info}12` }]}>
<View style={[styles.infoDot, { backgroundColor: theme.colors.info }]} />
<Text style={[styles.infoText, { color: theme.colors.textSecondary }]}>
Submit forms, set up Positive Pay, or add special instructions
</Text>
</View>
{/* Service grid */}
<View style={styles.grid}>
{options.map((opt) => (
<ServiceGridItem key={opt.id} {...opt} />
))}
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
paddingTop: 8,
},
infoStrip: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
borderRadius: 12,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
marginBottom: 16,
},
infoDot: {
width: 6,
height: 6,
borderRadius: 3,
},
infoText: {
fontSize: 12,
fontWeight: '600',
flex: 1,
lineHeight: 17,
},
});
@@ -0,0 +1,260 @@
import React, { useMemo } from 'react';
import { 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 Ionicons from '@react-native-vector-icons/ionicons';
import { useAppSelector } from '../../../app/hooks';
import { Card, ScreenShell } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import { ServiceGridItem } from '../../home/components/ServiceGridItem';
import { ServiceStatusPill } from '../components/ServiceStatusPill';
import { selectServiceRequests } from '../store/serviceSelectors';
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
type Props = CompositeScreenProps<
NativeStackScreenProps<ServicesStackParamList, 'ServicesHub'>,
BottomTabScreenProps<MainTabParamList, 'Services'>
>;
export default function ServicesHubScreen({ navigation }: Props) {
const theme = useTheme();
const toast = useToast();
const serviceRequests = useAppSelector(selectServiceRequests);
const latestRequest = serviceRequests[0] ?? null;
const openRequestsCount = serviceRequests.filter(
(request) => request.status !== 'resolved',
).length;
const serviceOptions = useMemo(
() => [
{
id: 'branch_locator',
title: 'Find Branch & ATMs',
icon: 'map-outline',
iconColor: '#10B981',
onPress: () => navigation.navigate('BranchLocator'),
},
{
id: 'all_deposits',
title: 'Deposits',
icon: 'wallet-outline',
iconColor: '#124577',
onPress: () => navigation.navigate('DepositsOverview'),
},
{
id: 'cheque_services',
title: 'Cheque Services',
icon: 'book-outline',
iconColor: '#3B82F6',
onPress: () => navigation.navigate('ChequeServicesHub'),
},
{
id: 'certificates',
title: 'Certificates',
icon: 'document-text-outline',
iconColor: '#F59E0B',
onPress: () => navigation.navigate('CertificatesHub'),
},
{
id: 'requests',
title: 'Service Requests',
icon: 'git-pull-request-outline',
iconColor: '#EF4444',
onPress: () => navigation.navigate('ServiceRequestsHub'),
},
{
id: 'calculator',
title: 'FD/RD Calc',
icon: 'calculator-outline',
iconColor: '#6366F1',
onPress: () => navigation.navigate('DepositCalculator'),
},
{
id: 'support',
title: 'Support Chat',
icon: 'chatbubble-ellipses-outline',
iconColor: '#0EA5E9',
onPress: () =>
toast(
'Support chat screen pending',
'info',
'This feature is coming in the next update.',
),
},
{
id: 'help',
title: 'Help Center',
icon: 'help-circle-outline',
iconColor: '#8B5CF6',
onPress: () =>
toast('Help center pending', 'info', 'Coming soon.'),
},
{
id: 'cyber_security',
title: 'Cyber Security',
icon: 'shield-half-outline',
iconColor: '#F59E0B',
onPress: () => navigation.navigate('AwarenessList'),
},
{
id: 'protection_schemes',
title: 'Protection & Schemes',
icon: 'umbrella-outline',
iconColor: '#EC4899',
onPress: () => navigation.navigate('FinancialProtectionMain'),
},
],
[navigation, toast]
);
return (
<ScreenShell title="Service desk" subtitle="All service options in one hub">
{/* <View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Service options
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
Choose a service card to continue
</Text>
</View> */}
<View style={styles.grid}>
{serviceOptions.map((option) => (
<ServiceGridItem
key={option.id}
id={option.id}
title={option.title}
icon={option.icon}
iconColor={option.iconColor}
onPress={option.onPress}
/>
))}
</View>
<Card variant="glass" style={styles.heroCard}>
<View style={styles.heroHeader}>
<View
style={[
styles.heroIcon,
{ backgroundColor: `${theme.colors.primary}12` },
]}
>
<Ionicons name="grid-outline" size={18} color={theme.colors.primary} />
</View>
<View style={styles.heroCopy}>
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
Services hub
</Text>
<Text style={[styles.heroSubtitle, { color: theme.colors.textSecondary }]}>
Browse deposit journeys, service support actions, and request tools from one place.
</Text>
</View>
</View>
<View style={styles.heroStats}>
<View style={[styles.heroStat, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.heroStatLabel, { color: theme.colors.textSecondary }]}>
Open requests
</Text>
<Text style={[styles.heroStatValue, { color: theme.colors.textPrimary }]}>
{openRequestsCount}
</Text>
</View>
<View style={[styles.heroStat, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.heroStatLabel, { color: theme.colors.textSecondary }]}>
Latest status
</Text>
{latestRequest ? (
<ServiceStatusPill status={latestRequest.status} />
) : (
<Text style={[styles.heroStatEmpty, { color: theme.colors.textMuted }]}>
No requests
</Text>
)}
</View>
</View>
</Card>
<View style={styles.bottomSpacer} />
</ScreenShell>
);
}
const styles = StyleSheet.create({
heroCard: {
gap: 16,
shadowOpacity: 0.04,
shadowRadius: 14,
elevation: 2,
},
heroHeader: {
flexDirection: 'row',
gap: 12,
},
heroIcon: {
width: 42,
height: 42,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
heroCopy: {
flex: 1,
gap: 4,
},
heroTitle: {
fontSize: 18,
fontWeight: '800',
},
heroSubtitle: {
fontSize: 13,
lineHeight: 18,
},
heroStats: {
flexDirection: 'row',
gap: 12,
},
heroStat: {
flex: 1,
borderRadius: 18,
padding: 14,
gap: 6,
},
heroStatLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroStatValue: {
fontSize: 22,
fontWeight: '900',
letterSpacing: -0.5,
},
heroStatEmpty: {
fontSize: 13,
fontWeight: '600',
},
sectionHeader: {
gap: 4,
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
},
sectionSubtitle: {
fontSize: 13,
lineHeight: 18,
},
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
},
bottomSpacer: {
height: 60,
},
});
@@ -0,0 +1,65 @@
import React, { useState } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { ServicesStackParamList } from '../../../navigation/types';
import { ScreenShell, Card, Input, Button } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import { useTheme } from '../../../theme';
type Props = NativeStackScreenProps<ServicesStackParamList, 'SpecialInstructions'>;
export default function SpecialInstructionsScreen({ navigation }: Props) {
const theme = useTheme();
const toast = useToast();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = () => {
if (!title || !description) {
toast('Empty fields', 'error', 'Title and description are required.');
return;
}
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
toast('Request Logged', 'success', 'Your special instruction has been submitted to the branch.');
navigation.goBack();
}, 1500);
};
return (
<ScreenShell title="Special Instructions" subtitle="Submit custom service requests">
<Card variant="default" style={styles.card}>
<View style={styles.formGroup}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Request Title</Text>
<Input value={title} onChangeText={setTitle} placeholder="Short objective summary" />
</View>
<View style={styles.formGroup}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Description</Text>
<Input
value={description}
onChangeText={setDescription}
placeholder="Detailed instructions..."
multiline
/>
</View>
<Button
title="Submit Instruction"
onPress={handleSubmit}
loading={isLoading}
style={styles.submitBtn}
/>
</Card>
</ScreenShell>
);
}
const styles = StyleSheet.create({
card: { padding: 16, gap: 16 },
formGroup: { gap: 8 },
label: { fontSize: 13, fontWeight: '600' },
submitBtn: { marginTop: 16 },
});
@@ -0,0 +1,67 @@
import React, { useState } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { ServicesStackParamList } from '../../../navigation/types';
import { ScreenShell, Card, Input, Button } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import { useTheme } from '../../../theme';
type Props = NativeStackScreenProps<ServicesStackParamList, 'StopPayment'>;
export default function StopPaymentScreen({ navigation }: Props) {
const theme = useTheme();
const toast = useToast();
const [account, setAccount] = useState('0000XXXX1234');
const [chequeNumber, setChequeNumber] = useState('');
const [reason, setReason] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = () => {
if (!chequeNumber) {
toast('Cheque number required', 'error', 'Please enter a valid cheque number.');
return;
}
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
toast('Payment Stopped', 'success', `Cheque #${chequeNumber} has been blocked.`);
navigation.goBack();
}, 1500);
};
return (
<ScreenShell title="Stop Payment" subtitle="Block a specific cheque">
<Card variant="default" style={styles.card}>
<View style={styles.formGroup}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Select Account</Text>
<Input value={account} onChangeText={setAccount} placeholder="Account Number" />
</View>
<View style={styles.formGroup}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Cheque Number</Text>
<Input value={chequeNumber} onChangeText={setChequeNumber} placeholder="6-digit unique number" keyboardType="number-pad" />
</View>
<View style={styles.formGroup}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Reason (Optional)</Text>
<Input value={reason} onChangeText={setReason} placeholder="e.g. Lost in transit" />
</View>
<Button
title="Confirm Stop Payment"
onPress={handleSubmit}
loading={isLoading}
style={styles.submitBtn}
variant="danger"
/>
</Card>
</ScreenShell>
);
}
const styles = StyleSheet.create({
card: { padding: 16, gap: 16 },
formGroup: { gap: 8 },
label: { fontSize: 13, fontWeight: '600' },
submitBtn: { marginTop: 16 },
});
@@ -0,0 +1,24 @@
import type { Branch } from '../../../types/app';
import { demoBranches } from '../../../constants/mockData';
import { endpoints } from '../../../services/api/endpoints';
import { apiClient } from '../../../services/api/apiClient';
import { baseApi } from '../../../services/api/baseApi';
import { resolveWithMock } from '../../../services/api/serviceHelpers';
export const branchApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getBranches: builder.query<Branch[], void>({
queryFn: () =>
resolveWithMock({
mockData: demoBranches,
request: async () => {
const response = await apiClient.get<Branch[]>(endpoints.branches.list);
return response.data;
},
}),
providesTags: ['Branches'],
}),
}),
});
export const { useGetBranchesQuery } = branchApi;
@@ -0,0 +1,61 @@
import type { ServiceRequest, SupportMessage } from '../../../types/app';
import { demoServiceRequests, demoSupportMessages } from '../../../constants/mockData';
import { endpoints } from '../../../services/api/endpoints';
import { apiClient } from '../../../services/api/apiClient';
import { baseApi } from '../../../services/api/baseApi';
import { resolveWithMock } from '../../../services/api/serviceHelpers';
interface CreateServiceRequest {
category: string;
notes: string;
}
export const servicesApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getServiceRequests: builder.query<ServiceRequest[], void>({
queryFn: () =>
resolveWithMock({
mockData: demoServiceRequests,
request: async () => {
const response = await apiClient.get<ServiceRequest[]>(endpoints.services.requests);
return response.data;
},
}),
providesTags: ['Services'],
}),
submitServiceRequest: builder.mutation<ServiceRequest, CreateServiceRequest>({
queryFn: ({ category, notes }) =>
resolveWithMock({
mockData: () => ({
id: `SRV${Date.now()}`,
category: category as ServiceRequest['category'],
status: 'submitted',
createdAt: new Date().toISOString(),
notes,
}),
request: async () => {
const response = await apiClient.post<ServiceRequest>(endpoints.services.requests);
return response.data;
},
}),
invalidatesTags: ['Services'],
}),
getSupportChat: builder.query<SupportMessage[], void>({
queryFn: () =>
resolveWithMock({
mockData: demoSupportMessages,
request: async () => {
const response = await apiClient.get<SupportMessage[]>(endpoints.services.chat);
return response.data;
},
}),
providesTags: ['Services'],
}),
}),
});
export const {
useGetServiceRequestsQuery,
useGetSupportChatQuery,
useSubmitServiceRequestMutation,
} = servicesApi;
@@ -0,0 +1,5 @@
import type { RootState } from '../../../app/store';
export const selectServicesState = (state: RootState) => state.service;
export const selectServiceRequests = (state: RootState) => state.service.requests;
export const selectSupportMessages = (state: RootState) => state.service.supportMessages;
@@ -0,0 +1,59 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { AsyncStatus, ServiceCategory, ServiceRequest, SupportMessage } from '../../../types/app';
import { seedServiceRequests, seedSupportMessages } from './serviceThunk';
interface ServicesState {
status: AsyncStatus;
draftCategory: ServiceCategory;
draftNotes: string;
requests: ServiceRequest[];
supportMessages: SupportMessage[];
}
const initialState: ServicesState = {
status: 'idle',
draftCategory: 'Cheque Book',
draftNotes: '',
requests: [],
supportMessages: [],
};
const serviceSlice = createSlice({
name: 'services',
initialState,
reducers: {
setDraftCategory(state, action: PayloadAction<ServiceCategory>) {
state.draftCategory = action.payload;
},
setDraftNotes(state, action: PayloadAction<string>) {
state.draftNotes = action.payload;
},
addServiceRequest(state, action: PayloadAction<ServiceRequest>) {
state.requests.unshift(action.payload);
},
addSupportMessage(state, action: PayloadAction<SupportMessage>) {
state.supportMessages.push(action.payload);
},
resetServiceDraft(state) {
state.draftCategory = 'Cheque Book';
state.draftNotes = '';
},
},
extraReducers: (builder) => {
builder
.addCase(seedServiceRequests.pending, (state) => {
state.status = 'loading';
})
.addCase(seedServiceRequests.fulfilled, (state, action) => {
state.status = 'succeeded';
state.requests = action.payload;
})
.addCase(seedSupportMessages.fulfilled, (state, action) => {
state.supportMessages = action.payload;
});
},
});
export const serviceActions = serviceSlice.actions;
export const serviceReducer = serviceSlice.reducer;
@@ -0,0 +1,24 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import {
demoServiceRequests,
demoSupportMessages,
} from '../../../constants/mockData';
import type { ServiceRequest, SupportMessage } from '../../../types/app';
import { sleep } from '../../../utils/async';
export const seedServiceRequests = createAsyncThunk<ServiceRequest[]>(
'services/seedRequests',
async () => {
await sleep(140);
return demoServiceRequests;
},
);
export const seedSupportMessages = createAsyncThunk<SupportMessage[]>(
'services/seedSupportMessages',
async () => {
await sleep(120);
return demoSupportMessages;
},
);
+5
View File
@@ -0,0 +1,5 @@
export type {
ServiceCategory,
ServiceRequest,
SupportMessage,
} from '../../../types/app';
+64
View File
@@ -0,0 +1,64 @@
import { Linking, Platform } from 'react-native';
/**
* Calculate distance between two coordinates using the Haversine formula.
* Returns distance in kilometers.
*/
export const haversineDistance = (
lat1: number,
lng1: number,
lat2: number,
lng2: number,
): number => {
const R = 6371; // Earth's radius in km
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) *
Math.cos(toRad(lat2)) *
Math.sin(dLng / 2) *
Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
};
const toRad = (deg: number): number => deg * (Math.PI / 180);
/**
* Format a distance in km to a human-readable string.
*/
export const formatDistance = (km: number): string => {
if (km < 1) {
return `${Math.round(km * 1000)} m`;
}
if (km < 10) {
return `${km.toFixed(1)} km`;
}
return `${Math.round(km)} km`;
};
/**
* Open external navigation (Google Maps) to the given destination.
*/
export const openDirections = (lat: number, lng: number): void => {
const url = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
Linking.openURL(url).catch(() => {
// Fallback for devices without Google Maps
const fallback =
Platform.OS === 'ios'
? `http://maps.apple.com/?daddr=${lat},${lng}`
: url;
Linking.openURL(fallback);
});
};
/**
* Open the phone dialer with the given number.
*/
export const callBranch = (phone: string): void => {
const cleaned = phone.replace(/\s/g, '');
Linking.openURL(`tel:${cleaned}`);
};
@@ -0,0 +1,12 @@
import type { ServiceStatus } from '../../../types/app';
export const getServiceStatusTone = (status: ServiceStatus) => {
switch (status) {
case 'resolved':
return '#35D6A3';
case 'in-progress':
return '#59C6FF';
default:
return '#F6B26B';
}
};