This commit is contained in:
Abin Toppo
2026-03-31 13:02:17 +05:30
parent 4af33c376f
commit cbad923b1a
44 changed files with 2282 additions and 1311 deletions
Binary file not shown.
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.circlecollect",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 24
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+17 -4
View File
@@ -39,6 +39,7 @@
"react-native-dialog": "^9.3.0", "react-native-dialog": "^9.3.0",
"react-native-elements": "^3.4.3", "react-native-elements": "^3.4.3",
"react-native-fast-confetti": "^1.1.1", "react-native-fast-confetti": "^1.1.1",
"react-native-gesture-handler": "^2.28.0",
"react-native-image-picker": "^8.2.1", "react-native-image-picker": "^8.2.1",
"react-native-inset-shadow": "^1.0.3", "react-native-inset-shadow": "^1.0.3",
"react-native-keychain": "^10.0.0", "react-native-keychain": "^10.0.0",
@@ -53,6 +54,7 @@
"react-native-svg": "^15.13.0", "react-native-svg": "^15.13.0",
"react-native-vector-icons": "^10.3.0", "react-native-vector-icons": "^10.3.0",
"react-native-walkthrough-tooltip": "^1.6.0", "react-native-walkthrough-tooltip": "^1.6.0",
"react-native-webview": "^13.16.1",
"react-native-worklets": "^0.6.0", "react-native-worklets": "^0.6.0",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"redux-persist": "^6.0.0" "redux-persist": "^6.0.0"
@@ -1806,7 +1808,6 @@
"node_modules/@egjs/hammerjs": { "node_modules/@egjs/hammerjs": {
"version": "2.0.17", "version": "2.0.17",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/hammerjs": "^2.0.36" "@types/hammerjs": "^2.0.36"
}, },
@@ -4453,8 +4454,7 @@
}, },
"node_modules/@types/hammerjs": { "node_modules/@types/hammerjs": {
"version": "2.0.46", "version": "2.0.46",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@types/istanbul-lib-coverage": { "node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6", "version": "2.0.6",
@@ -12639,7 +12639,6 @@
"node_modules/react-native-gesture-handler": { "node_modules/react-native-gesture-handler": {
"version": "2.28.0", "version": "2.28.0",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@egjs/hammerjs": "^2.0.17", "@egjs/hammerjs": "^2.0.17",
"hoist-non-react-statics": "^3.3.0", "hoist-non-react-statics": "^3.3.0",
@@ -12896,6 +12895,20 @@
"@types/react": ">=16.8.24" "@types/react": ">=16.8.24"
} }
}, },
"node_modules/react-native-webview": {
"version": "13.16.1",
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz",
"integrity": "sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^4.0.0",
"invariant": "2.2.4"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-worklets": { "node_modules/react-native-worklets": {
"version": "0.6.0", "version": "0.6.0",
"license": "MIT", "license": "MIT",
+1
View File
@@ -56,6 +56,7 @@
"react-native-svg": "^15.13.0", "react-native-svg": "^15.13.0",
"react-native-vector-icons": "^10.3.0", "react-native-vector-icons": "^10.3.0",
"react-native-walkthrough-tooltip": "^1.6.0", "react-native-walkthrough-tooltip": "^1.6.0",
"react-native-webview": "^13.16.1",
"react-native-worklets": "^0.6.0", "react-native-worklets": "^0.6.0",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"redux-persist": "^6.0.0" "redux-persist": "^6.0.0"
+24 -24
View File
@@ -1,12 +1,11 @@
import React, { FC } from 'react'; import React, { FC, useCallback, useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ToastAndroid } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { useNavigation } from '@react-navigation/native'; import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { responsiveFontSize } from 'react-native-responsive-dimensions'; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useTheme } from '../hooks/ui/useTheme'; import { useTheme } from '../hooks/ui/useTheme';
import { useFetch } from '../hooks/core/useFetch';
import { useCreate } from '../hooks/core/useCreate';
import { CONTRIBUTE } from '../utils/ApiUrls'; import { CONTRIBUTE } from '../utils/ApiUrls';
import FontsFamily from '../constents/FontsFamily'; import FontsFamily from '../constents/FontsFamily';
import { apiCall } from '../utils/ApiHelper';
interface JoinedGroup { interface JoinedGroup {
id: string; id: string;
@@ -22,7 +21,8 @@ interface JoinedGroup {
currentParticipants: number; currentParticipants: number;
contributionAmount: number; contributionAmount: number;
cycleType: 'MONTHLY' | 'WEEKLY' | 'QUARTERLY' | 'CUSTOM'; cycleType: 'MONTHLY' | 'WEEKLY' | 'QUARTERLY' | 'CUSTOM';
status: 'ACTIVE' | 'PENDING' | 'COMPLETED'| 'APPROVED'; status: 'ACTIVE' | 'PENDING' | 'COMPLETED' | 'APPROVED' | 'DELAY';
customCycleDays?: number;
} }
interface JoinedGroupCardProps { interface JoinedGroupCardProps {
@@ -42,24 +42,25 @@ const formatDate = (dateStr: string | null): string => {
return `${dayNum} ${months[monthIndex]} ${year}`; return `${dayNum} ${months[monthIndex]} ${year}`;
}; };
export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item }:any) => { export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item }: any) => {
const theme = useTheme(); const theme = useTheme();
const navigation = useNavigation<any>(); const navigation = useNavigation<any>();
console.log("item",item) const [payable, setPayable] = useState<any>(null);
const { data: payableRes } = useFetch<any>(`${CONTRIBUTE}${item.id}/calculate-contribution`); const fetchPayable = useCallback(async () => {
const payable = payableRes?.data; try {
const res = await apiCall(`${CONTRIBUTE}${item.id}/calculate-contribution`, 'GET') as any;
const { mutate: contribute, isPending: loading } = useCreate<any, any>( setPayable(res?.data || null);
`payments/contribute/${item?.id}?amount=${payable?.totalPayable}`, } catch (error) {
{ setPayable(null);
onSuccess: () => { console.log(`Failed to fetch fresh payable details for group ${item.id}`, error);
ToastAndroid.show('Payment successful!', ToastAndroid.SHORT);
},
onError: () => {
ToastAndroid.show('Payment failed. Please try again.', ToastAndroid.SHORT);
},
} }
}, [item.id]);
useFocusEffect(
useCallback(() => {
fetchPayable();
}, [fetchPayable])
); );
const getStatusColors = (status: JoinedGroup['status']) => { const getStatusColors = (status: JoinedGroup['status']) => {
@@ -103,7 +104,7 @@ export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item }:any) => {
</View> </View>
<View style={styles.infoItem}> <View style={styles.infoItem}>
<Text style={[styles.infoLabel, { color: theme.colors.textMuted }]}>Duration</Text> <Text style={[styles.infoLabel, { color: theme.colors.textMuted }]}>Duration</Text>
<Text style={[styles.infoValue, { color: theme.colors.textPrimary }]}>{ item?.cycleType === 'CUSTOM' ? `${item?.cycleType} (${item?.customCycleDays} days)` : item?.cycleType}</Text> <Text style={[styles.infoValue, { color: theme.colors.textPrimary }]}>{item?.cycleType === 'CUSTOM' ? `${item?.cycleType} (${item?.customCycleDays} days)` : item?.cycleType}</Text>
</View> </View>
</View> </View>
@@ -145,7 +146,7 @@ export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item }:any) => {
<Text style={[styles.paidText, { color: theme.colors.success }]}>✓ PAID</Text> <Text style={[styles.paidText, { color: theme.colors.success }]}>✓ PAID</Text>
</View> </View>
) : ( ) : (
(item.status !== 'COMPLETED' || item?.status === "ACTIVE" || item?.status === "DELAY") && ( (item?.status === "ACTIVE" || item?.status === "DELAY") && (
<TouchableOpacity <TouchableOpacity
style={[styles.payButton, { backgroundColor: theme.colors.primary }]} style={[styles.payButton, { backgroundColor: theme.colors.primary }]}
onPress={() => { onPress={() => {
@@ -154,10 +155,9 @@ export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item }:any) => {
routeFrom: 'GroupDetails' routeFrom: 'GroupDetails'
}) })
}} }}
disabled={loading}
> >
<Text style={[styles.payButtonText, { color: theme.colors.onPrimary }]}> <Text style={[styles.payButtonText, { color: theme.colors.onPrimary }]}>
{loading ? '...' : 'Pay'} Pay
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
) )
+3 -3
View File
@@ -27,7 +27,7 @@ const getStatusStyle = (status: UserJoinRequest["status"], theme: ReturnType<typ
label: "Pending", label: "Pending",
icon: "time-outline", icon: "time-outline",
}; };
case "REJECT": case "REJECTED":
return { return {
backgroundColor: theme.colors.error + "22", backgroundColor: theme.colors.error + "22",
textColor: theme.colors.error, textColor: theme.colors.error,
@@ -76,11 +76,11 @@ export const UserRequested: FC<UserRequestedProps> = ({ item }) => {
{" "}Group Created By:{" "} {" "}Group Created By:{" "}
<Text style={[styles.userName, { color: theme.colors.textPrimary }]} numberOfLines={1}>{item.createdBy}</Text> <Text style={[styles.userName, { color: theme.colors.textPrimary }]} numberOfLines={1}>{item.createdBy}</Text>
</Text> </Text>
{item.reason ? ( {/* {item.reason ? (
<Text style={[styles.reasonText, { color: theme.colors.textMuted }]} numberOfLines={2}> <Text style={[styles.reasonText, { color: theme.colors.textMuted }]} numberOfLines={2}>
<LucideIcon name="chatbubbles-outline" size={13} color={theme.colors.textMuted} /> {item.reason} <LucideIcon name="chatbubbles-outline" size={13} color={theme.colors.textMuted} /> {item.reason}
</Text> </Text>
) : null} ) : null} */}
</View> </View>
</View> </View>
); );
+2 -1
View File
@@ -3,6 +3,7 @@ import { StyleSheet, View, Text, TextInput, TouchableOpacity, TextInputProps } f
import LucideIcon, { LucideIconName } from './LucideIcon'; import LucideIcon, { LucideIconName } from './LucideIcon';
import { responsiveFontSize } from 'react-native-responsive-dimensions'; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useTheme } from '../../hooks/ui/useTheme'; import { useTheme } from '../../hooks/ui/useTheme';
import { ActivityIndicator } from 'react-native-paper';
// --- Custom Input --- // --- Custom Input ---
type CustomInputProps = TextInputProps & { type CustomInputProps = TextInputProps & {
@@ -143,7 +144,7 @@ const CustomButton: React.FC<CustomButtonProps> = ({
<LucideIcon name={icon} size={20} color={textColor} style={{ marginRight: 8 }} /> <LucideIcon name={icon} size={20} color={textColor} style={{ marginRight: 8 }} />
)} )}
<Text style={[styles.buttonText, { color: textColor }]}> <Text style={[styles.buttonText, { color: textColor }]}>
{loading ? 'Processing...' : title} {loading ?<ActivityIndicator color={textColor} /> : title}
</Text> </Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
+14 -1
View File
@@ -25,6 +25,7 @@ import ManagementScreen from "../screens/management/ManagementScreen";
import { navigationRef } from "../utils/navigationService"; import { navigationRef } from "../utils/navigationService";
import NotificationRedirection from "../screens/NotificationRedirection"; import NotificationRedirection from "../screens/NotificationRedirection";
import PaymentModeScreen from "../screens/payment/PaymentModeScreen"; import PaymentModeScreen from "../screens/payment/PaymentModeScreen";
import PaymentGatewayWebViewScreen from "../screens/payment/PaymentGatewayWebViewScreen";
// import screen from "../components/test"; // import screen from "../components/test";
export type RootStackParamList = { export type RootStackParamList = {
@@ -48,7 +49,18 @@ export type RootStackParamList = {
TransactionList: undefined TransactionList: undefined
RandomSelectUserScreen: { groupId: string | number, cycleNumber: number }; RandomSelectUserScreen: { groupId: string | number, cycleNumber: number };
Notification: undefined; Notification: undefined;
PaymentMode: { groupId: string | number; amount: number; payableData?: any }; PaymentMode: {
groupId?: string | number;
groupIds?: Array<string | number>;
amount?: number;
routeFrom?: string;
routeData?: any;
payableData?: any;
};
PaymentGatewayWebView: {
paymentUrl: string;
paymentData?: any;
};
NotificationRedirection: { data: any }; NotificationRedirection: { data: any };
Management: Management:
| { actionTab: "My Groups" } | { actionTab: "My Groups" }
@@ -122,6 +134,7 @@ export default function AppNavigator() {
<Stack.Screen name="Management" component={ManagementScreen} options={{ headerShown: false }} /> <Stack.Screen name="Management" component={ManagementScreen} options={{ headerShown: false }} />
<Stack.Screen name="NotificationRedirection" component={NotificationRedirection} options={{ headerShown: false }} /> <Stack.Screen name="NotificationRedirection" component={NotificationRedirection} options={{ headerShown: false }} />
<Stack.Screen name="PaymentMode" component={PaymentModeScreen} options={{ headerShown: false }} /> <Stack.Screen name="PaymentMode" component={PaymentModeScreen} options={{ headerShown: false }} />
<Stack.Screen name="PaymentGatewayWebView" component={PaymentGatewayWebViewScreen} options={{ headerShown: false }} />
</Stack.Navigator> </Stack.Navigator>
</NavigationContainer> </NavigationContainer>
); );
+12 -1
View File
@@ -1,6 +1,5 @@
// navigation/types.ts // navigation/types.ts
import { NavigatorScreenParams } from '@react-navigation/native';
import { StackScreenProps } from '@react-navigation/stack'; import { StackScreenProps } from '@react-navigation/stack';
// Type for the parameters passed to each screen in the Bottom Tab Navigator // Type for the parameters passed to each screen in the Bottom Tab Navigator
@@ -32,6 +31,18 @@ export type RootStackParamList = {
ManageJoinedGroup: { groupId: string | number }; ManageJoinedGroup: { groupId: string | number };
Invite: { groupId: string | number; code: string | number }; Invite: { groupId: string | number; code: string | number };
ManagementTabs:any ManagementTabs:any
PaymentMode: {
groupId?: string | number;
groupIds?: Array<string | number>;
amount?: number;
routeFrom?: string;
routeData?: any;
payableData?: any;
};
PaymentGatewayWebView: {
paymentUrl: string;
paymentData?: any;
};
NotificationRedirection: { data: any }; NotificationRedirection: { data: any };
}; };
+46 -6
View File
@@ -5,7 +5,6 @@ import {
StyleSheet, StyleSheet,
FlatList, FlatList,
TouchableOpacity, TouchableOpacity,
Dimensions,
Image, Image,
RefreshControl, RefreshControl,
ToastAndroid, ToastAndroid,
@@ -25,8 +24,6 @@ import { CustomInput, CustomButton } from '../components/common/Custom';
import SkeletonLoader from '../components/common/SkeletonLoader'; import SkeletonLoader from '../components/common/SkeletonLoader';
import FontsFamily from '../constents/FontsFamily'; import FontsFamily from '../constents/FontsFamily';
const { width } = Dimensions.get('window');
// --- Types --- // --- Types ---
interface Group { interface Group {
id: string | number; id: string | number;
@@ -76,7 +73,7 @@ const GroupCard = ({
const isFull = item.currentParticipants >= item.maxParticipants; const isFull = item.currentParticipants >= item.maxParticipants;
const isPending = Boolean(item.joinRequestPending); const isPending = Boolean(item.joinRequestPending);
const canJoin = !isJoined && !isPending && !isFull; const canJoin = !isJoined && !isPending && !isFull;
console.log("item>>>>>>>>>>>>>>", item);
return ( return (
<View style={[ <View style={[
styles.card, styles.card,
@@ -100,6 +97,20 @@ const GroupCard = ({
<View style={[styles.cardDivider, { backgroundColor: theme.colors.border }]} /> <View style={[styles.cardDivider, { backgroundColor: theme.colors.border }]} />
<View style={[styles.contributionBanner, { backgroundColor: theme.colors.primarySoft }]}>
<View>
<Text style={[styles.contributionLabel, { color: theme.colors.textMuted }]}>
Contribution Amount
</Text>
<Text style={[styles.contributionValue, { color: theme.colors.primary }]}>
UGX {Number(item.contributionAmount || 0).toLocaleString('en-IN')}
</Text>
</View>
<View style={[styles.contributionIconWrap, { backgroundColor: theme.colors.primary + '18' }]}>
<LucideIcon name="cash-outline" size={18} color={theme.colors.primary} />
</View>
</View>
<View style={styles.cardMeta}> <View style={styles.cardMeta}>
<View style={styles.metaItem}> <View style={styles.metaItem}>
<LucideIcon name="people-outline" size={14} color={theme.colors.textMuted} /> <LucideIcon name="people-outline" size={14} color={theme.colors.textMuted} />
@@ -190,7 +201,7 @@ export default function GroupScreen() {
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
onRefresh(); onRefresh();
}, []) }, [onRefresh])
); );
const handleJoin = (groupId: string | number) => { const handleJoin = (groupId: string | number) => {
console.log("Hitting here"); console.log("Hitting here");
@@ -395,13 +406,42 @@ const styles = StyleSheet.create({
}, },
cardMeta: { cardMeta: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', flexWrap: 'wrap',
gap: 12,
marginBottom: 12, marginBottom: 12,
}, },
contributionBanner: {
borderRadius: 14,
paddingHorizontal: 14,
paddingVertical: 12,
marginBottom: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
contributionLabel: {
fontSize: responsiveFontSize(1.25),
fontFamily: FontsFamily.nunitoSemiBold,
marginBottom: 4,
textTransform: 'uppercase',
letterSpacing: 0.6,
},
contributionValue: {
fontSize: responsiveFontSize(2),
fontFamily: FontsFamily.nunitoExtraBold,
},
contributionIconWrap: {
width: 38,
height: 38,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
metaItem: { metaItem: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 4, gap: 4,
marginRight: 4,
}, },
metaText: { metaText: {
fontSize: responsiveFontSize(1.3), fontSize: responsiveFontSize(1.3),
+108 -61
View File
@@ -19,6 +19,7 @@ import { responsiveFontSize } from 'react-native-responsive-dimensions';
import LucideIcon from '../components/common/LucideIcon'; import LucideIcon from '../components/common/LucideIcon';
import * as Keychain from 'react-native-keychain'; import * as Keychain from 'react-native-keychain';
import FontsFamily from '../constents/FontsFamily'; import FontsFamily from '../constents/FontsFamily';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
// --- Type Definitions --- // --- Type Definitions ---
interface Participant { interface Participant {
@@ -56,6 +57,7 @@ type InviteScreenProps = NativeStackScreenProps<RootStackParamList, 'Invite'>;
const InviteScreen: React.FC<InviteScreenProps> = ({ route, navigation }) => { const InviteScreen: React.FC<InviteScreenProps> = ({ route, navigation }) => {
const { groupId, code } = route.params; const { groupId, code } = route.params;
const theme = useTheme(); const theme = useTheme();
const insets = useSafeAreaInsets();
useEffect(() => { useEffect(() => {
const checkAuth = async () => { const checkAuth = async () => {
@@ -81,9 +83,20 @@ const InviteScreen: React.FC<InviteScreenProps> = ({ route, navigation }) => {
); );
const groupDetails: GroupDetails | null = detailsRes?.data || null; const groupDetails: GroupDetails | null = detailsRes?.data || null;
const hasEntryFee = Number(groupDetails?.entryFee || 0) > 0;
const handleJoin = () => { const handleJoin = () => {
if (!groupDetails) return; if (!groupDetails) return;
if (hasEntryFee) {
navigation.navigate('PaymentMode' as never, {
groupId: groupDetails.id,
routeFrom: 'GroupScreen',
routeData: groupDetails,
} as never);
return;
}
joinGroup({}, { joinGroup({}, {
onSuccess: () => { onSuccess: () => {
ToastAndroid.show(`Join request sent for "${groupDetails.groupName}".`, ToastAndroid.SHORT); ToastAndroid.show(`Join request sent for "${groupDetails.groupName}".`, ToastAndroid.SHORT);
@@ -125,73 +138,95 @@ const InviteScreen: React.FC<InviteScreenProps> = ({ route, navigation }) => {
return ( return (
<AppView> <AppView>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}> <View style={styles.screen}>
<View style={styles.header}> <ScrollView
<View style={[styles.inviteBadge, { backgroundColor: theme.colors.primary }]}> contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 120 }]}
<LucideIcon name="mail-open-outline" size={24} color={theme.colors.onPrimary} /> showsVerticalScrollIndicator={false}
>
<View style={styles.header}>
<View style={[styles.inviteBadge, { backgroundColor: theme.colors.primary }]}>
<LucideIcon name="mail-open-outline" size={24} color={theme.colors.onPrimary} />
</View>
<Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}>You're Invited!</Text>
<Text style={[styles.headerSubtitle, { color: theme.colors.textSecondary }]}>
Join <Text style={{ fontFamily: FontsFamily.nunitoBold }}>{groupDetails.groupName}</Text> and start saving together.
</Text>
</View> </View>
<Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}>You're Invited!</Text>
<Text style={[styles.headerSubtitle, { color: theme.colors.textSecondary }]}>
Join <Text style={{ fontFamily: FontsFamily.nunitoBold }}>{groupDetails.groupName}</Text> and start saving together.
</Text>
</View>
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}> <View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<Text style={[styles.cardTitle, { color: theme.colors.textPrimary }]}>Circle Overview</Text> <Text style={[styles.cardTitle, { color: theme.colors.textPrimary }]}>Circle Overview</Text>
<InfoRow label="Organizer" value={organizerName} /> <InfoRow label="Organizer" value={organizerName} />
<InfoRow label="Contribution" value={`UGX${groupDetails.contributionAmount.toLocaleString('en-IN')}`} /> <InfoRow label="Contribution" value={`UGX${groupDetails.contributionAmount.toLocaleString('en-IN')}`} />
<InfoRow <InfoRow
label="Frequency" label="Frequency"
value={groupDetails.cycleType === 'CUSTOM' ? `${groupDetails.customCycleDays} Day(s)` : groupDetails.cycleType} value={groupDetails.cycleType === 'CUSTOM' ? `${groupDetails.customCycleDays} Day(s)` : groupDetails.cycleType}
/> />
<InfoRow label="Min/Max Participants" value={`${groupDetails.minParticipants} / ${groupDetails.maxParticipants}`} /> <InfoRow label="Min/Max Participants" value={`${groupDetails.minParticipants} / ${groupDetails.maxParticipants}`} />
<InfoRow label="Entry Fee" value={`UGX${groupDetails.entryFee}`} /> <InfoRow label="Entry Fee" value={`UGX${groupDetails.entryFee}`} />
<InfoRow <InfoRow
label="Late Payment Charge" label="Late Payment Charge"
value={`UGX${groupDetails.latePaymentCharge} (${groupDetails.lateFeeMode || 'DAILY'})`} value={`UGX${groupDetails.latePaymentCharge} (${groupDetails.lateFeeMode || 'DAILY'})`}
/> />
<InfoRow label="Withdrawal Method" value={groupDetails.withdrawalMethod || 'RANDOM'} /> <InfoRow label="Withdrawal Method" value={groupDetails.withdrawalMethod || 'RANDOM'} />
<InfoRow label="Status" value={groupDetails.status} /> <InfoRow label="Status" value={groupDetails.status} />
<InfoRow label="Invite Code" value={code} /> <InfoRow label="Invite Code" value={code} />
</View> </View>
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}> <View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<Text style={[styles.cardTitle, { color: theme.colors.textPrimary }]}> <Text style={[styles.cardTitle, { color: theme.colors.textPrimary }]}>
Members ({groupDetails.totalParticipants}/{groupDetails.maxParticipants}) Members ({groupDetails.totalParticipants}/{groupDetails.maxParticipants})
</Text> </Text>
{groupDetails.participants.length > 0 ? ( {groupDetails.participants.length > 0 ? (
groupDetails.participants.map((p) => ( groupDetails.participants.map((p) => (
<View key={p.id} style={[styles.participantRow, { borderBottomColor: theme.colors.background }]}> <View key={p.id} style={[styles.participantRow, { borderBottomColor: theme.colors.background }]}>
<View style={styles.participantInfo}> <View style={styles.participantInfo}>
<View style={[styles.avatar, { backgroundColor: theme.colors.background }]}> <View style={[styles.avatar, { backgroundColor: theme.colors.background }]}>
<Text style={[styles.avatarText, { color: theme.colors.textSecondary }]}> <Text style={[styles.avatarText, { color: theme.colors.textSecondary }]}>
{p.firstName.charAt(0)} {p.firstName.charAt(0)}
</Text>
</View>
<Text style={[styles.participantName, { color: theme.colors.textPrimary }]}>
{`${p.firstName} ${p.lastName || ''}`.trim()}
</Text> </Text>
</View> </View>
<Text style={[styles.participantName, { color: theme.colors.textPrimary }]}> {p.role === 'ORGANIZER' && (
{`${p.firstName} ${p.lastName || ''}`.trim()} <View style={[styles.organizerBadge, { backgroundColor: theme.colors.background }]}>
</Text> <Text style={[styles.organizerBadgeText, { color: theme.colors.textMuted }]}>Organizer</Text>
</View>
)}
</View> </View>
{p.role === 'ORGANIZER' && ( ))
<View style={[styles.organizerBadge, { backgroundColor: theme.colors.background }]}> ) : (
<Text style={[styles.organizerBadgeText, { color: theme.colors.textMuted }]}>Organizer</Text> <Text style={[styles.emptyText, { color: theme.colors.textSecondary }]}>Be the first to join!</Text>
</View> )}
)} </View>
</View> </ScrollView>
))
) : (
<Text style={[styles.emptyText, { color: theme.colors.textSecondary }]}>Be the first to join!</Text>
)}
</View>
<CustomButton <View
title={isJoinable ? 'Accept Invitation' : `Circle is ${groupDetails.status}`} style={[
onPress={handleJoin} styles.floatingButtonContainer,
loading={isJoining} {
disabled={!isJoinable || isJoining} backgroundColor: theme.colors.background,
style={styles.joinButton} borderTopColor: theme.colors.border,
/> paddingBottom: Math.max(insets.bottom, 12),
</ScrollView> }
]}
>
<CustomButton
title={
isJoinable
? hasEntryFee
? 'Pay & Accept Invitation'
: 'Accept Invitation'
: `Circle is ${groupDetails.status}`
}
onPress={handleJoin}
loading={isJoining}
disabled={!isJoinable || isJoining}
style={styles.joinButton}
/>
</View>
</View>
</AppView> </AppView>
); );
}; };
@@ -207,6 +242,9 @@ const InfoRow = ({ label, value }: { label: string; value: string | number }) =>
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
screen: {
flex: 1,
},
scrollContent: { scrollContent: {
paddingBottom: 40, paddingBottom: 40,
}, },
@@ -308,8 +346,17 @@ const styles = StyleSheet.create({
fontSize: responsiveFontSize(1.6), fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoMedium, fontFamily: FontsFamily.nunitoMedium,
}, },
floatingButtonContainer: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingTop: 12,
borderTopWidth: 1,
},
joinButton: { joinButton: {
marginTop: 16, marginTop: 0,
marginBottom: 0,
}, },
errorContainer: { errorContainer: {
flex: 1, flex: 1,
+33 -15
View File
@@ -16,6 +16,7 @@ import * as Keychain from 'react-native-keychain';
import { RootStackParamList } from '../navigation/AppNavigator'; import { RootStackParamList } from '../navigation/AppNavigator';
import { useTheme } from '../hooks/ui/useTheme'; import { useTheme } from '../hooks/ui/useTheme';
import { useCreate } from '../hooks/core/useCreate'; import { useCreate } from '../hooks/core/useCreate';
import { useFetch } from '../hooks/core/useFetch';
import { LOGIN_URL, VARIFY_OTP_URL } from '../utils/ApiUrls'; import { LOGIN_URL, VARIFY_OTP_URL } from '../utils/ApiUrls';
import { getFcmToken } from '../utils/firebase/firebaseServices'; import { getFcmToken } from '../utils/firebase/firebaseServices';
import { CustomButton } from '../components/common/Custom'; import { CustomButton } from '../components/common/Custom';
@@ -57,7 +58,7 @@ export default function OtpVerificationScreen() {
}, 1000); }, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, [fadeAnim, slideAnim]);
const { mutate: verifyOtp, isPending: verifying } = useCreate<any, any>(VARIFY_OTP_URL, { const { mutate: verifyOtp, isPending: verifying } = useCreate<any, any>(VARIFY_OTP_URL, {
onSuccess: async (res) => { onSuccess: async (res) => {
@@ -84,17 +85,31 @@ export default function OtpVerificationScreen() {
}, },
}); });
const { mutate: resendOtp, isPending: resending } = useCreate<any, any>(LOGIN_URL, { const { refetch: resendOtp, isFetching: resending } = useFetch<any>(
onSuccess: () => { LOGIN_URL + mobile,
setTimer(30); {},
setCode(Array(CELL_COUNT).fill('')); {
inputs.current[0]?.focus(); enabled: false,
ToastAndroid.show('OTP Resent', ToastAndroid.SHORT); retry: false,
}, }
onError: () => { );
ToastAndroid.show('Failed to resend OTP', ToastAndroid.SHORT);
}, const handleResendOtp = async () => {
}); try {
const { data } = await resendOtp();
if (data?.statusCode === 200) {
setTimer(30);
setCode(Array(CELL_COUNT).fill(''));
inputs.current[0]?.focus();
ToastAndroid.show('OTP Resent', ToastAndroid.SHORT);
} else {
ToastAndroid.show(data?.message || 'Failed to send OTP. Please try again.', ToastAndroid.SHORT);
}
} catch {
ToastAndroid.show('Something went wrong. Please check your connection.', ToastAndroid.SHORT);
}
};
const handleTextChange = (text: string, index: number) => { const handleTextChange = (text: string, index: number) => {
// If text length > 1, it's likely a paste operation // If text length > 1, it's likely a paste operation
@@ -159,7 +174,7 @@ export default function OtpVerificationScreen() {
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Verification</Text> <Text style={[styles.title, { color: theme.colors.textPrimary }]}>Verification</Text>
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}> <Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
Enter the 6-digit code sent to {'\n'} Enter the 6-digit code sent to {'\n'}
<Text style={{ color: theme.colors.textPrimary, fontWeight: '700' }}>+91 {mobile}</Text> <Text style={[styles.emphasisText, { color: theme.colors.textPrimary }]}>{mobile}</Text>
</Text> </Text>
</View> </View>
@@ -200,10 +215,10 @@ export default function OtpVerificationScreen() {
<View style={styles.resendContainer}> <View style={styles.resendContainer}>
{timer > 0 ? ( {timer > 0 ? (
<Text style={[styles.timerText, { color: theme.colors.textMuted }]}> <Text style={[styles.timerText, { color: theme.colors.textMuted }]}>
Resend code in <Text style={{ fontWeight: '700' }}>00:{timer.toString().padStart(2, '0')}</Text> Resend code in <Text style={styles.emphasisText}>00:{timer.toString().padStart(2, '0')}</Text>
</Text> </Text>
) : ( ) : (
<TouchableOpacity onPress={() => resendOtp({ params: { mobile } })} disabled={resending}> <TouchableOpacity onPress={handleResendOtp} disabled={resending}>
<Text style={[styles.resendText, { color: theme.colors.primary }]}> <Text style={[styles.resendText, { color: theme.colors.primary }]}>
{resending ? 'Sending...' : 'Resend Code'} {resending ? 'Sending...' : 'Resend Code'}
</Text> </Text>
@@ -276,4 +291,7 @@ const styles = StyleSheet.create({
fontFamily: FontsFamily.nunitoBold, fontFamily: FontsFamily.nunitoBold,
textDecorationLine: 'underline', textDecorationLine: 'underline',
}, },
emphasisText: {
fontWeight: '700',
},
}); });
+2 -1
View File
@@ -8,8 +8,9 @@ import { RootStackParamList } from '../navigation/AppNavigator';
import { PROFILE_URL } from '../utils/ApiUrls'; import { PROFILE_URL } from '../utils/ApiUrls';
import { setUserData, clearUser } from '../redux/userSlice'; import { setUserData, clearUser } from '../redux/userSlice';
import { useTheme } from '../hooks/ui/useTheme'; import { useTheme } from '../hooks/ui/useTheme';
import { useFetch } from '../hooks/core/useFetch';
import FontsFamily from '../constents/FontsFamily'; import FontsFamily from '../constents/FontsFamily';
import { useFetch } from '../hooks/core/useFetch';
const SplashScreen = () => { const SplashScreen = () => {
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>(); const navigation = useNavigation<StackNavigationProp<RootStackParamList>>();
+22 -26
View File
@@ -5,17 +5,13 @@ import {
View, View,
TouchableOpacity, TouchableOpacity,
Modal, Modal,
ToastAndroid,
Image, Image,
ScrollView, ScrollView,
ActivityIndicator,
} from 'react-native'; } from 'react-native';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import LucideIcon from '../../components/common/LucideIcon'; import LucideIcon from '../../components/common/LucideIcon';
import { responsiveFontSize } from 'react-native-responsive-dimensions'; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useTheme } from '../../hooks/ui/useTheme'; import { useTheme } from '../../hooks/ui/useTheme';
import { useCreate } from '../../hooks/core/useCreate';
import { PAY_ALL_URL } from '../../utils/ApiUrls';
import FontsFamily from '../../constents/FontsFamily'; import FontsFamily from '../../constents/FontsFamily';
export type StatusData = { export type StatusData = {
@@ -70,20 +66,6 @@ const UserProfileStatus: React.FC<UserProfileStatusProps> = ({
} }
}, [statusData]); }, [statusData]);
const { mutate: payAll, isPending: submitting } = useCreate<any, any>(PAY_ALL_URL, {
onSuccess: (res) => {
if (res?.status) {
ToastAndroid.show('Payment initiated successfully', ToastAndroid.SHORT);
setShowPayDialog(false);
} else {
ToastAndroid.show(res?.message || 'Payment failed', ToastAndroid.SHORT);
}
},
onError: () => {
ToastAndroid.show('Something went wrong during payment', ToastAndroid.SHORT);
},
});
const infoItems = useMemo( const infoItems = useMemo(
() => [ () => [
{ icon: 'person-outline', label: 'Role', value: statusData?.role || '-' }, { icon: 'person-outline', label: 'Role', value: statusData?.role || '-' },
@@ -113,6 +95,24 @@ const UserProfileStatus: React.FC<UserProfileStatusProps> = ({
); );
}; };
const handleProceedToPayment = () => {
if (selectedGroupIds.length === 0) {
return;
}
setShowPayDialog(false);
navigation.navigate('PaymentMode', {
amount: totalDueSelected,
routeFrom: 'UserProfileStatus',
groupIds: selectedGroupIds,
routeData: {
currencyCode: 'UGX',
totalPayable: totalDueSelected,
groupCount: selectedGroupIds.length,
},
});
};
const renderDueBar = () => ( const renderDueBar = () => (
<View style={[styles.dueBar, { backgroundColor: theme.colors.surface, borderTopColor: theme.colors.border }]}> <View style={[styles.dueBar, { backgroundColor: theme.colors.surface, borderTopColor: theme.colors.border }]}>
<View> <View>
@@ -214,15 +214,11 @@ const UserProfileStatus: React.FC<UserProfileStatusProps> = ({
<Text style={[styles.totalValue, { color: theme.colors.textPrimary }]}>UGX{totalDueSelected}</Text> <Text style={[styles.totalValue, { color: theme.colors.textPrimary }]}>UGX{totalDueSelected}</Text>
</View> </View>
<TouchableOpacity <TouchableOpacity
style={[styles.confirmButton, { backgroundColor: theme.colors.primary, opacity: submitting ? 0.7 : 1 }]} style={[styles.confirmButton, { backgroundColor: theme.colors.primary }]}
onPress={() => payAll({ groupIds: selectedGroupIds })} onPress={handleProceedToPayment}
disabled={submitting || selectedGroupIds.length === 0} disabled={selectedGroupIds.length === 0}
> >
{submitting ? ( <Text style={[styles.confirmText, { color: theme.colors.onPrimary }]}>Choose Method</Text>
<ActivityIndicator color={theme.colors.onPrimary} />
) : (
<Text style={[styles.confirmText, { color: theme.colors.onPrimary }]}>Confirm Payment</Text>
)}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
+55 -36
View File
@@ -1,9 +1,8 @@
import React, { useState, useCallback } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
StyleSheet, StyleSheet,
Text, Text,
View, View,
FlatList,
TouchableOpacity, TouchableOpacity,
LayoutAnimation, LayoutAnimation,
UIManager, UIManager,
@@ -28,7 +27,7 @@ interface Contribution {
waivedAmount: number; waivedAmount: number;
totalAmount: number; totalAmount: number;
status: 'SUCCESS' | 'PENDING' | 'FAILED'; status: 'SUCCESS' | 'PENDING' | 'FAILED';
paidDate: [number, number, number]; paidDate: [number, number, number] | string | null;
} }
interface Withdrawal { interface Withdrawal {
@@ -37,13 +36,13 @@ interface Withdrawal {
baseAmount: number; baseAmount: number;
totalAmount: number; totalAmount: number;
status: 'SUCCESS' | 'PENDING' | 'FAILED'; status: 'SUCCESS' | 'PENDING' | 'FAILED';
paidDate: [number, number, number]; paidDate: [number, number, number] | string | null;
} }
interface Cycle { interface Cycle {
cycleNumber: number; cycleNumber: number;
cycleStart: [number, number, number]; cycleStart: [number, number, number] | string | null;
cycleEnd: [number, number, number]; cycleEnd: [number, number, number] | string | null;
expectedContribution: number; expectedContribution: number;
contributions: Contribution[]; contributions: Contribution[];
withdrawal: Withdrawal | null; withdrawal: Withdrawal | null;
@@ -87,11 +86,10 @@ const formatDate = (dateStr: string | null): string => {
}; };
const formatCurrency = (amount: number): string => { const formatCurrency = (amount: number): string => {
return new Intl.NumberFormat('en-IN', { return `UGX ${new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
minimumFractionDigits: 0, minimumFractionDigits: 0,
}).format(amount); maximumFractionDigits: 0,
}).format(amount || 0)}`;
}; };
@@ -132,24 +130,32 @@ const CycleItem = ({ item, isExpanded, onToggle }: { item: Cycle, isExpanded: bo
{/* Contributions List */} {/* Contributions List */}
<View style={styles.section}> <View style={styles.section}>
<Text style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}>Contributions ({item.paidParticipants}/{item.totalParticipants})</Text> <Text style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}>Contributions ({item.paidParticipants}/{item.totalParticipants})</Text>
{item.contributions.map(contribution => ( {item.contributions.length > 0 ? (
<View key={contribution.transactionId} style={[styles.contributionItem, { item.contributions.map(contribution => (
backgroundColor: contribution.status === 'SUCCESS' ? theme.colors.success + '15' : contribution.status === 'PENDING' ? theme.colors.warning + '15' : theme.colors.error + '15', <View key={contribution.transactionId} style={[styles.contributionItem, {
borderBottomColor: theme.colors.borderLight backgroundColor: contribution.status === 'SUCCESS' ? theme.colors.success + '15' : contribution.status === 'PENDING' ? theme.colors.warning + '15' : theme.colors.error + '15',
}]}> borderBottomColor: theme.colors.borderLight
<LucideIcon name="person-circle-outline" size={28} color={theme.colors.textSecondary} /> }]}>
<View style={{ flex: 1, marginLeft: 10 }}> <LucideIcon name="person-circle-outline" size={28} color={theme.colors.textSecondary} />
<Text style={[styles.contributorName, { color: theme.colors.textPrimary }]} numberOfLines={1} > {contribution.userName}</Text> <View style={{ flex: 1, marginLeft: 10 }}>
<Text style={[styles.contributionDate, { color: theme.colors.textMuted }]}>{formatDate(contribution.paidDate as any)}</Text> <Text style={[styles.contributorName, { color: theme.colors.textPrimary }]} numberOfLines={1}>{contribution.userName}</Text>
<Text style={{ <Text style={[styles.contributionDate, { color: theme.colors.textMuted }]}>{formatDate(contribution.paidDate as any)}</Text>
color: contribution.status === 'SUCCESS' ? theme.colors.success : contribution.status === 'PENDING' ? theme.colors.warning : theme.colors.error, <Text style={{
fontSize: responsiveFontSize(1.5), color: contribution.status === 'SUCCESS' ? theme.colors.success : contribution.status === 'PENDING' ? theme.colors.warning : theme.colors.error,
textTransform: 'capitalize' fontSize: responsiveFontSize(1.5),
}}>{contribution.status}</Text> textTransform: 'capitalize'
}}>{contribution.status}</Text>
</View>
<Text style={[styles.contributionAmount, { color: theme.colors.textPrimary }]}>{formatCurrency(contribution.totalAmount)}</Text>
</View> </View>
<Text style={[styles.contributionAmount, { color: theme.colors.textPrimary }]}>{formatCurrency(contribution.totalAmount)}</Text> ))
) : (
<View style={[styles.emptyState, { borderColor: theme.colors.borderLight, backgroundColor: theme.colors.background }]}>
<Text style={[styles.emptyStateText, { color: theme.colors.textMuted }]}>
No contributions recorded for this cycle yet.
</Text>
</View> </View>
))} )}
</View> </View>
</View> </View>
)} )}
@@ -163,13 +169,17 @@ const CycleItem = ({ item, isExpanded, onToggle }: { item: Cycle, isExpanded: bo
const CycleDashboard = ({ data }: { data: CycleDashboardData }) => { const CycleDashboard = ({ data }: { data: CycleDashboardData }) => {
const [expandedCycle, setExpandedCycle] = useState<number | null>(data.currentCycle); const [expandedCycle, setExpandedCycle] = useState<number | null>(data.currentCycle);
const theme = useTheme(); const theme = useTheme();
const activeCycleNumber = data.cycles?.[0]?.cycleNumber ?? data.currentCycle ?? null;
useEffect(() => {
setExpandedCycle(activeCycleNumber);
}, [activeCycleNumber]);
const handleToggle = (cycleNumber: number) => { const handleToggle = (cycleNumber: number) => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setExpandedCycle(expandedCycle === cycleNumber ? null : cycleNumber); setExpandedCycle(expandedCycle === cycleNumber ? null : cycleNumber);
}; };
console.log("expanded cycle : ", data)
const renderHeader = () => ( const renderHeader = () => (
<View style={styles.headerContainer}> <View style={styles.headerContainer}>
<Text style={[styles.groupName, { color: theme.colors.textPrimary }]}>{data.groupName}</Text> <Text style={[styles.groupName, { color: theme.colors.textPrimary }]}>{data.groupName}</Text>
@@ -184,26 +194,24 @@ const CycleDashboard = ({ data }: { data: CycleDashboardData }) => {
</View> </View>
<View style={styles.summaryBox}> <View style={styles.summaryBox}>
<Text style={[styles.summaryLabel, { color: theme.colors.textMuted }]}>Cycles</Text> <Text style={[styles.summaryLabel, { color: theme.colors.textMuted }]}>Cycles</Text>
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>{data.currentCycle}/{data.totalParticipants}</Text> <Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>{data.currentCycle}/{data.totalCycles}</Text>
</View> </View>
</View> </View>
</View> </View>
); );
return ( return (
<FlatList <View style={styles.container}>
data={data.cycles} {renderHeader()}
keyExtractor={(item) => item.cycleNumber.toString()} {data.cycles.map((item) => (
renderItem={({ item }) => (
<CycleItem <CycleItem
key={item.cycleNumber.toString()}
item={item} item={item}
isExpanded={expandedCycle === item.cycleNumber} isExpanded={expandedCycle === item.cycleNumber}
onToggle={() => handleToggle(item.cycleNumber)} onToggle={() => handleToggle(item.cycleNumber)}
/> />
)} ))}
ListHeaderComponent={renderHeader} </View>
contentContainerStyle={styles.container}
/>
); );
}; };
@@ -320,5 +328,16 @@ const styles = StyleSheet.create({
fontSize: responsiveFontSize(1.5), fontSize: responsiveFontSize(1.5),
fontWeight: '500', fontWeight: '500',
}, },
emptyState: {
borderWidth: 1,
borderStyle: 'dashed',
borderRadius: 10,
paddingVertical: 14,
paddingHorizontal: 12,
},
emptyStateText: {
fontSize: responsiveFontSize(1.4),
textAlign: 'center',
},
}); });
+337 -73
View File
@@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { import {
FlatList,
RefreshControl, RefreshControl,
StyleSheet, StyleSheet,
Text, Text,
@@ -8,8 +7,7 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
ScrollView, ScrollView,
Image, useWindowDimensions
Modal
} from "react-native"; } from "react-native";
import LinearGradient from "react-native-linear-gradient"; import LinearGradient from "react-native-linear-gradient";
@@ -25,77 +23,103 @@ import CycleDashboard from "./CycleDashboard";
import SkeletonLoader from "../../../components/common/SkeletonLoader"; import SkeletonLoader from "../../../components/common/SkeletonLoader";
import FontsFamily from "../../../constents/FontsFamily"; import FontsFamily from "../../../constents/FontsFamily";
import AppView from "../../../components/AppView"; import AppView from "../../../components/AppView";
// import CustomDropdown from "../../../components/CustomDropdown";
const formatCycleDateLabel = (dateValue?: string | null) => {
if (!dateValue || typeof dateValue !== "string") {
return "Date pending";
}
const parts = dateValue.split("-");
if (parts.length !== 3) {
return dateValue;
}
const [year, month, day] = parts;
const parsedDate = new Date(Number(year), Number(month) - 1, Number(day));
return parsedDate.toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
});
};
const GroupDetails = () => { const GroupDetails = () => {
const theme = useTheme(); const theme = useTheme();
const route = useRoute<any>(); const route = useRoute<any>();
const navigation = useNavigation<any>(); const navigation = useNavigation<any>();
const { width: windowWidth } = useWindowDimensions();
const groupId = route?.params?.item?.id || route?.params?.groupId;
const [memberData, setMemberData] = useState([]); const [memberData, setMemberData] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [openModal, setOpenModal] = useState(false); const [openModal, setOpenModal] = useState(false);
const [payable, setPayable] = useState<any>(); const [payable, setPayable] = useState<any>();
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [cycleDashboardData, setCycleDashboardData] = useState<any>([]); const [cycleDashboardData, setCycleDashboardData] = useState<any>(null);
const [selectedCycleNumber, setSelectedCycleNumber] = useState(null); const [selectedCycleNumber, setSelectedCycleNumber] = useState<number | null>(null);
const [cycleItems, setCycleItems] = useState([]);
const [selectedUser, setSelectedUser] = useState() as string | any; const [selectedUser, setSelectedUser] = useState() as string | any;
const group = { const group = {
id: route.params.item?.id, id: route?.params?.item?.id,
name: route.params.item?.groupName, name: route?.params?.item?.groupName,
collected: route.params.item?.contributionAmount, collected: route?.params?.item?.contributionAmount,
goal: route.params.item?.contributionAmount * route.params.item?.currentParticipants, goal: route?.params?.item?.contributionAmount * route?.params?.item?.currentParticipants,
description: route.params.item?.description, description: route?.params?.item?.description,
status: route.params.item?.status, status: route?.params?.item?.status,
members: memberData, members: memberData,
}; };
const getPaymentStatus = async () => { const getPaymentStatus = useCallback(async () => {
const response = await apiCall(`${BASE_URL}payments/group/${group.id || route?.params?.groupId}/payment/paid`, 'GET') as any; const response = await apiCall(`${BASE_URL}payments/group/${groupId}/payment/paid`, 'GET') as any;
if (response.status === 'success') { if (response.status === 'success') {
setSelectedUser(response.data); setSelectedUser(response.data);
} }
} }, [groupId]);
const getCycleDashboard = async () => { const getCycleDashboard = useCallback(async () => {
try { try {
const response = await apiCall(`${BASE_URL}payments/groups/${group.id || route?.params?.groupId}/cycle-dashboard`, "GET") as any; const response = await apiCall(`${BASE_URL}payments/groups/${groupId}/cycle-dashboard`, "GET") as any;
const responseData = response.data; const responseData = response.data;
setCycleDashboardData(responseData || []); setCycleDashboardData(responseData || null);
if (responseData?.cycles) { if (responseData?.cycles) {
const items = responseData.cycles.map((cycle: any) => ({ setSelectedCycleNumber((previousCycleNumber) => {
label: `Cycle ${cycle.cycleNumber}`, const hasPreviousSelection = responseData.cycles.some(
value: cycle.cycleNumber (cycle: any) => cycle.cycleNumber === previousCycleNumber
})); );
setCycleItems(items);
if (responseData.currentCycle) { if (hasPreviousSelection) {
setSelectedCycleNumber(responseData.currentCycle); return previousCycleNumber;
} }
if (responseData.currentCycle) {
return responseData.currentCycle;
}
return responseData.cycles[0]?.cycleNumber ?? null;
});
} }
} catch (error) { } catch (error) {
ToastAndroid.show("Failed to fetch cycle data", ToastAndroid.SHORT); ToastAndroid.show("Failed to fetch cycle data", ToastAndroid.SHORT);
} }
}; }, [groupId]);
const fetchGroupDetails = async () => { const fetchGroupDetails = useCallback(async () => {
try { try {
const res = await apiCall(`${GROUP_ALL_MEMBER}?groupId=${group.id || route?.params?.groupId}`, "GET") as any; const res = await apiCall(`${GROUP_ALL_MEMBER}?groupId=${groupId}`, "GET") as any;
setMemberData(res.data); setMemberData(res.data);
} catch (error: any) { } catch (error: any) {
ToastAndroid.show(error?.message || "Failed to fetch group details", ToastAndroid.SHORT); ToastAndroid.show(error?.message || "Failed to fetch group details", ToastAndroid.SHORT);
} }
}; }, [groupId]);
const handleContribution = async () => { const handleContribution = useCallback(async () => {
try { try {
const res = await apiCall(`${CONTRIBUTE}${group.id || route?.params?.groupId}/calculate-contribution`, 'GET') as any; const res = await apiCall(`${CONTRIBUTE}${groupId}/calculate-contribution`, 'GET') as any;
setPayable(res.data); setPayable(res.data);
} catch (error) { } catch (error) {
console.log("Error in handleContribution: ", error); console.log("Error in handleContribution: ", error);
} }
}; }, [groupId]);
const fetchAllData = useCallback(async () => { const fetchAllData = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -105,14 +129,14 @@ const GroupDetails = () => {
getCycleDashboard() getCycleDashboard()
]); ]);
setLoading(false); setLoading(false);
}, [group.id || route?.params?.groupId]); }, [fetchGroupDetails, getCycleDashboard, handleContribution]);
useEffect(() => { useEffect(() => {
if (group.id || route?.params?.groupId) { if (groupId) {
fetchAllData(); fetchAllData();
getPaymentStatus(); getPaymentStatus();
} }
}, [group.id || route?.params?.groupId, fetchAllData]); }, [fetchAllData, getPaymentStatus, groupId]);
const onRefresh = useCallback(async () => { const onRefresh = useCallback(async () => {
@@ -120,26 +144,19 @@ const GroupDetails = () => {
await fetchAllData(); await fetchAllData();
await getPaymentStatus(); await getPaymentStatus();
setRefreshing(false); setRefreshing(false);
}, [fetchAllData]); }, [fetchAllData, getPaymentStatus]);
const selectedCycleData =
const confirmPayment = async () => { cycleDashboardData?.cycles?.find((c: any) => c.cycleNumber === selectedCycleNumber)
try { || cycleDashboardData?.cycles?.[0];
const res = await apiCall(`${BASE_URL}payments/contribute/${group?.id}?amount=${payable?.totalPayable}`, 'POST') as any; const currentCycleNumber = cycleDashboardData?.currentCycle;
console.log('payment res ', res) const cycleCount = cycleDashboardData?.cycles?.length || 0;
if (res.status === 200 || res.status === 201 || res.statusCode === 200) { const shouldScrollCycles = cycleCount > 2;
ToastAndroid.show('Payment done!', ToastAndroid.SHORT); const cycleCardWidth = cycleCount <= 1
onRefresh(); ? windowWidth - 64
} else { : shouldScrollCycles
ToastAndroid.show(res?.data?.message || 'Payment failed!', ToastAndroid.SHORT); ? Math.max(156, Math.min(188, (windowWidth - 92) / 2))
} : (windowWidth - 76) / 2;
} catch (error) {
ToastAndroid.show("An error occurred during payment.", ToastAndroid.SHORT);
}
};
const selectedCycleData = cycleDashboardData?.cycles?.find((c: any) => c.cycleNumber === selectedCycleNumber);
const renderSkeletons = () => ( const renderSkeletons = () => (
<View style={styles.skeletonContainer}> <View style={styles.skeletonContainer}>
@@ -150,7 +167,7 @@ const GroupDetails = () => {
); );
return ( return (
<AppView paddingHorizontal={0} style={{ flex: 1, backgroundColor: theme.colors.background }}> <AppView paddingHorizontal={0} keyboardSafe={false} dismissKeyboardOnTap={false} style={{ flex: 1, backgroundColor: theme.colors.background }}>
{/* Standard Header */} {/* Standard Header */}
<View style={[styles.header, { backgroundColor: theme.colors.background }]}> <View style={[styles.header, { backgroundColor: theme.colors.background }]}>
<TouchableOpacity onPress={() => navigation.goBack()} style={[styles.iconButton, { backgroundColor: theme.colors.card }]}> <TouchableOpacity onPress={() => navigation.goBack()} style={[styles.iconButton, { backgroundColor: theme.colors.card }]}>
@@ -163,11 +180,14 @@ const GroupDetails = () => {
</View> </View>
<ScrollView <ScrollView
style={{ flex: 1 }}
refreshControl={ refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={[theme.colors.primary]} tintColor={theme.colors.onPrimary} /> <RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={[theme.colors.primary]} tintColor={theme.colors.onPrimary} />
} }
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
nestedScrollEnabled={true}
keyboardShouldPersistTaps="handled"
> >
{loading ? renderSkeletons() : ( {loading ? renderSkeletons() : (
<View style={styles.contentPadding}> <View style={styles.contentPadding}>
@@ -255,18 +275,156 @@ const GroupDetails = () => {
</View> </View>
{/* Cycle Timeline */} {/* Cycle Timeline */}
{cycleItems.length > 0 && ( {cycleDashboardData?.cycles?.length > 0 && (
<View style={styles.sectionContainer}> <View style={styles.sectionContainer}>
<Text style={[styles.sectionHeader, { color: theme.colors.textPrimary, fontFamily: FontsFamily.nunitoBold }]}>Cycle Statement</Text> <Text style={[styles.sectionHeader, { color: theme.colors.textPrimary, fontFamily: FontsFamily.nunitoBold }]}>Cycle Statement</Text>
{/* <CustomDropdown <View style={[styles.cycleSelectorShell, { backgroundColor: theme.colors.card, borderColor: theme.colors.borderLight }]}>
items={cycleItems} <View style={styles.cycleSelectorHeader}>
selectedValue={selectedCycleNumber} <View style={styles.cycleSelectorCopy}>
onValueChange={setSelectedCycleNumber} <Text style={[styles.cycleSelectorEyebrow, { color: theme.colors.textMuted, fontFamily: FontsFamily.nunitoSemiBold }]}>
placeholder="Select Statement Cycle" Statement Viewer
/> */} </Text>
<Text style={[styles.cycleSelectorTitle, { color: theme.colors.textPrimary, fontFamily: FontsFamily.nunitoBold }]}>
Viewing Cycle {selectedCycleData?.cycleNumber}
</Text>
<Text style={[styles.cycleSelectorHint, { color: theme.colors.textMuted, fontFamily: FontsFamily.nunitoRegular }]}>
Switch between past and current cycles to review collections.
</Text>
</View>
<View style={[styles.currentCycleBadge, { backgroundColor: theme.colors.primarySoft }]}>
<LucideIcon name="time-outline" size={14} color={theme.colors.primary} />
<Text style={[styles.currentCycleBadgeText, { color: theme.colors.primary, fontFamily: FontsFamily.nunitoBold }]}>
Current #{currentCycleNumber}
</Text>
</View>
</View>
<ScrollView
horizontal
scrollEnabled={shouldScrollCycles}
nestedScrollEnabled
directionalLockEnabled
showsHorizontalScrollIndicator={false}
contentContainerStyle={[
styles.cycleRailContent,
!shouldScrollCycles && styles.cycleRailStaticContent
]}
>
{cycleDashboardData?.cycles?.map((cycle: any) => {
const isSelected = cycle.cycleNumber === selectedCycleNumber;
const isCurrent = cycle.cycleNumber === currentCycleNumber;
const completedCount = cycle.paidParticipants || 0;
const participantCount = cycle.totalParticipants || 0;
return (
<TouchableOpacity
key={cycle.cycleNumber}
onPress={() => setSelectedCycleNumber(cycle.cycleNumber)}
activeOpacity={0.92}
style={[
styles.cycleSwitchCard,
{
width: cycleCardWidth,
backgroundColor: isSelected ? theme.colors.primary : theme.colors.background,
borderColor: isSelected ? theme.colors.primary : theme.colors.borderLight,
shadowColor: isSelected ? theme.colors.primary : "#000",
}
]}
>
<View style={styles.cycleSwitchTopRow}>
<View style={[
styles.cycleNumberBadge,
{ backgroundColor: isSelected ? "rgba(255,255,255,0.18)" : theme.colors.primarySoft }
]}>
<Text style={[
styles.cycleNumberBadgeText,
{
color: isSelected ? theme.colors.onPrimary : theme.colors.primary,
fontFamily: FontsFamily.nunitoExtraBold,
}
]}>
#{cycle.cycleNumber}
</Text>
</View>
{isCurrent && (
<View style={[
styles.cycleStatusPill,
{ backgroundColor: isSelected ? "rgba(255,255,255,0.18)" : theme.colors.success + "18" }
]}>
<Text style={[
styles.cycleStatusPillText,
{
color: isSelected ? theme.colors.onPrimary : theme.colors.success,
fontFamily: FontsFamily.nunitoBold,
}
]}>
Current
</Text>
</View>
)}
</View>
<Text style={[
styles.cycleSwitchTitle,
{
color: isSelected ? theme.colors.onPrimary : theme.colors.textPrimary,
fontFamily: FontsFamily.nunitoBold,
}
]}>
Cycle {cycle.cycleNumber}
</Text>
<Text style={[
styles.cycleSwitchDates,
{
color: isSelected ? theme.colors.onPrimary : theme.colors.textMuted,
fontFamily: FontsFamily.nunitoRegular,
}
]}>
{formatCycleDateLabel(cycle.cycleStart)} - {formatCycleDateLabel(cycle.cycleEnd)}
</Text>
<View style={styles.cycleSwitchFooter}>
<View style={styles.cycleSwitchFooterItem}>
<LucideIcon
name={isSelected ? "eye-outline" : "calendar-outline"}
size={14}
color={isSelected ? theme.colors.onPrimary : theme.colors.textMuted}
/>
<Text style={[
styles.cycleSwitchFooterText,
{
color: isSelected ? theme.colors.onPrimary : theme.colors.textMuted,
fontFamily: FontsFamily.nunitoSemiBold,
}
]}>
{isSelected ? "Showing" : "Open"}
</Text>
</View>
<Text style={[
styles.cycleSwitchFooterText,
{
color: isSelected ? theme.colors.onPrimary : theme.colors.textMuted,
fontFamily: FontsFamily.nunitoSemiBold,
}
]}>
{completedCount}/{participantCount} paid
</Text>
</View>
</TouchableOpacity>
);
})}
</ScrollView>
</View>
{selectedCycleData && cycleDashboardData && ( {selectedCycleData && cycleDashboardData && (
<View style={{ marginTop: 12 }}> <View style={{ marginTop: 12 }}>
<CycleDashboard data={{ ...cycleDashboardData, cycles: [selectedCycleData] }} /> <CycleDashboard
data={{
...cycleDashboardData,
currentCycle: selectedCycleData.cycleNumber,
cycles: [selectedCycleData]
}}
/>
</View> </View>
)} )}
</View> </View>
@@ -300,9 +458,7 @@ const GroupDetails = () => {
{member.role === "Organizer" ? "Group Admin" : "Member"} {member.role === "Organizer" ? "Group Admin" : "Member"}
</Text> </Text>
</View> </View>
<TouchableOpacity style={[styles.contactAction, { backgroundColor: theme.colors.background }]}>
<LucideIcon name="chatbubble-ellipses-outline" size={18} color={theme.colors.textMuted} />
</TouchableOpacity>
</View> </View>
))} ))}
</View> </View>
@@ -314,7 +470,7 @@ const GroupDetails = () => {
onPress={() => setOpenModal(true)} onPress={() => setOpenModal(true)}
style={[styles.payButton, { backgroundColor: theme.colors.primary }]} style={[styles.payButton, { backgroundColor: theme.colors.primary }]}
> >
<LucideIcon name="card" size={20} color="#fff" style={{ marginRight: 8 }} /> <LucideIcon name="card" size={20} color={theme.colors.onPrimary} style={{ marginRight: 8 }} />
<Text style={[styles.payButtonText, { color: theme.colors.onPrimary, fontFamily: FontsFamily.nunitoBold }]}> <Text style={[styles.payButtonText, { color: theme.colors.onPrimary, fontFamily: FontsFamily.nunitoBold }]}>
Pay Contribution Pay Contribution
</Text> </Text>
@@ -330,9 +486,10 @@ const GroupDetails = () => {
visible={openModal} visible={openModal}
onClose={() => setOpenModal(false)} onClose={() => setOpenModal(false)}
onConfirm={() => { onConfirm={() => {
setOpenModal(false); setOpenModal(false);
navigation.navigate('PaymentMode' as any, { navigation.navigate('PaymentMode' as any, {
groupId: group.id || route?.params?.groupId, groupId,
routeFrom: 'GroupDetails',
}); });
}} }}
/> />
@@ -365,7 +522,8 @@ const styles = StyleSheet.create({
fontSize: responsiveFontSize(2), fontSize: responsiveFontSize(2),
}, },
scrollContent: { scrollContent: {
paddingBottom: 100, flexGrow: 1,
paddingBottom: 40,
}, },
contentPadding: { contentPadding: {
paddingHorizontal: 16, paddingHorizontal: 16,
@@ -529,6 +687,112 @@ const styles = StyleSheet.create({
marginBottom: 12, marginBottom: 12,
paddingLeft: 4, paddingLeft: 4,
}, },
cycleSelectorShell: {
borderRadius: 22,
borderWidth: 1,
paddingVertical: 18,
paddingLeft: 16,
marginBottom: 4,
},
cycleSelectorHeader: {
paddingRight: 16,
marginBottom: 16,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
cycleSelectorCopy: {
flex: 1,
paddingRight: 12,
},
cycleSelectorEyebrow: {
fontSize: responsiveFontSize(1.25),
letterSpacing: 1,
textTransform: 'uppercase',
marginBottom: 6,
},
cycleSelectorTitle: {
fontSize: responsiveFontSize(2),
marginBottom: 4,
},
cycleSelectorHint: {
fontSize: responsiveFontSize(1.5),
},
currentCycleBadge: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 999,
flexDirection: 'row',
alignItems: 'center',
},
currentCycleBadgeText: {
fontSize: responsiveFontSize(1.35),
marginLeft: 6,
},
cycleRailContent: {
paddingRight: 16,
},
cycleRailStaticContent: {
width: '100%',
paddingRight: 0,
justifyContent: 'space-between',
},
cycleSwitchCard: {
minHeight: 142,
borderRadius: 20,
borderWidth: 1,
padding: 14,
marginRight: 12,
justifyContent: 'space-between',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.12,
shadowRadius: 14,
elevation: 4,
},
cycleSwitchTopRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 14,
},
cycleNumberBadge: {
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
},
cycleNumberBadgeText: {
fontSize: responsiveFontSize(1.45),
},
cycleStatusPill: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 999,
},
cycleStatusPillText: {
fontSize: responsiveFontSize(1.2),
},
cycleSwitchTitle: {
fontSize: responsiveFontSize(1.9),
marginBottom: 6,
},
cycleSwitchDates: {
fontSize: responsiveFontSize(1.35),
lineHeight: 18,
},
cycleSwitchFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 16,
},
cycleSwitchFooterItem: {
flexDirection: 'row',
alignItems: 'center',
},
cycleSwitchFooterText: {
fontSize: responsiveFontSize(1.3),
marginLeft: 6,
},
// Contacts List // Contacts List
contactsContainer: { contactsContainer: {
borderRadius: 20, borderRadius: 20,
+6 -6
View File
@@ -543,7 +543,7 @@ const EditGroupScreen: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
)} )}
{paymentStatus?.cycleStatus === 'READY_FOR_WITHDRAWAL' && paymentStatus?.selectedUser && ( {paymentStatus?.cycleStatus === 'READY_FOR_WITHDRAWAL' && paymentStatus?.selectedUser && (
<TouchableOpacity onPress={() => setSelectedUserModel(true)} style={styles.secondaryBtn}> <TouchableOpacity onPress={() => setSelectedUserModel(true)} style={styles.primaryBtn}>
<Text style={styles.secondaryBtnText}>View Winner</Text> <Text style={styles.secondaryBtnText}>View Winner</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
@@ -592,9 +592,9 @@ const EditGroupScreen: React.FC = () => {
<ActivityIndicator color="#fff" /> <ActivityIndicator color="#fff" />
) : ( ) : (
<> <>
<LucideIcon name="power" size={24} color={ theme.colors.reverseText} style={{ marginRight: 8 }} /> <LucideIcon name="power" size={24} color={theme.colors.reverseText} style={{ marginRight: 8 }} />
<Text style={styles.floatingBtnText}>Activate Group</Text> <Text style={styles.floatingBtnText}>Activate Group</Text>
{/* <LucideIcon name="arrow-forward" size={20} color={ theme.colors.reverseText} style={{ marginLeft: 8, opacity: 0.8 }} /> */} {/* <LucideIcon name="arrow-forward" size={20} color={ theme.colors.reverseText} style={{ marginLeft: 8, opacity: 0.8 }} /> */}
</> </>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -709,7 +709,7 @@ const getStyles = (theme: any) => StyleSheet.create({
// Participants // Participants
participantRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 }, participantRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
avatarPlaceholder: { width: 40, height: 40, borderRadius: 20, backgroundColor: '#6366F1', justifyContent: 'center', alignItems: 'center', marginRight: 12 }, avatarPlaceholder: { width: 40, height: 40, borderRadius: 10, backgroundColor: theme.colors.primary, justifyContent: 'center', alignItems: 'center', marginRight: 12 },
pName: { fontSize: 16, fontFamily: FontsFamily.nunitoBold, color: theme.colors.textPrimary }, pName: { fontSize: 16, fontFamily: FontsFamily.nunitoBold, color: theme.colors.textPrimary },
pRole: { fontSize: 12, color: theme.colors.textSecondary }, pRole: { fontSize: 12, color: theme.colors.textSecondary },
viewAllBtn: { paddingVertical: 8, alignItems: 'center', borderTopWidth: 1, borderTopColor: '#f0f0f0', marginTop: 8 }, viewAllBtn: { paddingVertical: 8, alignItems: 'center', borderTopWidth: 1, borderTopColor: '#f0f0f0', marginTop: 8 },
@@ -718,9 +718,9 @@ const getStyles = (theme: any) => StyleSheet.create({
// Actions // Actions
actionsContainer: { padding: 20 }, actionsContainer: { padding: 20 },
primaryBtn: { backgroundColor: theme.colors.primary, padding: 16, borderRadius: 12, alignItems: 'center', elevation: 2, marginBottom: 12 }, primaryBtn: { backgroundColor: theme.colors.primary, padding: 16, borderRadius: 12, alignItems: 'center', elevation: 2, marginBottom: 12 },
btnText: { color: theme.colors.white, fontSize: 16, fontFamily: FontsFamily.nunitoBold }, btnText: { color: theme.colors.reverseText, fontSize: 16, fontFamily: FontsFamily.nunitoBold },
secondaryBtn: { backgroundColor: '#f0f0f0', padding: 16, borderRadius: 12, alignItems: 'center' }, secondaryBtn: { backgroundColor: '#f0f0f0', padding: 16, borderRadius: 12, alignItems: 'center' },
secondaryBtnText: { color: theme.colors.textPrimary, fontSize: 16, fontFamily: FontsFamily.nunitoBold }, secondaryBtnText: { color: theme.colors.reverseText, fontSize: 16, fontFamily: FontsFamily.nunitoBold },
// Modal // Modal
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }, modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
+123 -51
View File
@@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import {
View, View,
Text, Text,
@@ -84,83 +84,139 @@ const ManagementScreen: React.FC = () => {
setActiveTab(routeTab); setActiveTab(routeTab);
}, [route.params?.actionTab]); }, [route.params?.actionTab]);
const myGroupsParams = useMemo(() => { const myGroupsParams = {
return { ...(myGroupStatusFilter && myGroupStatusFilter !== 'ALL'
...(myGroupStatusFilter && myGroupStatusFilter !== 'ALL' ? { status: myGroupStatusFilter }
? { status: myGroupStatusFilter } : {}),
: {}), page: 0,
page: 0, size: 10,
size: 10, };
};
}, [myGroupStatusFilter]);
const joinedGroupsParams = useMemo(() => { const joinedGroupsParams = {
return { ...(joinedGroupStatusFilter && joinedGroupStatusFilter !== 'ALL'
...(joinedGroupStatusFilter && joinedGroupStatusFilter !== 'ALL' ? { status: joinedGroupStatusFilter }
? { status: joinedGroupStatusFilter } : {}),
: {}), page: 0,
page: 0, size: 10,
size: 10, };
};
}, [joinedGroupStatusFilter]);
const requestsParams = useMemo(() => ({ page: 0, size: 10 }), []); const requestsParams = { page: 0, size: 10 };
const myRequestsParams = useMemo(() => ({ page: 0, size: 10 }), []); const myRequestsParams = { page: 0, size: 10 };
console.log('joinedGroupsParams:', joinedGroupsParams); const alwaysFreshFetchOptions = {
staleTime: 0,
gcTime: 0,
refetchOnMount: 'always' as const,
refetchOnReconnect: true,
};
// Data Fetching // Data Fetching
const { data: myGroupsRes, isLoading: myLoading, refetch: refetchMy } = useFetch<any>( const {
data: myGroupsRes,
isLoading: myLoading,
isFetching: myFetching,
refetch: refetchMy,
} = useFetch<any>(
MY_GROUPS_URL, MY_GROUPS_URL,
myGroupsParams, myGroupsParams,
{ enabled: user.role === 'ORGANIZER' } { enabled: user.role === 'ORGANIZER' && activeTab === 'My Groups', ...alwaysFreshFetchOptions }
); );
const { data: joinedRes, isLoading: joinedLoading, refetch: refetchJoined } = useFetch<any>( const {
data: joinedRes,
isLoading: joinedLoading,
isFetching: joinedFetching,
refetch: refetchJoined,
} = useFetch<any>(
GET_JOINED_GROUP_URL, GET_JOINED_GROUP_URL,
joinedGroupsParams joinedGroupsParams,
{ enabled: activeTab === 'Joined Groups', ...alwaysFreshFetchOptions }
); );
const { data: requestsRes, isLoading: requestsLoading, refetch: refetchRequests } = useFetch<any>( const {
data: requestsRes,
isLoading: requestsLoading,
isFetching: requestsFetching,
refetch: refetchRequests,
} = useFetch<any>(
REQUESTS_URL, REQUESTS_URL,
requestsParams, requestsParams,
{ enabled: user.role === 'ORGANIZER' } {
enabled: user.role === 'ORGANIZER' && activeTab === 'Requests' && requestSubTab === 'Join Requests',
...alwaysFreshFetchOptions,
}
); );
const { data: userReqRes, isLoading: userReqLoading, refetch: refetchUserReq } = useFetch<any>( const {
data: userReqRes,
isLoading: userReqLoading,
isFetching: userReqFetching,
refetch: refetchUserReq,
} = useFetch<any>(
USER_REQUEST, USER_REQUEST,
myRequestsParams myRequestsParams,
{
enabled:
activeTab === 'My Requests' ||
(user.role === 'ORGANIZER' && activeTab === 'Requests' && requestSubTab === 'My Requests'),
...alwaysFreshFetchOptions,
}
); );
const { mutate: updateRequest } = useUpdate<any, any>(''); const { mutate: updateRequest } = useUpdate<any, any>('');
const myGroups = useMemo(() => getListFromResponse<MyGroup>(myGroupsRes), [myGroupsRes]); const myGroups = getListFromResponse<MyGroup>(myGroupsRes);
const joinedGroups = useMemo(() => getListFromResponse<any>(joinedRes), [joinedRes]); const joinedGroups = getListFromResponse<any>(joinedRes);
const incomingRequests = useMemo(() => getListFromResponse<JoinRequest>(requestsRes), [requestsRes]); const incomingRequests = getListFromResponse<JoinRequest>(requestsRes);
const myRequests = useMemo(() => getListFromResponse<any>(userReqRes), [userReqRes]); const myRequests = getListFromResponse<any>(userReqRes);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(() => { const onRefresh = useCallback(async () => {
setRefreshing(true); setRefreshing(true);
Promise.all([ const refreshTasks = [refetchJoined(), refetchUserReq()];
refetchMy(),
refetchJoined(), if (user.role === 'ORGANIZER') {
refetchRequests(), refreshTasks.push(refetchMy(), refetchRequests());
refetchUserReq(), }
]).finally(() => {
await Promise.all(refreshTasks).finally(() => {
setRefreshing(false); setRefreshing(false);
}); });
}, [refetchMy, refetchJoined, refetchRequests, refetchUserReq]); }, [refetchMy, refetchJoined, refetchRequests, refetchUserReq, user.role]);
const refetchActiveTab = useCallback(async () => {
if (activeTab === 'My Groups' && user.role === 'ORGANIZER') {
await refetchMy();
return;
}
if (activeTab === 'Joined Groups') {
await refetchJoined();
return;
}
if (activeTab === 'Requests' && user.role === 'ORGANIZER') {
if (requestSubTab === 'Join Requests') {
await refetchRequests();
} else {
await refetchUserReq();
}
return;
}
if (activeTab === 'My Requests') {
await refetchUserReq();
}
}, [activeTab, requestSubTab, refetchMy, refetchJoined, refetchRequests, refetchUserReq, user.role]);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
onRefresh(); refetchActiveTab();
}, []) }, [refetchActiveTab])
); );
const tabs: ActiveTab[] = useMemo(() => const tabs: ActiveTab[] =
user.role === 'ORGANIZER' user.role === 'ORGANIZER'
? ['My Groups', 'Joined Groups', 'Requests'] ? ['My Groups', 'Joined Groups', 'Requests']
: ['Joined Groups', 'My Requests'], : ['Joined Groups', 'My Requests'];
[user.role]);
const handleApprove = (req: JoinRequest) => { const handleApprove = (req: JoinRequest) => {
updateRequest({ resource: `${APPROVE_REQUEST_URL}${req.requestId}/approve`, payload: {} } as any, { updateRequest({ resource: `${APPROVE_REQUEST_URL}${req.requestId}/approve`, payload: {} } as any, {
@@ -173,7 +229,7 @@ const ManagementScreen: React.FC = () => {
}; };
const handleDecline = (req: JoinRequest) => { const handleDecline = (req: JoinRequest) => {
updateRequest({ resource: `${DECLINE_REQUEST_URL}${req.requestId}/reject?reason=/`, payload: {} } as any, { updateRequest({ resource: `${DECLINE_REQUEST_URL}${req.requestId}/reject?reason=no Reason`, payload: {} } as any, {
onSuccess: () => { onSuccess: () => {
ToastAndroid.show('Request declined', ToastAndroid.SHORT); ToastAndroid.show('Request declined', ToastAndroid.SHORT);
refetchRequests(); refetchRequests();
@@ -203,7 +259,23 @@ const ManagementScreen: React.FC = () => {
} }
}; };
const isLoading = myLoading || joinedLoading || requestsLoading || userReqLoading; const isMyGroupsLoading = myLoading || myFetching;
const isJoinedGroupsLoading = joinedLoading || joinedFetching;
const isRequestsLoading = requestsLoading || requestsFetching;
const isUserRequestsLoading = userReqLoading || userReqFetching;
const isActiveTabLoading =
activeTab === 'My Groups'
? isMyGroupsLoading
: activeTab === 'Joined Groups'
? isJoinedGroupsLoading
: activeTab === 'Requests'
? requestSubTab === 'Join Requests'
? isRequestsLoading
: isUserRequestsLoading
: isUserRequestsLoading;
const listData = refreshing || isActiveTabLoading ? [] : getFilteredData();
return ( return (
<AppView paddingHorizontal={0}> <AppView paddingHorizontal={0}>
@@ -369,7 +441,7 @@ const ManagementScreen: React.FC = () => {
)} )}
<FlatList <FlatList
data={getFilteredData()} data={listData}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
keyExtractor={(item, index) => String(item.id || item.requestId || index)} keyExtractor={(item, index) => String(item.id || item.requestId || index)}
renderItem={({ item }) => { renderItem={({ item }) => {
@@ -393,7 +465,7 @@ const ManagementScreen: React.FC = () => {
/> />
} }
ListEmptyComponent={ ListEmptyComponent={
!isLoading ? ( !isActiveTabLoading && !refreshing ? (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<LucideIcon name="documents-outline" size={64} color={theme.colors.textMuted} /> <LucideIcon name="documents-outline" size={64} color={theme.colors.textMuted} />
<Text style={[styles.emptyText, { color: theme.colors.textSecondary }]}>Nothing to show here</Text> <Text style={[styles.emptyText, { color: theme.colors.textSecondary }]}>Nothing to show here</Text>
+145 -546
View File
@@ -13,18 +13,18 @@ import {
import LucideIcon from '../../components/common/LucideIcon'; import LucideIcon from '../../components/common/LucideIcon';
import { useNavigation, useRoute } from '@react-navigation/native'; import { useNavigation, useRoute } from '@react-navigation/native';
import { responsiveFontSize } from 'react-native-responsive-dimensions'; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import AppView from '../../components/AppView'; import AppView from '../../components/AppView';
import { useTheme } from '../../hooks/ui/useTheme'; import { useTheme } from '../../hooks/ui/useTheme';
import { useFetch } from '../../hooks/core/useFetch'; import { useFetch } from '../../hooks/core/useFetch';
import { useUpdate } from '../../hooks/core/useUpdate'; import { apiCall } from '../../utils/ApiHelper';
import { BASE_URL } from '../../utils/ApiUrls'; import { BASE_URL } from '../../utils/ApiUrls';
import FontsFamily from '../../constents/FontsFamily'; import FontsFamily from '../../constents/FontsFamily';
import SkeletonLoader from '../../components/common/SkeletonLoader'; import SkeletonLoader from '../../components/common/SkeletonLoader';
import Header from '../../components/common/Header';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
const NUM_COLUMNS = 3; const NUM_COLUMNS = 3;
const ITEM_SIZE = width / NUM_COLUMNS - 28; const ITEM_SIZE = width / NUM_COLUMNS - 24; // Increased size slightly
interface User { interface User {
id: string; id: string;
@@ -159,7 +159,7 @@ const RandomSelectUserScreen: React.FC = () => {
const itemAnims = useRef<Animated.Value[]>([]).current; const itemAnims = useRef<Animated.Value[]>([]).current;
const winnerScaleAnim = useRef(new Animated.Value(1)).current; const winnerScaleAnim = useRef(new Animated.Value(1)).current;
const winnerGlowAnim = useRef(new Animated.Value(0)).current; const winnerGlowAnim = useRef(new Animated.Value(0)).current;
const bannerSlideAnim = useRef(new Animated.Value(100)).current; const bannerSlideAnim = useRef(new Animated.Value(200)).current;
const bannerOpacity = useRef(new Animated.Value(0)).current; const bannerOpacity = useRef(new Animated.Value(0)).current;
const headerPulseAnim = useRef(new Animated.Value(1)).current; const headerPulseAnim = useRef(new Animated.Value(1)).current;
@@ -173,13 +173,10 @@ const RandomSelectUserScreen: React.FC = () => {
[itemAnims], [itemAnims],
); );
// --- DATA FETCHING ---
const { data: participantsRes, isLoading: isParticipantsLoading } = useFetch<any>( const { data: participantsRes, isLoading: isParticipantsLoading } = useFetch<any>(
`${BASE_URL}fund/group/all/member?groupId=${groupId}`, `${BASE_URL}fund/group/all/member?groupId=${groupId}`,
{}, {},
{ { enabled: !!groupId },
enabled: !!groupId,
},
); );
useEffect(() => { useEffect(() => {
@@ -194,35 +191,34 @@ const RandomSelectUserScreen: React.FC = () => {
} }
}, [participantsRes, ensureAnims]); }, [participantsRes, ensureAnims]);
// --- MUTATIONS --- const selectWinnerApi = async () => {
const { mutate: selectWinnerApi } = useUpdate<any, any>( try {
`${BASE_URL}fund/method/based/userSelect`, const res = await apiCall<any>(
{ `${BASE_URL}fund/method/based/userSelect`,
onSuccess: (res: any) => { 'POST',
if (res.status === 'success' && res.data?.selectedUser?.id) { { groupId },
const winnerData = { );
id: res.data.selectedUser.id, if (res?.status === 'success' && res?.data?.selectedUser?.id) {
name: `${res.data.selectedUser.firstName} ${res.data.selectedUser.lastName}`, const winnerData = {
}; id: res.data.selectedUser.id,
const winnerIndex = users.findIndex(u => u.id === winnerData.id); name: `${res.data.selectedUser.firstName} ${res.data.selectedUser.lastName}`,
};
if (winnerIndex !== -1) { const winnerIndex = users.findIndex(u => u.id === winnerData.id);
triggerWinnerReveal(winnerData, winnerIndex); if (winnerIndex !== -1) {
} else { triggerWinnerReveal(winnerData, winnerIndex);
ToastAndroid.show('Winner not found among participants', ToastAndroid.SHORT);
resetAnimation();
}
} else { } else {
ToastAndroid.show('Failed to determine a winner', ToastAndroid.SHORT); ToastAndroid.show('Winner lookup failed', ToastAndroid.SHORT);
resetAnimation(); resetAnimation();
} }
}, } else {
onError: (err: any) => { ToastAndroid.show('Failed to select winner', ToastAndroid.SHORT);
ToastAndroid.show(err?.message || 'Selection failed', ToastAndroid.SHORT);
resetAnimation(); resetAnimation();
}, }
}, } catch (err: any) {
); ToastAndroid.show(err?.message || 'Api Error', ToastAndroid.SHORT);
resetAnimation();
}
};
const resetAnimation = () => { const resetAnimation = () => {
setIsAnimating(false); setIsAnimating(false);
@@ -231,26 +227,17 @@ const RandomSelectUserScreen: React.FC = () => {
setShowWinnerReveal(false); setShowWinnerReveal(false);
winnerScaleAnim.setValue(1); winnerScaleAnim.setValue(1);
winnerGlowAnim.setValue(0); winnerGlowAnim.setValue(0);
bannerSlideAnim.setValue(100); bannerSlideAnim.setValue(200);
bannerOpacity.setValue(0); bannerOpacity.setValue(0);
itemAnims.forEach(a => a.setValue(0)); itemAnims.forEach(a => a.setValue(0));
}; };
// --- HEADER PULSE during animation ---
useEffect(() => { useEffect(() => {
if (phase === 'scanning' || phase === 'slowing') { if (phase === 'scanning' || phase === 'slowing') {
const pulse = Animated.loop( const pulse = Animated.loop(
Animated.sequence([ Animated.sequence([
Animated.timing(headerPulseAnim, { Animated.timing(headerPulseAnim, { toValue: 1.05, duration: 600, useNativeDriver: true }),
toValue: 1.05, Animated.timing(headerPulseAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
duration: 600,
useNativeDriver: true,
}),
Animated.timing(headerPulseAnim, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}),
]), ]),
); );
pulse.start(); pulse.start();
@@ -260,100 +247,61 @@ const RandomSelectUserScreen: React.FC = () => {
} }
}, [phase, headerPulseAnim]); }, [phase, headerPulseAnim]);
// --- WINNER REVEAL ANIMATION ---
const triggerWinnerReveal = (winnerData: { id: string; name: string }, winnerIdx: number) => { const triggerWinnerReveal = (winnerData: { id: string; name: string }, winnerIdx: number) => {
setPhase('reveal'); setPhase('reveal');
setWinner(winnerData); setWinner(winnerData);
setHighlightedIndex(winnerIdx); setHighlightedIndex(winnerIdx);
setShowWinnerReveal(true); setShowWinnerReveal(true);
// 1. Pop all items down except winner
const itemAnimations = itemAnims.map((anim, i) => { const itemAnimations = itemAnims.map((anim, i) => {
if (i === winnerIdx) { if (i === winnerIdx) {
return Animated.timing(anim, { return Animated.timing(anim, {
toValue: 2, // special "winner" state toValue: 2,
duration: 400, duration: 500,
easing: Easing.out(Easing.back(1.5)), easing: Easing.out(Easing.back(1.5)),
useNativeDriver: true, useNativeDriver: true,
}); });
} }
return Animated.timing(anim, { return Animated.timing(anim, {
toValue: -1, // "dim" state toValue: -1,
duration: 300, duration: 300,
useNativeDriver: true, useNativeDriver: true,
}); });
}); });
// 2. Winner scale + glow const revealGroup = Animated.parallel([
const winnerReveal = Animated.parallel([ Animated.spring(winnerScaleAnim, { toValue: 1.25, friction: 3, tension: 70, useNativeDriver: true }),
Animated.spring(winnerScaleAnim, { Animated.timing(winnerGlowAnim, { toValue: 1, duration: 800, useNativeDriver: true }),
toValue: 1.2, Animated.spring(bannerSlideAnim, { toValue: 0, friction: 6, tension: 50, useNativeDriver: true }),
friction: 4, Animated.timing(bannerOpacity, { toValue: 1, duration: 600, useNativeDriver: true }),
tension: 80,
useNativeDriver: true,
}),
Animated.timing(winnerGlowAnim, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}),
]);
// 3. Banner slide in
const bannerReveal = Animated.parallel([
Animated.spring(bannerSlideAnim, {
toValue: 0,
friction: 6,
tension: 60,
useNativeDriver: true,
}),
Animated.timing(bannerOpacity, {
toValue: 1,
duration: 500,
useNativeDriver: true,
}),
]); ]);
Animated.sequence([ Animated.sequence([
Animated.parallel(itemAnimations), Animated.parallel(itemAnimations),
Animated.delay(200), Animated.delay(100),
winnerReveal, revealGroup,
Animated.delay(300),
bannerReveal,
]).start(() => { ]).start(() => {
setTimeout(() => { setTimeout(() => {
navigation.replace('EditGroup', { groupId }); navigation.replace('EditGroup', { groupId });
}, 2500); }, 3500);
}); });
}; };
// --- MAIN SELECTION ANIMATION (3-phase spotlight roulette) ---
const handleStartSelection = () => { const handleStartSelection = () => {
const availableUsers = users.filter(u => !u.selected); const availableUsers = users.filter(u => !u.selected);
if (availableUsers.length === 0 || isAnimating) { if (availableUsers.length === 0 || isAnimating) {
ToastAndroid.show( ToastAndroid.show('All members selected already', ToastAndroid.SHORT);
'All users are already selected or selection in progress',
ToastAndroid.SHORT,
);
return; return;
} }
resetAnimation(); resetAnimation();
setWinner(null); setWinner(null);
setIsAnimating(true); setIsAnimating(true);
setPhase('scanning'); setPhase('scanning');
// Phase 1: Fast random scanning (accelerate then sustain) const totalPhase1 = 15;
let count = 0; const totalPhase2 = 12;
const totalPhase1 = 18;
const totalPhase2 = 15;
const runPhase = ( const runPhase = (phaseCount: number, getDelay: (i: number) => number, onComplete: () => void) => {
phaseCount: number,
getDelay: (i: number) => number,
onComplete: () => void,
) => {
let i = 0; let i = 0;
const step = () => { const step = () => {
if (i >= phaseCount) { if (i >= phaseCount) {
@@ -365,14 +313,9 @@ const RandomSelectUserScreen: React.FC = () => {
const origIdx = users.findIndex(u => u.id === user.id); const origIdx = users.findIndex(u => u.id === user.id);
setHighlightedIndex(origIdx); setHighlightedIndex(origIdx);
// Animate the highlighted item
ensureAnims(users.length); ensureAnims(users.length);
itemAnims.forEach((anim, idx) => { itemAnims.forEach((anim, idx) => {
Animated.timing(anim, { Animated.timing(anim, { toValue: idx === origIdx ? 1 : 0, duration: 60, useNativeDriver: true }).start();
toValue: idx === origIdx ? 1 : 0,
duration: 60,
useNativeDriver: true,
}).start();
}); });
i++; i++;
@@ -381,372 +324,152 @@ const RandomSelectUserScreen: React.FC = () => {
step(); step();
}; };
// Phase 1: Fast scanning — starts moderate, gets fast runPhase(totalPhase1, i => Math.max(50, 150 - i * 8), () => {
runPhase( setPhase('slowing');
totalPhase1, runPhase(totalPhase2, i => 100 + i * 50 + Math.pow(i, 1.5) * 10, () => {
i => Math.max(50, 150 - i * 6), selectWinnerApi();
() => { });
setPhase('slowing'); });
// Phase 2: Dramatic slowdown — each step takes longer
runPhase(
totalPhase2,
i => 100 + i * 40 + Math.pow(i, 1.6) * 8,
() => {
// Final api call to determine real winner
selectWinnerApi({ groupId });
},
);
},
);
}; };
// --- RENDER ITEM ---
const renderUserCard = (item: User, index: number) => { const renderUserCard = (item: User, index: number) => {
const isHighlighted = index === highlightedIndex; const isHighlighted = index === highlightedIndex;
const isWinnerCard = winner?.id === item.id && phase === 'reveal'; const isWinnerCard = winner?.id === item.id && phase === 'reveal';
ensureAnims(users.length);
const itemAnim = itemAnims[index] || new Animated.Value(0); const itemAnim = itemAnims[index] || new Animated.Value(0);
const isDark = theme.mode === 'dark'; const isDark = theme.mode === 'dark';
// Dynamic colors
const cardBg = isWinnerCard
? isDark ? '#1a3d2a' : '#e8f5ee'
: item.selected
? theme.colors.surface
: theme.colors.background;
const borderColor = isWinnerCard
? theme.colors.success
: isHighlighted
? isDark ? '#818CF8' : '#6366F1'
: theme.colors.borderLight;
const textColor = isWinnerCard
? theme.colors.success
: item.selected
? theme.colors.textMuted
: theme.colors.textPrimary;
const iconColor = isWinnerCard
? theme.colors.success
: isHighlighted
? isDark ? '#818CF8' : '#6366F1'
: item.selected
? theme.colors.textMuted
: theme.colors.textSecondary;
return ( return (
<Animated.View <Animated.View
key={item.id} key={item.id}
style={[ style={[
styles.gridItem, styles.gridItem,
{ {
backgroundColor: cardBg, backgroundColor: isWinnerCard
borderColor: borderColor, ? theme.colors.success + '10'
borderWidth: isHighlighted || isWinnerCard ? 2.5 : 1, : item.selected ? theme.colors.surface : theme.colors.card,
transform: [ borderColor: isWinnerCard
{ ? theme.colors.success
scale: isWinnerCard : isHighlighted ? theme.colors.primary : theme.colors.borderLight,
? winnerScaleAnim borderWidth: isHighlighted || isWinnerCard ? 2 : 1,
: itemAnim.interpolate({ transform: [{
inputRange: [-1, 0, 1], scale: isWinnerCard ? winnerScaleAnim : itemAnim.interpolate({
outputRange: [0.92, 1, 1.08], inputRange: [-1, 0, 1], outputRange: [0.94, 1, 1.1], extrapolate: 'clamp',
extrapolate: 'clamp', }),
}), }],
},
],
opacity: itemAnim.interpolate({ opacity: itemAnim.interpolate({
inputRange: [-1, 0, 1, 2], inputRange: [-1, 0, 1], outputRange: [0.35, 1, 1], extrapolate: 'clamp',
outputRange: [0.4, 1, 1, 1],
extrapolate: 'clamp',
}), }),
// Elevated shadow for highlighted / winner shadowColor: isWinnerCard ? theme.colors.success : theme.colors.primary,
...(isHighlighted && !isWinnerCard shadowOpacity: isHighlighted || isWinnerCard ? 0.3 : 0,
? { elevation: isHighlighted || isWinnerCard ? 10 : 0,
elevation: 12,
shadowColor: isDark ? '#818CF8' : '#6366F1',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.5,
shadowRadius: 16,
}
: isWinnerCard
? {
elevation: 16,
shadowColor: theme.colors.success,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.6,
shadowRadius: 20,
}
: {
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 4,
}),
}, },
]} ]}
> >
{/* Glow overlay for highlighted */}
{isHighlighted && !isWinnerCard && (
<View
style={[
StyleSheet.absoluteFill,
{
borderRadius: 16,
backgroundColor: isDark ? 'rgba(129, 140, 248, 0.08)' : 'rgba(99, 102, 241, 0.06)',
},
]}
/>
)}
{/* Winner pulse rings */}
{isWinnerCard && showWinnerReveal && ( {isWinnerCard && showWinnerReveal && (
<View style={[StyleSheet.absoluteFill, { alignItems: 'center', justifyContent: 'center' }]}> <View style={[StyleSheet.absoluteFill, { alignItems: 'center', justifyContent: 'center' }]}>
<PulseRing delay={0} color={theme.colors.success} size={ITEM_SIZE} /> <PulseRing delay={0} color={theme.colors.success} size={ITEM_SIZE} />
<PulseRing delay={250} color={theme.colors.success} size={ITEM_SIZE} /> <PulseRing delay={300} color={theme.colors.success} size={ITEM_SIZE} />
<PulseRing delay={500} color={theme.colors.success} size={ITEM_SIZE} /> {Array.from({ length: 10 }).map((_, i) => (
</View> <SparkleParticle key={i} delay={i * 80} startX={ITEM_SIZE / 2} startY={ITEM_SIZE / 2}
)} color={i % 2 === 0 ? theme.colors.success : theme.colors.warning} size={3 + Math.random() * 4} />
{/* Winner sparkle particles */}
{isWinnerCard && showWinnerReveal && (
<View style={[StyleSheet.absoluteFill, { alignItems: 'center', justifyContent: 'center' }]}>
{Array.from({ length: 12 }).map((_, i) => (
<SparkleParticle
key={`sparkle-${i}`}
delay={i * 60}
startX={ITEM_SIZE / 2 - 3}
startY={ITEM_SIZE / 2 - 3}
color={
i % 3 === 0
? theme.colors.success
: i % 3 === 1
? isDark ? '#818CF8' : '#6366F1'
: theme.colors.warning
}
size={4 + Math.random() * 3}
/>
))} ))}
</View> </View>
)} )}
{/* Winner crown badge */}
{isWinnerCard && ( {isWinnerCard && (
<Animated.View <Animated.View style={[styles.winnerIcon, { backgroundColor: theme.colors.success, opacity: winnerGlowAnim }]}>
style={[
styles.crownBadge,
{
backgroundColor: theme.colors.success,
opacity: winnerGlowAnim,
transform: [
{
scale: winnerGlowAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
}),
},
],
},
]}
>
<LucideIcon name="trophy" size={12} color={theme.colors.white} /> <LucideIcon name="trophy" size={12} color={theme.colors.white} />
</Animated.View> </Animated.View>
)} )}
{/* Already selected badge */}
{item.selected && !isWinnerCard && ( {item.selected && !isWinnerCard && (
<View style={[styles.selectedBadge, { backgroundColor: theme.colors.surface }]}> <View style={[styles.disabledOverlay, { backgroundColor: theme.colors.surface + '80' }]}>
<LucideIcon name="checkmark" size={10} color={theme.colors.textMuted} /> <LucideIcon name="checkmark-circle" size={20} color={theme.colors.textMuted} />
</View> </View>
)} )}
{/* Avatar circle */} <View style={[styles.avatarBox, {
<View backgroundColor: isWinnerCard ? theme.colors.success + '20' : theme.colors.surface
style={[ }]}>
styles.avatarCircle, <LucideIcon name={isWinnerCard ? "trophy" : "person"} size={26} color={isWinnerCard ? theme.colors.success : theme.colors.textSecondary} />
{
backgroundColor: isHighlighted && !isWinnerCard
? isDark ? 'rgba(129, 140, 248, 0.15)' : 'rgba(99, 102, 241, 0.1)'
: isWinnerCard
? isDark ? 'rgba(59, 142, 98, 0.2)' : 'rgba(46, 138, 87, 0.1)'
: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
},
]}
>
<LucideIcon
name={isWinnerCard ? 'trophy' : 'person'}
size={isWinnerCard ? 26 : 22}
color={iconColor}
/>
</View> </View>
{/* Name */} <Text style={[styles.userName, {
<Text color: isWinnerCard ? theme.colors.success : item.selected ? theme.colors.textMuted : theme.colors.textPrimary,
style={[ fontFamily: isWinnerCard ? FontsFamily.nunitoExtraBold : FontsFamily.nunitoBold,
styles.gridText, }]} numberOfLines={2}>
{
color: textColor,
fontFamily: isWinnerCard ? FontsFamily.nunitoBold : FontsFamily.nunitoMedium,
},
]}
numberOfLines={2}
>
{item.name} {item.name}
</Text> </Text>
</Animated.View> </Animated.View>
); );
}; };
// --- STATUS TEXT ---
const getPhaseText = () => {
switch (phase) {
case 'scanning':
return 'Scanning participants...';
case 'slowing':
return 'Narrowing down...';
case 'reveal':
return '🎉 Winner Found!';
default:
return 'Tap below to start selection';
}
};
// --- LOADING STATE ---
if (isParticipantsLoading) { if (isParticipantsLoading) {
return ( return (
<AppView style={{ backgroundColor: theme.colors.background }}> <AppView backgroundColor={theme.colors.background}>
<View style={styles.header}> <Header title="Winner Selection" showBack={true} />
<SkeletonLoader width={220} height={36} style={{ marginBottom: 10, borderRadius: 8 }} /> <View style={styles.loadingContainer}>
<SkeletonLoader width={140} height={18} style={{ borderRadius: 6 }} />
</View>
<View style={styles.skeletonGrid}>
{Array.from({ length: 9 }).map((_, i) => ( {Array.from({ length: 9 }).map((_, i) => (
<SkeletonLoader key={i} width={ITEM_SIZE} height={ITEM_SIZE + 10} style={{ borderRadius: 16 }} /> <SkeletonLoader key={i} width={ITEM_SIZE} height={ITEM_SIZE + 20} style={{ borderRadius: 20 }} />
))} ))}
</View> </View>
</AppView> </AppView>
); );
} }
// --- MAIN RENDER ---
return ( return (
<AppView paddingHorizontal={0} style={{ backgroundColor: theme.colors.background }}> <AppView paddingHorizontal={0} backgroundColor={theme.colors.surface}>
{/* Header */} <Header title="Payout Draw" showBack={true} />
<Animated.View style={[styles.header, { transform: [{ scale: headerPulseAnim }] }]}>
<Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}> <Animated.View style={[styles.hero, { transform: [{ scale: headerPulseAnim }] }]}>
{phase === 'reveal' ? '🏆 Winner!' : '🎯 Winner Selection'} <View style={[styles.tag, { backgroundColor: theme.colors.primarySoft }]}>
</Text> <Text style={[styles.tagText, { color: theme.colors.primary }]}>Cycle #{cycleNumber}</Text>
<View style={[styles.cycleBadge, { backgroundColor: theme.colors.surface }]}>
<LucideIcon name="sync-outline" size={14} color={theme.colors.textSecondary} />
<Text style={[styles.cycleText, { color: theme.colors.textSecondary }]}>
Cycle #{cycleNumber}
</Text>
</View> </View>
<Text <Text style={[styles.phaseTitle, { color: theme.colors.textPrimary }]}>
style={[ {phase === 'reveal' ? 'Lucky Winner!' : phase === 'idle' ? 'Select Receiver' : 'Selecting...'}
styles.phaseText, </Text>
{ <Text style={[styles.phaseSub, { color: phase === 'reveal' ? theme.colors.success : theme.colors.textSecondary }]}>
color: {phase === 'reveal' ? 'Fund receiver for this month' : phase !== 'idle' ? 'Determining the fair winner...' : 'Tap the button below to draw'}
phase === 'reveal'
? theme.colors.success
: phase !== 'idle'
? theme.mode === 'dark' ? '#818CF8' : '#6366F1'
: theme.colors.textMuted,
},
]}
>
{getPhaseText()}
</Text> </Text>
</Animated.View> </Animated.View>
{/* Participants Grid */} <ScrollView contentContainerStyle={styles.scrollContainer} showsVerticalScrollIndicator={false}>
<ScrollView <View style={styles.userGrid}>
contentContainerStyle={styles.grid} {users.map((u, i) => renderUserCard(u, i))}
showsVerticalScrollIndicator={false}
>
<View style={styles.gridInner}>
{users.map((user, index) => renderUserCard(user, index))}
</View> </View>
</ScrollView> </ScrollView>
{/* Footer */} <View style={[styles.stickyFooter, { backgroundColor: theme.colors.card, borderTopColor: theme.colors.borderLight }]}>
<View
style={[
styles.footer,
{
backgroundColor: theme.colors.background,
borderTopColor: theme.colors.borderLight,
},
]}
>
{/* Winner Banner */}
{winner && phase === 'reveal' && ( {winner && phase === 'reveal' && (
<Animated.View <Animated.View style={[styles.winnerFloat, { backgroundColor: theme.colors.success, opacity: bannerOpacity, transform: [{ translateY: bannerSlideAnim }] }]}>
style={[ <View style={styles.glassCircle}>
styles.winnerBanner, <LucideIcon name="trophy" size={20} color={theme.colors.white} />
{
backgroundColor: theme.colors.success,
transform: [{ translateY: bannerSlideAnim }],
opacity: bannerOpacity,
},
]}
>
<View style={styles.trophyCircle}>
<LucideIcon name="trophy" size={20} color={theme.colors.success} />
</View> </View>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={[styles.winnerLabel, { color: 'rgba(255,255,255,0.7)' }]}> <Text style={styles.winnerFloatLabel}>WINNER</Text>
WINNER <Text style={styles.winnerFloatName}>{winner.name}</Text>
</Text>
<Text style={[styles.winnerName, { color: theme.colors.white }]}>
{winner.name}
</Text>
</View> </View>
<LucideIcon name="sparkles-outline" size={20} color="rgba(255,255,255,0.6)" /> <LucideIcon name="sparkles-outline" size={24} color={theme.colors.white + '80'} />
</Animated.View> </Animated.View>
)} )}
{/* Action Button */}
<TouchableOpacity <TouchableOpacity
style={[ style={[styles.mainBtn, {
styles.actionButton, backgroundColor: isAnimating ? theme.colors.surface : (winner ? theme.colors.success : theme.colors.primary)
{ }]}
backgroundColor: isAnimating
? theme.colors.surface
: phase === 'reveal'
? theme.colors.success
: theme.mode === 'dark' ? '#818CF8' : '#6366F1',
},
]}
onPress={handleStartSelection} onPress={handleStartSelection}
disabled={isAnimating} disabled={isAnimating}
activeOpacity={0.8}
> >
{isAnimating && phase !== 'reveal' ? ( <View style={styles.btnContent}>
<View style={styles.buttonContent}> <LucideIcon
<LucideIcon name={isAnimating ? "sync-outline" : "sparkles-outline"}
name="sync-outline" size={20}
size={18} color={isAnimating ? theme.colors.reverseText : theme.colors.reverseText}
color={theme.colors.textMuted} />
/> <Text style={[styles.btnText, { color: isAnimating ? theme.colors.reverseText : theme.colors.reverseText }]}>
<Text style={[styles.actionButtonText, { color: theme.colors.textMuted }]}> {isAnimating ? 'Selecting...' : winner ? 'Select Again' : 'Shuffle & Select'}
Selecting... </Text>
</Text> </View>
</View>
) : (
<View style={styles.buttonContent}>
<LucideIcon
name={winner ? 'sync-outline' : 'sparkles-outline'}
size={18}
color={theme.colors.white}
/>
<Text style={[styles.actionButtonText, { color: theme.colors.white }]}>
{winner ? 'Select Again' : 'Start Selection'}
</Text>
</View>
)}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</AppView> </AppView>
@@ -754,154 +477,30 @@ const RandomSelectUserScreen: React.FC = () => {
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
header: { loadingContainer: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 12, padding: 16 },
paddingVertical: 20, hero: { alignItems: 'center', paddingVertical: 24, paddingHorizontal: 20, gap: 6 },
paddingHorizontal: 16, tag: { paddingHorizontal: 12, paddingVertical: 4, borderRadius: 20 },
alignItems: 'center', tagText: { fontSize: responsiveFontSize(1.4), fontFamily: FontsFamily.nunitoBold },
gap: 8, phaseTitle: { fontSize: responsiveFontSize(3), fontFamily: FontsFamily.nunitoExtraBold },
}, phaseSub: { fontSize: responsiveFontSize(1.6), fontFamily: FontsFamily.nunitoMedium },
headerTitle: { scrollContainer: { paddingBottom: 180, paddingHorizontal: 8 },
fontSize: responsiveFontSize(2.8), userGrid: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center' },
fontFamily: FontsFamily.nunitoBold,
letterSpacing: -0.5,
},
cycleBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 5,
paddingHorizontal: 12,
borderRadius: 20,
},
cycleText: {
fontSize: responsiveFontSize(1.5),
fontFamily: FontsFamily.nunitoMedium,
},
phaseText: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoMedium,
marginTop: 2,
},
grid: {
paddingHorizontal: 12,
paddingBottom: 160,
},
gridInner: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
},
skeletonGrid: {
padding: 16,
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
gap: 12,
},
gridItem: { gridItem: {
margin: 6, width: ITEM_SIZE, height: ITEM_SIZE + 20, margin: 6, borderRadius: 20,
width: ITEM_SIZE, justifyContent: 'center', alignItems: 'center', padding: 10, position: 'relative'
height: ITEM_SIZE + 10,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 16,
padding: 8,
position: 'relative',
overflow: 'visible',
},
avatarCircle: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 6,
},
gridText: {
textAlign: 'center',
fontSize: responsiveFontSize(1.25),
lineHeight: responsiveFontSize(1.7),
},
selectedBadge: {
position: 'absolute',
top: 6,
right: 6,
width: 18,
height: 18,
borderRadius: 9,
alignItems: 'center',
justifyContent: 'center',
},
crownBadge: {
position: 'absolute',
top: -6,
right: -6,
width: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
elevation: 8,
},
footer: {
position: 'absolute',
bottom: 0,
width: '100%',
padding: 16,
paddingBottom: 20,
borderTopWidth: 1,
alignItems: 'center',
gap: 10,
},
winnerBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 14,
width: '100%',
},
trophyCircle: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(255,255,255,0.2)',
alignItems: 'center',
justifyContent: 'center',
},
winnerLabel: {
fontSize: responsiveFontSize(1.1),
fontFamily: FontsFamily.nunitoBold,
letterSpacing: 1.5,
},
winnerName: {
fontSize: responsiveFontSize(1.9),
fontFamily: FontsFamily.nunitoBold,
marginTop: 1,
},
actionButton: {
width: '100%',
paddingVertical: 15,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
elevation: 6,
shadowColor: '#6366F1',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.25,
shadowRadius: 12,
},
buttonContent: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
actionButtonText: {
fontSize: responsiveFontSize(1.9),
fontFamily: FontsFamily.nunitoBold,
}, },
winnerIcon: { position: 'absolute', top: -10, right: -10, width: 28, height: 28, borderRadius: 14, justifyContent: 'center', alignItems: 'center', zIndex: 10, elevation: 5 },
disabledOverlay: { position: 'absolute', inset: 0, borderRadius: 20, justifyContent: 'center', alignItems: 'center', zIndex: 5 },
avatarBox: { width: 48, height: 48, borderRadius: 24, justifyContent: 'center', alignItems: 'center', marginBottom: 8 },
userName: { fontSize: responsiveFontSize(1.4), textAlign: 'center' },
stickyFooter: { position: 'absolute', bottom: 0, left: 0, right: 0, padding: 16, borderTopWidth: 1 },
winnerFloat: { flexDirection: 'row', alignItems: 'center', gap: 14, padding: 12, borderRadius: 16, marginBottom: 16, elevation: 8 },
glassCircle: { width: 40, height: 40, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.2)', justifyContent: 'center', alignItems: 'center' },
winnerFloatLabel: { fontSize: responsiveFontSize(1.1), fontFamily: FontsFamily.nunitoBold, color: 'rgba(255,255,255,0.7)', letterSpacing: 1.2 },
winnerFloatName: { fontSize: responsiveFontSize(2), fontFamily: FontsFamily.nunitoExtraBold, color: '#fff' },
mainBtn: { width: '100%', height: 56, borderRadius: 16, justifyContent: 'center', alignItems: 'center', elevation: 4 },
btnContent: { flexDirection: 'row', alignItems: 'center', gap: 10 },
btnText: { fontSize: responsiveFontSize(2), fontFamily: FontsFamily.nunitoBold },
}); });
export default RandomSelectUserScreen; export default RandomSelectUserScreen;
+169 -84
View File
@@ -1,12 +1,18 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { StyleSheet, Text, View, Animated, ToastAndroid } from 'react-native'; import { StyleSheet, Text, View, Animated, ToastAndroid, TouchableOpacity, ActivityIndicator } from 'react-native';
import LucideIcon from '../../components/common/LucideIcon'; import LucideIcon from '../../components/common/LucideIcon';
import { responsiveFontSize } from 'react-native-responsive-dimensions'; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import CustomModal from '../../components/common/CustomModal'; import CustomModal from '../../components/common/CustomModal';
import { useTheme } from '../../hooks/ui/useTheme'; import { useTheme } from '../../hooks/ui/useTheme';
import { useUpdate } from '../../hooks/core/useUpdate';
import { BASE_URL } from '../../utils/ApiUrls'; import { BASE_URL } from '../../utils/ApiUrls';
import FontsFamily from '../../constents/FontsFamily'; import FontsFamily from '../../constents/FontsFamily';
import { apiCall } from '../../utils/ApiHelper';
const PAYMENT_MODES = [
{ label: 'Cash', value: 'CASH', icon: 'cash-outline' },
{ label: 'Online', value: 'ONLINE', icon: 'globe-outline' },
];
interface WinnerUserProps { interface WinnerUserProps {
data: { data: {
@@ -41,78 +47,73 @@ interface WinnerUserProps {
const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId }) => { const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId }) => {
const theme = useTheme(); const theme = useTheme();
const [selectedMode, setSelectedMode] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const { const {
withdrawalDone, withdrawalDone,
selectionDone, selectionDone,
currentCycle, currentCycle,
totalParticipants, totalParticipants,
paidParticipants, paidParticipants,
pendingParticipants,
allPaid, allPaid,
selectedUser, selectedUser,
cycleStatus,
selectionMethod,
canProceedToWithdrawal, canProceedToWithdrawal,
} = data || {}; } = data || {};
console.log("data",data)
const scaleAnim = useRef(new Animated.Value(0)).current; const scaleAnim = useRef(new Animated.Value(0)).current;
const pulseAnim = useRef(new Animated.Value(1)).current; const pulseAnim = useRef(new Animated.Value(1)).current;
const fadeAnim = useRef(new Animated.Value(0)).current; const fadeAnim = useRef(new Animated.Value(0)).current;
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
Animated.spring(scaleAnim, { Animated.spring(scaleAnim, { toValue: 1, friction: 5, useNativeDriver: true }).start();
toValue: 1,
friction: 5,
useNativeDriver: true,
}).start();
Animated.loop( Animated.loop(
Animated.sequence([ Animated.sequence([
Animated.timing(pulseAnim, { Animated.timing(pulseAnim, { toValue: 1.05, duration: 800, useNativeDriver: true }),
toValue: 1.05, Animated.timing(pulseAnim, { toValue: 1, duration: 800, useNativeDriver: true }),
duration: 800,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
]) ])
).start(); ).start();
Animated.timing(fadeAnim, { toValue: 1, duration: 1000, useNativeDriver: true }).start();
Animated.timing(fadeAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}).start();
} else { } else {
scaleAnim.setValue(0); scaleAnim.setValue(0);
pulseAnim.setValue(1); pulseAnim.setValue(1);
fadeAnim.setValue(0); fadeAnim.setValue(0);
setSelectedMode(null);
setIsProcessing(false);
} }
}, [visible]); }, [fadeAnim, pulseAnim, scaleAnim, visible]);
const { mutate: withdrawFunds, isPending } = useUpdate<any, any>( const handleWithdraw = async () => {
`${BASE_URL}payments/withdraw`, if (!canProceedToWithdrawal) { onClose(); return; }
{ if (!selectedMode) {
onSuccess: () => { ToastAndroid.show('Please select a payment mode', ToastAndroid.SHORT);
ToastAndroid.show('Withdrawal successful', ToastAndroid.SHORT); return;
}
setIsProcessing(true);
try {
const res = await apiCall<any>(
`${BASE_URL}payments/withdraw`,
'PUT',
{
groupId: Number(groupId),
userId: selectedUser?.userId ?? Number(selectedUser?.id),
cycleNumber: currentCycle,
paymentMode: selectedMode,
}
);
if (res?.status === 'success' || res?.statusCode === 200 || res?.statusCode === 201) {
ToastAndroid.show('Withdrawal successful!', ToastAndroid.SHORT);
onClose(); onClose();
}, } else {
onError: (err) => { ToastAndroid.show(res?.message || 'Withdrawal failed', ToastAndroid.SHORT);
ToastAndroid.show(err?.message || 'Withdrawal failed', ToastAndroid.SHORT); }
}, } catch (err: any) {
ToastAndroid.show(err?.message || 'Withdrawal failed', ToastAndroid.SHORT);
} finally {
setIsProcessing(false);
} }
);
const handleWithdraw = () => {
withdrawFunds({
groupId,
userId: selectedUser?.id,
cycleNumber: currentCycle,
});
}; };
const renderRow = (icon: any, label: string, value: any, color?: string) => ( const renderRow = (icon: any, label: string, value: any, color?: string) => (
@@ -129,15 +130,24 @@ const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId
<CustomModal <CustomModal
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
confirmText={canProceedToWithdrawal ? (isPending ? 'Processing...' : 'Withdraw Funds') : 'Close'} confirmText={
onConfirm={canProceedToWithdrawal ? handleWithdraw : onClose} !canProceedToWithdrawal
confirmButton={theme.colors.primary} ? 'Close'
: isProcessing
? 'Processing...'
: selectedMode
? 'Withdraw Funds'
: 'Select Payment Mode'
}
onConfirm={handleWithdraw}
confirmButton={canProceedToWithdrawal && selectedMode ? theme.colors.success : theme.colors.textMuted}
> >
<View style={styles.container}> <View style={styles.container}>
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}> <Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
{/* Header */}
<View style={styles.header}> <View style={styles.header}>
<View style={[styles.trophyContainer, { backgroundColor: theme.colors.background }]}> <View style={[styles.trophyContainer, { backgroundColor: theme.colors.success + '15' }]}>
<LucideIcon name="trophy" size={32} color={theme.colors.primary} /> <LucideIcon name="trophy" size={32} color={theme.colors.success} />
</View> </View>
<Text style={[styles.cycle, { color: theme.colors.textSecondary }]}>Cycle {currentCycle} Winner</Text> <Text style={[styles.cycle, { color: theme.colors.textSecondary }]}>Cycle {currentCycle} Winner</Text>
<Animated.Text style={[styles.name, { color: theme.colors.textPrimary, transform: [{ scale: pulseAnim }] }]}> <Animated.Text style={[styles.name, { color: theme.colors.textPrimary, transform: [{ scale: pulseAnim }] }]}>
@@ -145,12 +155,13 @@ const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId
</Animated.Text> </Animated.Text>
</View> </View>
{/* Details */}
<View style={[styles.detailsCard, { backgroundColor: theme.colors.background, borderColor: theme.colors.border }]}> <View style={[styles.detailsCard, { backgroundColor: theme.colors.background, borderColor: theme.colors.border }]}>
{renderRow('call-outline', 'Mobile', selectedUser?.mobile)} {renderRow('call-outline', 'Mobile', selectedUser?.mobile)}
{renderRow('mail-outline', 'Email', selectedUser?.email || 'N/A')} {renderRow('mail-outline', 'Email', selectedUser?.email || 'N/A')}
{renderRow('people-outline', 'Participants', `${paidParticipants}/${totalParticipants}`)} {renderRow('people-outline', 'Paid Members', `${paidParticipants}/${totalParticipants}`)}
{renderRow( {renderRow(
allPaid ? 'checkmark-circle-outline' : 'alert-circle-outline', allPaid ? 'checkmark-circle' : 'alert-circle-outline',
'All Paid', 'All Paid',
allPaid ? 'Yes' : 'No', allPaid ? 'Yes' : 'No',
allPaid ? theme.colors.success : theme.colors.error allPaid ? theme.colors.success : theme.colors.error
@@ -163,9 +174,59 @@ const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId
)} )}
</View> </View>
<Animated.Text style={[styles.congrats, { color: theme.colors.success, opacity: fadeAnim }]}> {/* Payment Mode Selector — only when withdrawal is pending */}
{selectionDone ? 'Congratulations!' : 'Selection Pending'} {canProceedToWithdrawal && !withdrawalDone && (
</Animated.Text> <View style={styles.modeSection}>
<Text style={[styles.modeTitle, { color: theme.colors.textPrimary }]}>Select Payment Mode</Text>
<Text style={[styles.modeHint, { color: theme.colors.textSecondary }]}>
Ask the user how the withdrawal was paid and choose Cash or Online.
</Text>
<View style={styles.modeRow}>
{PAYMENT_MODES.map((mode) => {
const isActive = selectedMode === mode.value;
return (
<TouchableOpacity
key={mode.value}
onPress={() => setSelectedMode(mode.value)}
style={[
styles.modeChip,
isActive ? styles.modeChipActive : styles.modeChipInactive,
{
backgroundColor: isActive ? theme.colors.success + '15' : theme.colors.surface,
borderColor: isActive ? theme.colors.success : theme.colors.border,
},
]}
>
<LucideIcon
name={mode.icon}
size={18}
color={isActive ? theme.colors.success : theme.colors.textSecondary}
/>
<Text style={[
styles.modeLabel,
{ color: isActive ? theme.colors.success : theme.colors.textSecondary, fontFamily: isActive ? FontsFamily.nunitoBold : FontsFamily.nunitoMedium },
]}>
{mode.label}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
)}
{/* Status label */}
{!canProceedToWithdrawal && (
<Animated.Text style={[styles.statusNote, { color: theme.colors.warning, opacity: fadeAnim }]}>
{selectionDone ? 'Awaiting all payments before withdrawal' : 'Selection Pending'}
</Animated.Text>
)}
{canProceedToWithdrawal && isProcessing && (
<View style={styles.processingRow}>
<ActivityIndicator size="small" color={theme.colors.success} />
<Text style={[styles.processingText, { color: theme.colors.textSecondary }]}>Processing withdrawal...</Text>
</View>
)}
</Animated.View> </Animated.View>
</View> </View>
</CustomModal> </CustomModal>
@@ -173,26 +234,21 @@ const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: { padding: 4 },
padding: 10, header: { alignItems: 'center', marginBottom: 20 },
},
header: {
alignItems: 'center',
marginBottom: 24,
},
trophyContainer: { trophyContainer: {
width: 64, width: 72,
height: 64, height: 72,
borderRadius: 32, borderRadius: 36,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginBottom: 12, marginBottom: 12,
}, },
cycle: { cycle: {
fontSize: responsiveFontSize(1.6), fontSize: responsiveFontSize(1.5),
fontFamily: FontsFamily.nunitoBold, fontFamily: FontsFamily.nunitoBold,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 1, letterSpacing: 1.2,
marginBottom: 4, marginBottom: 4,
}, },
name: { name: {
@@ -201,9 +257,10 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
}, },
detailsCard: { detailsCard: {
padding: 16, padding: 14,
borderRadius: 16, borderRadius: 16,
borderWidth: 1, borderWidth: 1,
marginBottom: 20,
}, },
row: { row: {
flexDirection: 'row', flexDirection: 'row',
@@ -212,25 +269,53 @@ const styles = StyleSheet.create({
paddingVertical: 10, paddingVertical: 10,
borderBottomWidth: 1, borderBottomWidth: 1,
}, },
rowLabelContainer: { rowLabelContainer: { flexDirection: 'row', alignItems: 'center', gap: 8 },
flexDirection: 'row', label: { fontSize: responsiveFontSize(1.6), fontFamily: FontsFamily.nunitoMedium },
alignItems: 'center', value: { fontSize: responsiveFontSize(1.6), fontFamily: FontsFamily.nunitoBold },
gap: 8, // Payment mode
modeSection: { marginBottom: 16 },
modeTitle: {
fontSize: responsiveFontSize(1.7),
fontFamily: FontsFamily.nunitoBold,
}, },
label: { modeHint: {
fontSize: responsiveFontSize(1.35),
fontFamily: FontsFamily.nunitoMedium,
marginTop: 4,
marginBottom: 10,
},
modeRow: { flexDirection: 'row', gap: 8 },
modeChip: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10,
borderRadius: 12,
gap: 4,
},
modeChipActive: {
borderWidth: 2,
},
modeChipInactive: {
borderWidth: 1,
},
modeLabel: { fontSize: responsiveFontSize(1.35), textAlign: 'center' },
// Status
statusNote: {
fontSize: responsiveFontSize(1.6), fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoMedium, fontFamily: FontsFamily.nunitoMedium,
},
value: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoBold,
},
congrats: {
fontSize: responsiveFontSize(2.2),
fontFamily: FontsFamily.nunitoBold,
textAlign: 'center', textAlign: 'center',
marginTop: 24, marginTop: 8,
paddingHorizontal: 16,
}, },
processingRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
marginTop: 8,
},
processingText: { fontSize: responsiveFontSize(1.5), fontFamily: FontsFamily.nunitoMedium },
}); });
export default WinnerUser; export default WinnerUser;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,419 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
AppState,
AppStateStatus,
StyleSheet,
Text,
ToastAndroid,
TouchableOpacity,
View,
} from 'react-native';
import { useNavigation, useRoute } from '@react-navigation/native';
import WebView, { WebViewNavigation } from 'react-native-webview';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import AppView from '../../components/AppView';
import LucideIcon from '../../components/common/LucideIcon';
import FontsFamily from '../../constents/FontsFamily';
import { useTheme } from '../../hooks/ui/useTheme';
import { apiCall } from '../../utils/ApiHelper';
import { BASE_URL } from '../../utils/ApiUrls';
interface PaymentGatewayRouteParams {
paymentUrl: string;
paymentData?: any;
}
const FINAL_TXN_STATUSES = ['SUCCESS', 'FAILED', 'REJECTED', 'CANCELLED'];
const POLL_START_DELAY_MS = 30 * 1000; // 30 seconds
const POLL_INTERVAL_MS = 5 * 1000; // 5 seconds
const PaymentGatewayWebViewScreen: React.FC = () => {
const theme = useTheme();
const navigation = useNavigation<any>();
const route = useRoute<any>();
const webViewRef = useRef<WebView>(null);
const autoCheckedUrlRef = useRef<string | null>(null);
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
const isCheckingStatusRef = useRef(false);
const hasTerminalStatusRef = useRef(false);
const pollStartTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const { paymentUrl, paymentData } = (route.params || {}) as PaymentGatewayRouteParams;
const [isPageLoading, setIsPageLoading] = useState(true);
const [isCheckingStatus, setIsCheckingStatus] = useState(false);
const parsedPgRequest = useMemo(() => {
if (!paymentData?.pgRequestJson) {
return null;
}
try {
return JSON.parse(paymentData.pgRequestJson);
} catch (error) {
console.log('Unable to parse pgRequestJson:', error);
return null;
}
}, [paymentData?.pgRequestJson]);
const pgOrderNo = paymentData?.pgOrderNo || paymentData?.orderNo || '';
const completionUrls = useMemo(
() => [parsedPgRequest?.callback_url, parsedPgRequest?.cancellation_url].filter(Boolean),
[parsedPgRequest]
);
const stopAutoPolling = useCallback(() => {
if (pollStartTimeoutRef.current) {
clearTimeout(pollStartTimeoutRef.current);
pollStartTimeoutRef.current = null;
}
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
}, []);
const verifyPaymentStatus = useCallback(async ({
fallbackMessage = 'Checking payment status...',
silentPending = false,
}: {
fallbackMessage?: string;
silentPending?: boolean;
} = {}) => {
if (!pgOrderNo || isCheckingStatusRef.current || hasTerminalStatusRef.current) {
if (!pgOrderNo) {
ToastAndroid.show('Payment reference is missing', ToastAndroid.SHORT);
}
return;
}
try {
isCheckingStatusRef.current = true;
setIsCheckingStatus(true);
const statusRes = await apiCall(
`${BASE_URL}payments/pg/check-status/${pgOrderNo}`,
'GET'
) as any;
const txnStatus = statusRes?.data?.txnStatus;
if (txnStatus === 'SUCCESS') {
hasTerminalStatusRef.current = true;
stopAutoPolling();
ToastAndroid.show('Payment successful!', ToastAndroid.SHORT);
navigation.goBack();
return;
}
if (txnStatus && FINAL_TXN_STATUSES.includes(txnStatus)) {
hasTerminalStatusRef.current = true;
stopAutoPolling();
ToastAndroid.show(
statusRes?.data?.message || statusRes?.message || 'Payment was not completed.',
ToastAndroid.LONG
);
return;
}
if (!silentPending) {
ToastAndroid.show(
statusRes?.data?.message || statusRes?.message || fallbackMessage,
ToastAndroid.SHORT
);
}
} catch (error) {
console.log('Payment status verification failed:', error);
if (!silentPending) {
ToastAndroid.show('Unable to verify payment right now', ToastAndroid.SHORT);
}
} finally {
isCheckingStatusRef.current = false;
setIsCheckingStatus(false);
}
}, [navigation, pgOrderNo, stopAutoPolling]);
const handleNavigationChange = useCallback((navState: WebViewNavigation) => {
const nextUrl = navState.url || '';
const matchedCompletionUrl = completionUrls.find((url) => nextUrl.startsWith(url));
if (matchedCompletionUrl && autoCheckedUrlRef.current !== nextUrl) {
autoCheckedUrlRef.current = nextUrl;
verifyPaymentStatus({
fallbackMessage: 'Payment submitted. Waiting for confirmation...',
});
}
}, [completionUrls, verifyPaymentStatus]);
useEffect(() => {
if (!pgOrderNo) {
return;
}
pollStartTimeoutRef.current = setTimeout(() => {
verifyPaymentStatus({ silentPending: true });
pollingIntervalRef.current = setInterval(() => {
verifyPaymentStatus({ silentPending: true });
}, POLL_INTERVAL_MS);
}, POLL_START_DELAY_MS);
return () => {
stopAutoPolling();
};
}, [pgOrderNo, stopAutoPolling, verifyPaymentStatus]);
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextAppState) => {
const wasInBackground = /inactive|background/.test(appStateRef.current);
appStateRef.current = nextAppState;
if (wasInBackground && nextAppState === 'active') {
verifyPaymentStatus({
fallbackMessage: 'Checking payment status after app resume...',
silentPending: true,
});
}
});
return () => {
subscription.remove();
};
}, [verifyPaymentStatus]);
const renderLoadingOverlay = () => (
<View style={[styles.loadingOverlay, { backgroundColor: theme.colors.background }]}>
<ActivityIndicator size="large" color={theme.colors.primary} />
<Text style={[styles.loadingText, { color: theme.colors.textSecondary }]}>
Opening secure payment page...
</Text>
</View>
);
if (!paymentUrl) {
return (
<AppView
style={styles.screen}
backgroundColor={theme.colors.background}
>
<View style={styles.emptyState}>
<LucideIcon name="alert-circle-outline" size={56} color={theme.colors.error} />
<Text style={[styles.emptyTitle, { color: theme.colors.textPrimary }]}>
Payment link unavailable
</Text>
<Text style={[styles.emptyMessage, { color: theme.colors.textSecondary }]}>
We could not open the payment page for this transaction.
</Text>
<TouchableOpacity
activeOpacity={0.8}
onPress={() => navigation.goBack()}
style={[styles.primaryButton, { backgroundColor: theme.colors.primary }]}
>
<Text style={styles.primaryButtonText}>Go Back</Text>
</TouchableOpacity>
</View>
</AppView>
);
}
return (
<AppView
style={styles.screen}
backgroundColor={theme.colors.background}
paddingHorizontal={0}
keyboardSafe={false}
dismissKeyboardOnTap={false}
>
<View style={styles.fullScreenContainer}>
<WebView
ref={webViewRef}
source={{ uri: paymentUrl }}
style={styles.webView}
onLoadStart={() => setIsPageLoading(true)}
onLoadEnd={() => setIsPageLoading(false)}
onNavigationStateChange={handleNavigationChange}
onError={() => {
setIsPageLoading(false);
ToastAndroid.show('Unable to load the payment page', ToastAndroid.SHORT);
}}
javaScriptEnabled
domStorageEnabled
sharedCookiesEnabled
thirdPartyCookiesEnabled
originWhitelist={['*']}
startInLoadingState
/>
{isPageLoading && renderLoadingOverlay()}
{/* <View style={styles.topOverlay}>
<View
style={[
styles.topOverlayCard,
{
backgroundColor: `${theme.colors.card}EE`,
borderColor: theme.colors.border,
},
]}
>
<TouchableOpacity
activeOpacity={0.8}
onPress={() => navigation.goBack()}
style={styles.iconButton}
>
<LucideIcon name="arrow-back" size={20} color={theme.colors.textPrimary} />
</TouchableOpacity>
<View style={styles.titleContainer}>
<Text style={[styles.overlayTitle, { color: theme.colors.textPrimary }]}>
Secure Payment
</Text>
<Text
style={[styles.overlaySubtitle, { color: theme.colors.textSecondary }]}
numberOfLines={1}
>
{currencyCode} {amount.toLocaleString()} {pgOrderNo ? `| ${pgOrderNo}` : ''}
</Text>
</View>
<TouchableOpacity
activeOpacity={0.8}
onPress={() => webViewRef.current?.reload()}
style={styles.iconButton}
>
<LucideIcon name="refresh-outline" size={20} color={theme.colors.textPrimary} />
</TouchableOpacity>
</View>
</View> */}
<View style={styles.bottomOverlay}>
<TouchableOpacity
activeOpacity={0.8}
disabled={isCheckingStatus}
onPress={() => verifyPaymentStatus()}
style={[
styles.primaryButton,
styles.bottomButton,
isCheckingStatus && styles.primaryButtonDisabled,
{
backgroundColor: theme.colors.primary,
},
]}
>
{isCheckingStatus ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<>
<LucideIcon name="shield-checkmark-outline" size={18} color="#fff" />
<Text style={styles.primaryButtonText}>Check Status</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</AppView>
);
};
export default PaymentGatewayWebViewScreen;
const styles = StyleSheet.create({
screen: {
flex: 1,
},
fullScreenContainer: {
flex: 1,
},
webView: {
flex: 1,
},
topOverlay: {
position: 'absolute',
top: 12,
left: 12,
right: 12,
},
topOverlayCard: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 18,
paddingHorizontal: 10,
paddingVertical: 10,
},
titleContainer: {
flex: 1,
marginHorizontal: 10,
},
overlayTitle: {
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoBold,
},
overlaySubtitle: {
fontSize: responsiveFontSize(1.2),
fontFamily: FontsFamily.nunitoSemiBold,
marginTop: 2,
},
bottomOverlay: {
position: 'absolute',
left: 16,
right: 16,
bottom: 16,
},
bottomButton: {
borderRadius: 18,
paddingHorizontal: 18,
},
iconButton: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
gap: 12,
},
loadingText: {
fontSize: responsiveFontSize(1.5),
fontFamily: FontsFamily.nunitoSemiBold,
},
primaryButton: {
minHeight: 52,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
gap: 8,
},
primaryButtonDisabled: {
opacity: 0.7,
},
primaryButtonText: {
color: '#fff',
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoBold,
},
emptyState: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 24,
gap: 12,
},
emptyTitle: {
fontSize: responsiveFontSize(2.2),
fontFamily: FontsFamily.nunitoBold,
textAlign: 'center',
},
emptyMessage: {
fontSize: responsiveFontSize(1.5),
fontFamily: FontsFamily.nunitoRegular,
textAlign: 'center',
lineHeight: 22,
marginBottom: 8,
},
});
+159 -86
View File
@@ -1,5 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { import {
View, View,
Text, Text,
@@ -8,7 +7,6 @@ import {
TouchableOpacity, TouchableOpacity,
ActivityIndicator, ActivityIndicator,
Animated, Animated,
Image,
ToastAndroid, ToastAndroid,
Modal, Modal,
Easing, Easing,
@@ -19,10 +17,9 @@ import LucideIcon from '../../components/common/LucideIcon';
import AppView from '../../components/AppView'; import AppView from '../../components/AppView';
import { useTheme } from '../../hooks/ui/useTheme'; import { useTheme } from '../../hooks/ui/useTheme';
import { apiCall } from '../../utils/ApiHelper'; import { apiCall } from '../../utils/ApiHelper';
import { BASE_URL, CONTRIBUTE, PAYMENT_MODES_URL } from '../../utils/ApiUrls'; import { BASE_URL, CONTRIBUTE, PAYMENT_MODES_URL, PAY_ALL_URL } from '../../utils/ApiUrls';
import FontsFamily from '../../constents/FontsFamily'; import FontsFamily from '../../constents/FontsFamily';
import Header from '../../components/common/Header'; import Header from '../../components/common/Header';
import { clampRGBA } from 'react-native-reanimated/lib/typescript/Colors';
interface PaymentMode { interface PaymentMode {
id: number; id: number;
@@ -39,6 +36,18 @@ interface PaymentMode {
iconUrl: string; iconUrl: string;
} }
interface PaymentModeRouteParams {
groupId?: string | number;
groupIds?: Array<string | number>;
amount?: number;
routeFrom?: string;
routeData?: any;
}
const isPaymentRequestSuccessful = (res: any) =>
res?.status === true || res?.status === 'success' || res?.statusCode === 200;
const WEBVIEW_PAYMENT_MODE_ID = 2;
// --- Extracted ModeCard Component (hooks must be in a component, not a render function) --- // --- Extracted ModeCard Component (hooks must be in a component, not a render function) ---
const getBrandColors = (modeCode: string): { bg: string; text: string } => { const getBrandColors = (modeCode: string): { bg: string; text: string } => {
switch (modeCode) { switch (modeCode) {
@@ -68,7 +77,7 @@ const ModeCard: React.FC<ModeCardProps> = ({ item, index, isSelected, isDisabled
delay: index * 100, delay: index * 100,
useNativeDriver: true, useNativeDriver: true,
}).start(); }).start();
}, []); }, [cardAnim, index]);
const brandColors = getBrandColors(item.modeCode); const brandColors = getBrandColors(item.modeCode);
@@ -130,10 +139,15 @@ const ModeCard: React.FC<ModeCardProps> = ({ item, index, isSelected, isDisabled
// --- Main Screen --- // --- Main Screen ---
const PaymentModeScreen: React.FC = () => { const PaymentModeScreen: React.FC = () => {
const theme = useTheme(); const theme = useTheme();
const navigation = useNavigation(); const navigation = useNavigation<any>();
const route = useRoute<any>(); const route = useRoute<any>();
const { groupId, routeFrom, routeData = null } = route.params || {}; const {
const user = useSelector((state: any) => state.user); groupId,
groupIds = [],
amount = 0,
routeFrom,
routeData = null,
} = (route.params || {}) as PaymentModeRouteParams;
const [modes, setModes] = useState<PaymentMode[]>([]); const [modes, setModes] = useState<PaymentMode[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -153,29 +167,63 @@ const PaymentModeScreen: React.FC = () => {
// Animations // Animations
const fadeAnim = useRef(new Animated.Value(0)).current; const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(30)).current; const slideAnim = useRef(new Animated.Value(30)).current;
const normalizedGroupIds = useMemo(
() =>
(Array.isArray(groupIds) ? groupIds : []).map((id) => {
const numericId = Number(id);
return Number.isNaN(numericId) ? id : numericId;
}),
[groupIds]
);
const paymentSummary = useMemo(() => {
switch (routeFrom) {
case 'GroupDetails':
return {
label: 'Total Payable',
currencyCode: payable?.currencyCode || 'UGX',
value: Number(payable?.totalPayable || 0),
};
case 'GroupScreen':
return {
label: 'Entry Fee',
currencyCode: routeData?.currencyCode || 'UGX',
value: Number(routeData?.entryFee || amount || 0),
};
case 'UserProfileStatus':
return {
label: 'Selected Due',
currencyCode: routeData?.currencyCode || 'UGX',
value: Number(amount || routeData?.totalPayable || 0),
};
default:
return null;
}
}, [amount, payable, routeData, routeFrom]);
useEffect(() => { useEffect(() => {
Animated.parallel([ Animated.parallel([
Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }), Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }),
Animated.timing(slideAnim, { toValue: 0, duration: 500, useNativeDriver: true }), Animated.timing(slideAnim, { toValue: 0, duration: 500, useNativeDriver: true }),
]).start(); ]).start();
}, []); }, [fadeAnim, slideAnim]);
const handleContribution = useCallback(async () => {
useEffect(() => {
{ routeFrom === 'GroupDetails' && handleContribution(); }
console.log("data", payable);
}, []);
const handleContribution = async () => {
try { try {
const res = await apiCall(`${CONTRIBUTE}${groupId || route?.params?.groupId}/calculate-contribution`, 'GET') as any; const res = await apiCall(`${CONTRIBUTE}${groupId}/calculate-contribution`, 'GET') as any;
setPayable(res.data); setPayable(res.data);
} catch (error) { } catch (error) {
console.log("Error in handleContribution: ", error); console.log("Error in handleContribution: ", error);
} }
}; }, [groupId]);
useEffect(() => {
if (routeFrom === 'GroupDetails') {
handleContribution();
}
}, [handleContribution, routeFrom]);
// Spin animation for processing modal // Spin animation for processing modal
const startSpinAnimation = () => { const startSpinAnimation = useCallback(() => {
spinAnim.setValue(0); spinAnim.setValue(0);
spinLoop.current = Animated.loop( spinLoop.current = Animated.loop(
Animated.timing(spinAnim, { Animated.timing(spinAnim, {
@@ -186,13 +234,13 @@ const PaymentModeScreen: React.FC = () => {
}) })
); );
spinLoop.current.start(); spinLoop.current.start();
}; }, [spinAnim]);
const stopSpinAnimation = () => { const stopSpinAnimation = useCallback(() => {
spinLoop.current?.stop(); spinLoop.current?.stop();
}; }, []);
const cleanupPolling = () => { const cleanupPolling = useCallback(() => {
if (pollingRef.current) { if (pollingRef.current) {
clearInterval(pollingRef.current); clearInterval(pollingRef.current);
pollingRef.current = null; pollingRef.current = null;
@@ -202,12 +250,12 @@ const PaymentModeScreen: React.FC = () => {
countdownRef.current = null; countdownRef.current = null;
} }
stopSpinAnimation(); stopSpinAnimation();
}; }, [stopSpinAnimation]);
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {
return () => cleanupPolling(); return () => cleanupPolling();
}, []); }, [cleanupPolling]);
const pollTransactionStatus = (pgOrderNo: string) => { const pollTransactionStatus = (pgOrderNo: string) => {
setProcessingVisible(true); setProcessingVisible(true);
@@ -304,6 +352,31 @@ const PaymentModeScreen: React.FC = () => {
useEffect(() => { useEffect(() => {
fetchPaymentModes(); fetchPaymentModes();
}, [fetchPaymentModes]); }, [fetchPaymentModes]);
const handleSuccessfulPaymentResponse = (res: any, fallbackMessage: string) => {
const paymentData = res?.data || res;
const paymentUrl = paymentData?.paymentUrl;
const pgOrderNo = paymentData?.pgOrderNo || res?.pgOrderNo;
if (selectedMode === WEBVIEW_PAYMENT_MODE_ID && paymentUrl) {
setPaying(false);
navigation.replace('PaymentGatewayWebView', {
paymentUrl,
paymentData,
});
return;
}
if (pgOrderNo) {
pollTransactionStatus(pgOrderNo);
return;
}
ToastAndroid.show(res?.message || fallbackMessage, ToastAndroid.SHORT);
setPaying(false);
navigation.goBack();
};
const handlePaymentForRouteFormGroupScreen = async () => { const handlePaymentForRouteFormGroupScreen = async () => {
try { try {
@@ -323,15 +396,8 @@ const PaymentModeScreen: React.FC = () => {
console.log("payment res", res) console.log("payment res", res)
if (res?.status === 'success' || res?.statusCode === 200) { if (isPaymentRequestSuccessful(res)) {
const pgOrderNo = res?.data?.pgOrderNo; handleSuccessfulPaymentResponse(res, 'Payment initiated successfully');
if (pgOrderNo) {
// Start polling for transaction status
pollTransactionStatus(pgOrderNo);
} else {
ToastAndroid.show('Payment initiated but no order number received', ToastAndroid.SHORT);
setPaying(false);
}
} else { } else {
ToastAndroid.show(res?.data?.message || res?.message || 'Payment failed', ToastAndroid.SHORT); ToastAndroid.show(res?.data?.message || res?.message || 'Payment failed', ToastAndroid.SHORT);
setPaying(false); setPaying(false);
@@ -341,7 +407,7 @@ const PaymentModeScreen: React.FC = () => {
setPaying(false); setPaying(false);
} }
} }
const handlePaymentForRouteFormGroupDetails = async (PaymentMode: number) => { const handlePaymentForRouteFormGroupDetails = async () => {
try { try {
setPaying(true); setPaying(true);
@@ -361,43 +427,11 @@ const PaymentModeScreen: React.FC = () => {
console.log("payment payload ", payload) console.log("payment payload ", payload)
console.log("payment res ", res) console.log("payment res ", res)
switch (PaymentMode) { if (isPaymentRequestSuccessful(res)) {
case 6: handleSuccessfulPaymentResponse(res, 'Payment initiated successfully');
{ } else {
if (res?.status === 'success' || res?.statusCode === 200) { ToastAndroid.show(res?.data?.message || res?.message || 'Payment failed', ToastAndroid.SHORT);
setPaying(false);
const pgOrderNo = res?.data?.pgOrderNo;
if (pgOrderNo) {
// Start polling for transaction status
pollTransactionStatus(pgOrderNo);
} else {
ToastAndroid.show('Payment initiated but no order number received', ToastAndroid.SHORT);
setPaying(false);
}
} else {
ToastAndroid.show(res?.data?.message || res?.message || 'Payment failed', ToastAndroid.SHORT);
setPaying(false);
}
}
break;
default:
{
if (res?.status === 'success' || res?.statusCode === 200) {
const pgOrderNo = res?.data?.pgOrderNo;
if (pgOrderNo) {
// Start polling for transaction status
pollTransactionStatus(pgOrderNo);
} else {
ToastAndroid.show('Payment initiated but no order number received', ToastAndroid.SHORT);
setPaying(false);
}
} else {
ToastAndroid.show(res?.data?.message || res?.message || 'Payment failed', ToastAndroid.SHORT);
setPaying(false);
}
}
break;
} }
} catch (error) { } catch (error) {
@@ -405,6 +439,40 @@ const PaymentModeScreen: React.FC = () => {
setPaying(false); setPaying(false);
} }
} }
const handlePaymentForUserProfileStatus = async () => {
try {
if (normalizedGroupIds.length === 0) {
ToastAndroid.show('Please select at least one due payment', ToastAndroid.SHORT);
return;
}
setPaying(true);
const payload = {
groupIds: normalizedGroupIds,
payAllDue: true,
paymentId: selectedMode || 0,
mobileNumber: '',
};
const res = await apiCall(
PAY_ALL_URL,
'POST',
payload
) as any;
if (isPaymentRequestSuccessful(res)) {
handleSuccessfulPaymentResponse(res, 'Payment initiated successfully');
} else {
ToastAndroid.show(res?.data?.message || res?.message || 'Payment failed', ToastAndroid.SHORT);
setPaying(false);
}
} catch (error) {
ToastAndroid.show('An error occurred during payment', ToastAndroid.SHORT);
setPaying(false);
}
}
const handleConfirmPayment = async () => { const handleConfirmPayment = async () => {
console.log("selectedMode", selectedMode) console.log("selectedMode", selectedMode)
if (!selectedMode) { if (!selectedMode) {
@@ -412,13 +480,17 @@ const PaymentModeScreen: React.FC = () => {
ToastAndroid.show('Please select a payment method', ToastAndroid.SHORT); ToastAndroid.show('Please select a payment method', ToastAndroid.SHORT);
return; return;
} }
console.log("routeFrom", routeFrom)
switch (routeFrom) { switch (routeFrom) {
case 'GroupDetails': case 'GroupDetails':
handlePaymentForRouteFormGroupDetails(selectedMode); handlePaymentForRouteFormGroupDetails();
break; break;
case 'GroupScreen': case 'GroupScreen':
handlePaymentForRouteFormGroupScreen(); handlePaymentForRouteFormGroupScreen();
break; break;
case 'UserProfileStatus':
handlePaymentForUserProfileStatus();
break;
default: default:
break; break;
} }
@@ -431,23 +503,19 @@ const PaymentModeScreen: React.FC = () => {
<Animated.View style={[styles.container, { opacity: fadeAnim, transform: [{ translateY: slideAnim }] }]}> <Animated.View style={[styles.container, { opacity: fadeAnim, transform: [{ translateY: slideAnim }] }]}>
{/* Amount Summary */} {/* Amount Summary */}
{routeFrom === 'GroupDetails' && <View style={[styles.amountCard, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}> {paymentSummary && <View style={[styles.amountCard, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}>
<View style={styles.amountRow}> <View style={styles.amountRow}>
<LucideIcon name="wallet-outline" size={20} color={theme.colors.primary} /> <LucideIcon name="wallet-outline" size={20} color={theme.colors.primary} />
<Text style={[styles.amountLabel, { color: theme.colors.textSecondary }]}>Total Payable</Text> <Text style={[styles.amountLabel, { color: theme.colors.textSecondary }]}>{paymentSummary.label}</Text>
</View> </View>
<Text style={[styles.amountValue, { color: theme.colors.textPrimary }]}> <Text style={[styles.amountValue, { color: theme.colors.textPrimary }]}>
{payable?.currencyCode || 'UGX'} {Number(payable?.totalPayable || 0).toLocaleString()} {paymentSummary.currencyCode} {paymentSummary.value.toLocaleString()}
</Text>
</View>}
{routeFrom === 'GroupScreen' && <View style={[styles.amountCard, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}>
<View style={styles.amountRow}>
<LucideIcon name="wallet-outline" size={20} color={theme.colors.primary} />
<Text style={[styles.amountLabel, { color: theme.colors.textSecondary }]}>Entry Fee</Text>
</View>
<Text style={[styles.amountValue, { color: theme.colors.textPrimary }]}>
{payable?.currencyCode || 'UGX'} {Number(routeData?.entryFee || 0).toLocaleString()}
</Text> </Text>
{routeFrom === 'UserProfileStatus' && routeData?.groupCount ? (
<Text style={[styles.amountMeta, { color: theme.colors.textMuted }]}>
{routeData.groupCount} selected group{routeData.groupCount > 1 ? 's' : ''}
</Text>
) : null}
</View>} </View>}
{/* Payment Methods */} {/* Payment Methods */}
@@ -500,7 +568,7 @@ const PaymentModeScreen: React.FC = () => {
) : ( ) : (
<> <>
<LucideIcon name="shield-checkmark-outline" size={20} color={theme.colors.reverseText} style={{ marginRight: 8 }} /> <LucideIcon name="shield-checkmark-outline" size={20} color={theme.colors.reverseText} style={{ marginRight: 8 }} />
<Text style={[styles.confirmText, { color: theme.colors.black }]}>Confirm & Pay</Text> <Text style={[styles.confirmText, { color: theme.colors.reverseText }]}>Confirm & Pay</Text>
</> </>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -616,6 +684,11 @@ const styles = StyleSheet.create({
fontSize: responsiveFontSize(3), fontSize: responsiveFontSize(3),
fontFamily: FontsFamily.nunitoExtraBold, fontFamily: FontsFamily.nunitoExtraBold,
}, },
amountMeta: {
fontSize: responsiveFontSize(1.2),
fontFamily: FontsFamily.nunitoSemiBold,
marginTop: 6,
},
listContent: { listContent: {
paddingBottom: 100, paddingBottom: 100,
gap: 10, gap: 10,
+1 -1
View File
@@ -134,7 +134,7 @@ const BecomeOrg = () => {
<CustomInput <CustomInput
label="ID Type" label="ID Type"
placeholder="e.g., Aadhaar, PAN Card" placeholder="e.g., National ID"
value={idName} value={idName}
onChangeText={(val) => handleChange("idName", val)} onChangeText={(val) => handleChange("idName", val)}
icon="person-outline" icon="person-outline"
+3 -3
View File
@@ -25,7 +25,7 @@ type BankFormErrors = {
const isValidBankName = (value: string) => /^[A-Za-z][A-Za-z\s.&-]{1,}$/.test(value.trim()); const isValidBankName = (value: string) => /^[A-Za-z][A-Za-z\s.&-]{1,}$/.test(value.trim());
const isValidAccountNumber = (value: string) => /^\d{9,18}$/.test(value.trim()); const isValidAccountNumber = (value: string) => /^\d{9,18}$/.test(value.trim());
const isValidIfsc = (value: string) => /^[A-Z]{4}0[A-Z0-9]{6}$/.test(value.trim().toUpperCase()); const isValidIfsc = (value: string) => /^[A-Z0-9]{6}$/.test(value.trim().toUpperCase());
const EditBankDetails = () => { const EditBankDetails = () => {
const theme = useTheme(); const theme = useTheme();
@@ -79,7 +79,7 @@ const EditBankDetails = () => {
nextErrors.bankAccountNumber = "Account number must be 9-18 digits."; nextErrors.bankAccountNumber = "Account number must be 9-18 digits.";
} }
if (!isValidIfsc(branchCode)) { if (!isValidIfsc(branchCode)) {
nextErrors.branchCode = "Enter a valid IFSC (e.g. HDFC0001234)."; nextErrors.branchCode = "Enter a valid branch code.";
} }
setErrors(nextErrors); setErrors(nextErrors);
@@ -135,7 +135,7 @@ const EditBankDetails = () => {
/> />
<CustomInput <CustomInput
label="Branch Code (IFSC)" label="Branch Code"
placeholder="Enter the branch code" placeholder="Enter the branch code"
value={branchCode} value={branchCode}
onChangeText={(val) => handleChange("branchCode", val)} onChangeText={(val) => handleChange("branchCode", val)}
+1 -1
View File
@@ -11,7 +11,7 @@ export const JOIN_GROUP_URL = `${BASE_URL}fund/`;
export const MY_GROUPS_URL = `${BASE_URL}fund/getAll/my/groups` export const MY_GROUPS_URL = `${BASE_URL}fund/getAll/my/groups`
export const REQUESTS_URL = `${BASE_URL}fund/group/pending/request` export const REQUESTS_URL = `${BASE_URL}fund/group/pending/request`
export const APPROVE_REQUEST_URL = `${BASE_URL}fund/requests/` export const APPROVE_REQUEST_URL = `${BASE_URL}fund/requests/`
export const DECLINE_REQUEST_URL = `${BASE_URL}fund/user/my/groups` export const DECLINE_REQUEST_URL = `${BASE_URL}fund/requests/`
export const GET_JOINED_GROUP_URL = `${BASE_URL}fund/my/join/groups` export const GET_JOINED_GROUP_URL = `${BASE_URL}fund/my/join/groups`
export const PUT_USER_PROFILE_IMG = `${BASE_URL}user/update/userImg` export const PUT_USER_PROFILE_IMG = `${BASE_URL}user/update/userImg`
export const PUT_USER_PROFILE = `${BASE_URL}user/profile/update` export const PUT_USER_PROFILE = `${BASE_URL}user/profile/update`