Fix form keyboard issues and sticky footers in agent screens
This commit is contained in:
@@ -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);
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
ServiceCategory,
|
||||
ServiceRequest,
|
||||
SupportMessage,
|
||||
} from '../../../types/app';
|
||||
@@ -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';
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user