latest
@@ -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
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 25 KiB |
@@ -39,6 +39,7 @@
|
||||
"react-native-dialog": "^9.3.0",
|
||||
"react-native-elements": "^3.4.3",
|
||||
"react-native-fast-confetti": "^1.1.1",
|
||||
"react-native-gesture-handler": "^2.28.0",
|
||||
"react-native-image-picker": "^8.2.1",
|
||||
"react-native-inset-shadow": "^1.0.3",
|
||||
"react-native-keychain": "^10.0.0",
|
||||
@@ -53,6 +54,7 @@
|
||||
"react-native-svg": "^15.13.0",
|
||||
"react-native-vector-icons": "^10.3.0",
|
||||
"react-native-walkthrough-tooltip": "^1.6.0",
|
||||
"react-native-webview": "^13.16.1",
|
||||
"react-native-worklets": "^0.6.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"redux-persist": "^6.0.0"
|
||||
@@ -1806,7 +1808,6 @@
|
||||
"node_modules/@egjs/hammerjs": {
|
||||
"version": "2.0.17",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/hammerjs": "^2.0.36"
|
||||
},
|
||||
@@ -4453,8 +4454,7 @@
|
||||
},
|
||||
"node_modules/@types/hammerjs": {
|
||||
"version": "2.0.46",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
@@ -12639,7 +12639,6 @@
|
||||
"node_modules/react-native-gesture-handler": {
|
||||
"version": "2.28.0",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@egjs/hammerjs": "^2.0.17",
|
||||
"hoist-non-react-statics": "^3.3.0",
|
||||
@@ -12896,6 +12895,20 @@
|
||||
"@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": {
|
||||
"version": "0.6.0",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"react-native-svg": "^15.13.0",
|
||||
"react-native-vector-icons": "^10.3.0",
|
||||
"react-native-walkthrough-tooltip": "^1.6.0",
|
||||
"react-native-webview": "^13.16.1",
|
||||
"react-native-worklets": "^0.6.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"redux-persist": "^6.0.0"
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { FC } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ToastAndroid } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import React, { FC, useCallback, useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import { responsiveFontSize } from 'react-native-responsive-dimensions';
|
||||
import { useTheme } from '../hooks/ui/useTheme';
|
||||
import { useFetch } from '../hooks/core/useFetch';
|
||||
import { useCreate } from '../hooks/core/useCreate';
|
||||
import { CONTRIBUTE } from '../utils/ApiUrls';
|
||||
import FontsFamily from '../constents/FontsFamily';
|
||||
import { apiCall } from '../utils/ApiHelper';
|
||||
|
||||
interface JoinedGroup {
|
||||
id: string;
|
||||
@@ -22,7 +21,8 @@ interface JoinedGroup {
|
||||
currentParticipants: number;
|
||||
contributionAmount: number;
|
||||
cycleType: 'MONTHLY' | 'WEEKLY' | 'QUARTERLY' | 'CUSTOM';
|
||||
status: 'ACTIVE' | 'PENDING' | 'COMPLETED'| 'APPROVED';
|
||||
status: 'ACTIVE' | 'PENDING' | 'COMPLETED' | 'APPROVED' | 'DELAY';
|
||||
customCycleDays?: number;
|
||||
}
|
||||
|
||||
interface JoinedGroupCardProps {
|
||||
@@ -42,24 +42,25 @@ const formatDate = (dateStr: string | null): string => {
|
||||
return `${dayNum} ${months[monthIndex]} ${year}`;
|
||||
};
|
||||
|
||||
export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item }:any) => {
|
||||
export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item }: any) => {
|
||||
const theme = useTheme();
|
||||
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 payable = payableRes?.data;
|
||||
|
||||
const { mutate: contribute, isPending: loading } = useCreate<any, any>(
|
||||
`payments/contribute/${item?.id}?amount=${payable?.totalPayable}`,
|
||||
{
|
||||
onSuccess: () => {
|
||||
ToastAndroid.show('Payment successful!', ToastAndroid.SHORT);
|
||||
},
|
||||
onError: () => {
|
||||
ToastAndroid.show('Payment failed. Please try again.', ToastAndroid.SHORT);
|
||||
},
|
||||
const fetchPayable = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiCall(`${CONTRIBUTE}${item.id}/calculate-contribution`, 'GET') as any;
|
||||
setPayable(res?.data || null);
|
||||
} catch (error) {
|
||||
setPayable(null);
|
||||
console.log(`Failed to fetch fresh payable details for group ${item.id}`, error);
|
||||
}
|
||||
}, [item.id]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
fetchPayable();
|
||||
}, [fetchPayable])
|
||||
);
|
||||
|
||||
const getStatusColors = (status: JoinedGroup['status']) => {
|
||||
@@ -103,7 +104,7 @@ export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item }:any) => {
|
||||
</View>
|
||||
<View style={styles.infoItem}>
|
||||
<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>
|
||||
|
||||
@@ -145,7 +146,7 @@ export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item }:any) => {
|
||||
<Text style={[styles.paidText, { color: theme.colors.success }]}>✓ PAID</Text>
|
||||
</View>
|
||||
) : (
|
||||
(item.status !== 'COMPLETED' || item?.status === "ACTIVE" || item?.status === "DELAY") && (
|
||||
(item?.status === "ACTIVE" || item?.status === "DELAY") && (
|
||||
<TouchableOpacity
|
||||
style={[styles.payButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={() => {
|
||||
@@ -154,10 +155,9 @@ export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item }:any) => {
|
||||
routeFrom: 'GroupDetails'
|
||||
})
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={[styles.payButtonText, { color: theme.colors.onPrimary }]}>
|
||||
{loading ? '...' : 'Pay'}
|
||||
Pay
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ const getStatusStyle = (status: UserJoinRequest["status"], theme: ReturnType<typ
|
||||
label: "Pending",
|
||||
icon: "time-outline",
|
||||
};
|
||||
case "REJECT":
|
||||
case "REJECTED":
|
||||
return {
|
||||
backgroundColor: theme.colors.error + "22",
|
||||
textColor: theme.colors.error,
|
||||
@@ -76,11 +76,11 @@ export const UserRequested: FC<UserRequestedProps> = ({ item }) => {
|
||||
{" "}Group Created By:{" "}
|
||||
<Text style={[styles.userName, { color: theme.colors.textPrimary }]} numberOfLines={1}>{item.createdBy}</Text>
|
||||
</Text>
|
||||
{item.reason ? (
|
||||
{/* {item.reason ? (
|
||||
<Text style={[styles.reasonText, { color: theme.colors.textMuted }]} numberOfLines={2}>
|
||||
<LucideIcon name="chatbubbles-outline" size={13} color={theme.colors.textMuted} /> {item.reason}
|
||||
</Text>
|
||||
) : null}
|
||||
) : null} */}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { StyleSheet, View, Text, TextInput, TouchableOpacity, TextInputProps } f
|
||||
import LucideIcon, { LucideIconName } from './LucideIcon';
|
||||
import { responsiveFontSize } from 'react-native-responsive-dimensions';
|
||||
import { useTheme } from '../../hooks/ui/useTheme';
|
||||
import { ActivityIndicator } from 'react-native-paper';
|
||||
|
||||
// --- Custom Input ---
|
||||
type CustomInputProps = TextInputProps & {
|
||||
@@ -143,7 +144,7 @@ const CustomButton: React.FC<CustomButtonProps> = ({
|
||||
<LucideIcon name={icon} size={20} color={textColor} style={{ marginRight: 8 }} />
|
||||
)}
|
||||
<Text style={[styles.buttonText, { color: textColor }]}>
|
||||
{loading ? 'Processing...' : title}
|
||||
{loading ?<ActivityIndicator color={textColor} /> : title}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -25,6 +25,7 @@ import ManagementScreen from "../screens/management/ManagementScreen";
|
||||
import { navigationRef } from "../utils/navigationService";
|
||||
import NotificationRedirection from "../screens/NotificationRedirection";
|
||||
import PaymentModeScreen from "../screens/payment/PaymentModeScreen";
|
||||
import PaymentGatewayWebViewScreen from "../screens/payment/PaymentGatewayWebViewScreen";
|
||||
// import screen from "../components/test";
|
||||
|
||||
export type RootStackParamList = {
|
||||
@@ -48,7 +49,18 @@ export type RootStackParamList = {
|
||||
TransactionList: undefined
|
||||
RandomSelectUserScreen: { groupId: string | number, cycleNumber: number };
|
||||
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 };
|
||||
Management:
|
||||
| { actionTab: "My Groups" }
|
||||
@@ -122,6 +134,7 @@ export default function AppNavigator() {
|
||||
<Stack.Screen name="Management" component={ManagementScreen} options={{ headerShown: false }} />
|
||||
<Stack.Screen name="NotificationRedirection" component={NotificationRedirection} options={{ headerShown: false }} />
|
||||
<Stack.Screen name="PaymentMode" component={PaymentModeScreen} options={{ headerShown: false }} />
|
||||
<Stack.Screen name="PaymentGatewayWebView" component={PaymentGatewayWebViewScreen} options={{ headerShown: false }} />
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// navigation/types.ts
|
||||
|
||||
import { NavigatorScreenParams } from '@react-navigation/native';
|
||||
import { StackScreenProps } from '@react-navigation/stack';
|
||||
|
||||
// Type for the parameters passed to each screen in the Bottom Tab Navigator
|
||||
@@ -32,6 +31,18 @@ export type RootStackParamList = {
|
||||
ManageJoinedGroup: { groupId: string | number };
|
||||
Invite: { groupId: string | number; code: string | number };
|
||||
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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
Dimensions,
|
||||
Image,
|
||||
RefreshControl,
|
||||
ToastAndroid,
|
||||
@@ -25,8 +24,6 @@ import { CustomInput, CustomButton } from '../components/common/Custom';
|
||||
import SkeletonLoader from '../components/common/SkeletonLoader';
|
||||
import FontsFamily from '../constents/FontsFamily';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
// --- Types ---
|
||||
interface Group {
|
||||
id: string | number;
|
||||
@@ -76,7 +73,7 @@ const GroupCard = ({
|
||||
const isFull = item.currentParticipants >= item.maxParticipants;
|
||||
const isPending = Boolean(item.joinRequestPending);
|
||||
const canJoin = !isJoined && !isPending && !isFull;
|
||||
console.log("item>>>>>>>>>>>>>>", item);
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.card,
|
||||
@@ -100,6 +97,20 @@ const GroupCard = ({
|
||||
|
||||
<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.metaItem}>
|
||||
<LucideIcon name="people-outline" size={14} color={theme.colors.textMuted} />
|
||||
@@ -190,7 +201,7 @@ export default function GroupScreen() {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
onRefresh();
|
||||
}, [])
|
||||
}, [onRefresh])
|
||||
);
|
||||
const handleJoin = (groupId: string | number) => {
|
||||
console.log("Hitting here");
|
||||
@@ -395,13 +406,42 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
cardMeta: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 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: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
marginRight: 4,
|
||||
},
|
||||
metaText: {
|
||||
fontSize: responsiveFontSize(1.3),
|
||||
|
||||
@@ -19,6 +19,7 @@ import { responsiveFontSize } from 'react-native-responsive-dimensions';
|
||||
import LucideIcon from '../components/common/LucideIcon';
|
||||
import * as Keychain from 'react-native-keychain';
|
||||
import FontsFamily from '../constents/FontsFamily';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
// --- Type Definitions ---
|
||||
interface Participant {
|
||||
@@ -56,6 +57,7 @@ type InviteScreenProps = NativeStackScreenProps<RootStackParamList, 'Invite'>;
|
||||
const InviteScreen: React.FC<InviteScreenProps> = ({ route, navigation }) => {
|
||||
const { groupId, code } = route.params;
|
||||
const theme = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
@@ -81,9 +83,20 @@ const InviteScreen: React.FC<InviteScreenProps> = ({ route, navigation }) => {
|
||||
);
|
||||
|
||||
const groupDetails: GroupDetails | null = detailsRes?.data || null;
|
||||
const hasEntryFee = Number(groupDetails?.entryFee || 0) > 0;
|
||||
|
||||
const handleJoin = () => {
|
||||
if (!groupDetails) return;
|
||||
|
||||
if (hasEntryFee) {
|
||||
navigation.navigate('PaymentMode' as never, {
|
||||
groupId: groupDetails.id,
|
||||
routeFrom: 'GroupScreen',
|
||||
routeData: groupDetails,
|
||||
} as never);
|
||||
return;
|
||||
}
|
||||
|
||||
joinGroup({}, {
|
||||
onSuccess: () => {
|
||||
ToastAndroid.show(`Join request sent for "${groupDetails.groupName}".`, ToastAndroid.SHORT);
|
||||
@@ -125,7 +138,11 @@ const InviteScreen: React.FC<InviteScreenProps> = ({ route, navigation }) => {
|
||||
|
||||
return (
|
||||
<AppView>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.screen}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 120 }]}
|
||||
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} />
|
||||
@@ -183,15 +200,33 @@ const InviteScreen: React.FC<InviteScreenProps> = ({ route, navigation }) => {
|
||||
<Text style={[styles.emptyText, { color: theme.colors.textSecondary }]}>Be the first to join!</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.floatingButtonContainer,
|
||||
{
|
||||
backgroundColor: theme.colors.background,
|
||||
borderTopColor: theme.colors.border,
|
||||
paddingBottom: Math.max(insets.bottom, 12),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CustomButton
|
||||
title={isJoinable ? 'Accept Invitation' : `Circle is ${groupDetails.status}`}
|
||||
title={
|
||||
isJoinable
|
||||
? hasEntryFee
|
||||
? 'Pay & Accept Invitation'
|
||||
: 'Accept Invitation'
|
||||
: `Circle is ${groupDetails.status}`
|
||||
}
|
||||
onPress={handleJoin}
|
||||
loading={isJoining}
|
||||
disabled={!isJoinable || isJoining}
|
||||
style={styles.joinButton}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</AppView>
|
||||
);
|
||||
};
|
||||
@@ -207,6 +242,9 @@ const InfoRow = ({ label, value }: { label: string; value: string | number }) =>
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
@@ -308,8 +346,17 @@ const styles = StyleSheet.create({
|
||||
fontSize: responsiveFontSize(1.6),
|
||||
fontFamily: FontsFamily.nunitoMedium,
|
||||
},
|
||||
floatingButtonContainer: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
joinButton: {
|
||||
marginTop: 16,
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
|
||||
@@ -16,6 +16,7 @@ import * as Keychain from 'react-native-keychain';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { useTheme } from '../hooks/ui/useTheme';
|
||||
import { useCreate } from '../hooks/core/useCreate';
|
||||
import { useFetch } from '../hooks/core/useFetch';
|
||||
import { LOGIN_URL, VARIFY_OTP_URL } from '../utils/ApiUrls';
|
||||
import { getFcmToken } from '../utils/firebase/firebaseServices';
|
||||
import { CustomButton } from '../components/common/Custom';
|
||||
@@ -57,7 +58,7 @@ export default function OtpVerificationScreen() {
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [fadeAnim, slideAnim]);
|
||||
|
||||
const { mutate: verifyOtp, isPending: verifying } = useCreate<any, any>(VARIFY_OTP_URL, {
|
||||
onSuccess: async (res) => {
|
||||
@@ -84,17 +85,31 @@ export default function OtpVerificationScreen() {
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: resendOtp, isPending: resending } = useCreate<any, any>(LOGIN_URL, {
|
||||
onSuccess: () => {
|
||||
const { refetch: resendOtp, isFetching: resending } = useFetch<any>(
|
||||
LOGIN_URL + mobile,
|
||||
{},
|
||||
{
|
||||
enabled: false,
|
||||
retry: false,
|
||||
}
|
||||
);
|
||||
|
||||
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);
|
||||
},
|
||||
onError: () => {
|
||||
ToastAndroid.show('Failed to resend OTP', 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) => {
|
||||
// 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.subtitle, { color: theme.colors.textSecondary }]}>
|
||||
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>
|
||||
</View>
|
||||
|
||||
@@ -200,10 +215,10 @@ export default function OtpVerificationScreen() {
|
||||
<View style={styles.resendContainer}>
|
||||
{timer > 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<TouchableOpacity onPress={() => resendOtp({ params: { mobile } })} disabled={resending}>
|
||||
<TouchableOpacity onPress={handleResendOtp} disabled={resending}>
|
||||
<Text style={[styles.resendText, { color: theme.colors.primary }]}>
|
||||
{resending ? 'Sending...' : 'Resend Code'}
|
||||
</Text>
|
||||
@@ -276,4 +291,7 @@ const styles = StyleSheet.create({
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
emphasisText: {
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,8 +8,9 @@ import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { PROFILE_URL } from '../utils/ApiUrls';
|
||||
import { setUserData, clearUser } from '../redux/userSlice';
|
||||
import { useTheme } from '../hooks/ui/useTheme';
|
||||
import { useFetch } from '../hooks/core/useFetch';
|
||||
|
||||
import FontsFamily from '../constents/FontsFamily';
|
||||
import { useFetch } from '../hooks/core/useFetch';
|
||||
|
||||
const SplashScreen = () => {
|
||||
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>();
|
||||
|
||||
@@ -5,17 +5,13 @@ import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
ToastAndroid,
|
||||
Image,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import LucideIcon from '../../components/common/LucideIcon';
|
||||
import { responsiveFontSize } from 'react-native-responsive-dimensions';
|
||||
import { useTheme } from '../../hooks/ui/useTheme';
|
||||
import { useCreate } from '../../hooks/core/useCreate';
|
||||
import { PAY_ALL_URL } from '../../utils/ApiUrls';
|
||||
import FontsFamily from '../../constents/FontsFamily';
|
||||
|
||||
export type StatusData = {
|
||||
@@ -70,20 +66,6 @@ const UserProfileStatus: React.FC<UserProfileStatusProps> = ({
|
||||
}
|
||||
}, [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(
|
||||
() => [
|
||||
{ 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 = () => (
|
||||
<View style={[styles.dueBar, { backgroundColor: theme.colors.surface, borderTopColor: theme.colors.border }]}>
|
||||
<View>
|
||||
@@ -214,15 +214,11 @@ const UserProfileStatus: React.FC<UserProfileStatusProps> = ({
|
||||
<Text style={[styles.totalValue, { color: theme.colors.textPrimary }]}>UGX{totalDueSelected}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.confirmButton, { backgroundColor: theme.colors.primary, opacity: submitting ? 0.7 : 1 }]}
|
||||
onPress={() => payAll({ groupIds: selectedGroupIds })}
|
||||
disabled={submitting || selectedGroupIds.length === 0}
|
||||
style={[styles.confirmButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={handleProceedToPayment}
|
||||
disabled={selectedGroupIds.length === 0}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator color={theme.colors.onPrimary} />
|
||||
) : (
|
||||
<Text style={[styles.confirmText, { color: theme.colors.onPrimary }]}>Confirm Payment</Text>
|
||||
)}
|
||||
<Text style={[styles.confirmText, { color: theme.colors.onPrimary }]}>Choose Method</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
LayoutAnimation,
|
||||
UIManager,
|
||||
@@ -28,7 +27,7 @@ interface Contribution {
|
||||
waivedAmount: number;
|
||||
totalAmount: number;
|
||||
status: 'SUCCESS' | 'PENDING' | 'FAILED';
|
||||
paidDate: [number, number, number];
|
||||
paidDate: [number, number, number] | string | null;
|
||||
}
|
||||
|
||||
interface Withdrawal {
|
||||
@@ -37,13 +36,13 @@ interface Withdrawal {
|
||||
baseAmount: number;
|
||||
totalAmount: number;
|
||||
status: 'SUCCESS' | 'PENDING' | 'FAILED';
|
||||
paidDate: [number, number, number];
|
||||
paidDate: [number, number, number] | string | null;
|
||||
}
|
||||
|
||||
interface Cycle {
|
||||
cycleNumber: number;
|
||||
cycleStart: [number, number, number];
|
||||
cycleEnd: [number, number, number];
|
||||
cycleStart: [number, number, number] | string | null;
|
||||
cycleEnd: [number, number, number] | string | null;
|
||||
expectedContribution: number;
|
||||
contributions: Contribution[];
|
||||
withdrawal: Withdrawal | null;
|
||||
@@ -87,11 +86,10 @@ const formatDate = (dateStr: string | null): string => {
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number): string => {
|
||||
return new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
return `UGX ${new Intl.NumberFormat('en-IN', {
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount || 0)}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -132,14 +130,15 @@ const CycleItem = ({ item, isExpanded, onToggle }: { item: Cycle, isExpanded: bo
|
||||
{/* Contributions List */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}>Contributions ({item.paidParticipants}/{item.totalParticipants})</Text>
|
||||
{item.contributions.map(contribution => (
|
||||
{item.contributions.length > 0 ? (
|
||||
item.contributions.map(contribution => (
|
||||
<View key={contribution.transactionId} style={[styles.contributionItem, {
|
||||
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 }}>
|
||||
<Text style={[styles.contributorName, { color: theme.colors.textPrimary }]} numberOfLines={1} > {contribution.userName}</Text>
|
||||
<Text style={[styles.contributorName, { color: theme.colors.textPrimary }]} numberOfLines={1}>{contribution.userName}</Text>
|
||||
<Text style={[styles.contributionDate, { color: theme.colors.textMuted }]}>{formatDate(contribution.paidDate as any)}</Text>
|
||||
<Text style={{
|
||||
color: contribution.status === 'SUCCESS' ? theme.colors.success : contribution.status === 'PENDING' ? theme.colors.warning : theme.colors.error,
|
||||
@@ -149,7 +148,14 @@ const CycleItem = ({ item, isExpanded, onToggle }: { item: Cycle, isExpanded: bo
|
||||
</View>
|
||||
<Text style={[styles.contributionAmount, { color: theme.colors.textPrimary }]}>{formatCurrency(contribution.totalAmount)}</Text>
|
||||
</View>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
@@ -163,13 +169,17 @@ const CycleItem = ({ item, isExpanded, onToggle }: { item: Cycle, isExpanded: bo
|
||||
const CycleDashboard = ({ data }: { data: CycleDashboardData }) => {
|
||||
const [expandedCycle, setExpandedCycle] = useState<number | null>(data.currentCycle);
|
||||
const theme = useTheme();
|
||||
const activeCycleNumber = data.cycles?.[0]?.cycleNumber ?? data.currentCycle ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedCycle(activeCycleNumber);
|
||||
}, [activeCycleNumber]);
|
||||
|
||||
const handleToggle = (cycleNumber: number) => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||
setExpandedCycle(expandedCycle === cycleNumber ? null : cycleNumber);
|
||||
};
|
||||
|
||||
console.log("expanded cycle : ", data)
|
||||
const renderHeader = () => (
|
||||
<View style={styles.headerContainer}>
|
||||
<Text style={[styles.groupName, { color: theme.colors.textPrimary }]}>{data.groupName}</Text>
|
||||
@@ -184,26 +194,24 @@ const CycleDashboard = ({ data }: { data: CycleDashboardData }) => {
|
||||
</View>
|
||||
<View style={styles.summaryBox}>
|
||||
<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>
|
||||
);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={data.cycles}
|
||||
keyExtractor={(item) => item.cycleNumber.toString()}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.container}>
|
||||
{renderHeader()}
|
||||
{data.cycles.map((item) => (
|
||||
<CycleItem
|
||||
key={item.cycleNumber.toString()}
|
||||
item={item}
|
||||
isExpanded={expandedCycle === item.cycleNumber}
|
||||
onToggle={() => handleToggle(item.cycleNumber)}
|
||||
/>
|
||||
)}
|
||||
ListHeaderComponent={renderHeader}
|
||||
contentContainerStyle={styles.container}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -320,5 +328,16 @@ const styles = StyleSheet.create({
|
||||
fontSize: responsiveFontSize(1.5),
|
||||
fontWeight: '500',
|
||||
},
|
||||
emptyState: {
|
||||
borderWidth: 1,
|
||||
borderStyle: 'dashed',
|
||||
borderRadius: 10,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
emptyStateText: {
|
||||
fontSize: responsiveFontSize(1.4),
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
@@ -8,8 +7,7 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ScrollView,
|
||||
Image,
|
||||
Modal
|
||||
useWindowDimensions
|
||||
} from "react-native";
|
||||
import LinearGradient from "react-native-linear-gradient";
|
||||
|
||||
@@ -25,77 +23,103 @@ import CycleDashboard from "./CycleDashboard";
|
||||
import SkeletonLoader from "../../../components/common/SkeletonLoader";
|
||||
import FontsFamily from "../../../constents/FontsFamily";
|
||||
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 theme = useTheme();
|
||||
const route = useRoute<any>();
|
||||
const navigation = useNavigation<any>();
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const groupId = route?.params?.item?.id || route?.params?.groupId;
|
||||
|
||||
const [memberData, setMemberData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
const [payable, setPayable] = useState<any>();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [cycleDashboardData, setCycleDashboardData] = useState<any>([]);
|
||||
const [selectedCycleNumber, setSelectedCycleNumber] = useState(null);
|
||||
const [cycleItems, setCycleItems] = useState([]);
|
||||
const [cycleDashboardData, setCycleDashboardData] = useState<any>(null);
|
||||
const [selectedCycleNumber, setSelectedCycleNumber] = useState<number | null>(null);
|
||||
const [selectedUser, setSelectedUser] = useState() as string | any;
|
||||
|
||||
const group = {
|
||||
id: route.params.item?.id,
|
||||
name: route.params.item?.groupName,
|
||||
collected: route.params.item?.contributionAmount,
|
||||
goal: route.params.item?.contributionAmount * route.params.item?.currentParticipants,
|
||||
description: route.params.item?.description,
|
||||
status: route.params.item?.status,
|
||||
id: route?.params?.item?.id,
|
||||
name: route?.params?.item?.groupName,
|
||||
collected: route?.params?.item?.contributionAmount,
|
||||
goal: route?.params?.item?.contributionAmount * route?.params?.item?.currentParticipants,
|
||||
description: route?.params?.item?.description,
|
||||
status: route?.params?.item?.status,
|
||||
members: memberData,
|
||||
};
|
||||
|
||||
const getPaymentStatus = async () => {
|
||||
const response = await apiCall(`${BASE_URL}payments/group/${group.id || route?.params?.groupId}/payment/paid`, 'GET') as any;
|
||||
const getPaymentStatus = useCallback(async () => {
|
||||
const response = await apiCall(`${BASE_URL}payments/group/${groupId}/payment/paid`, 'GET') as any;
|
||||
if (response.status === 'success') {
|
||||
setSelectedUser(response.data);
|
||||
}
|
||||
}, [groupId]);
|
||||
|
||||
const getCycleDashboard = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiCall(`${BASE_URL}payments/groups/${groupId}/cycle-dashboard`, "GET") as any;
|
||||
const responseData = response.data;
|
||||
setCycleDashboardData(responseData || null);
|
||||
if (responseData?.cycles) {
|
||||
setSelectedCycleNumber((previousCycleNumber) => {
|
||||
const hasPreviousSelection = responseData.cycles.some(
|
||||
(cycle: any) => cycle.cycleNumber === previousCycleNumber
|
||||
);
|
||||
|
||||
if (hasPreviousSelection) {
|
||||
return previousCycleNumber;
|
||||
}
|
||||
|
||||
const getCycleDashboard = async () => {
|
||||
try {
|
||||
const response = await apiCall(`${BASE_URL}payments/groups/${group.id || route?.params?.groupId}/cycle-dashboard`, "GET") as any;
|
||||
const responseData = response.data;
|
||||
setCycleDashboardData(responseData || []);
|
||||
if (responseData?.cycles) {
|
||||
const items = responseData.cycles.map((cycle: any) => ({
|
||||
label: `Cycle ${cycle.cycleNumber}`,
|
||||
value: cycle.cycleNumber
|
||||
}));
|
||||
setCycleItems(items);
|
||||
if (responseData.currentCycle) {
|
||||
setSelectedCycleNumber(responseData.currentCycle);
|
||||
return responseData.currentCycle;
|
||||
}
|
||||
|
||||
return responseData.cycles[0]?.cycleNumber ?? null;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
ToastAndroid.show("Failed to fetch cycle data", ToastAndroid.SHORT);
|
||||
}
|
||||
};
|
||||
}, [groupId]);
|
||||
|
||||
const fetchGroupDetails = async () => {
|
||||
const fetchGroupDetails = useCallback(async () => {
|
||||
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);
|
||||
} catch (error: any) {
|
||||
ToastAndroid.show(error?.message || "Failed to fetch group details", ToastAndroid.SHORT);
|
||||
}
|
||||
};
|
||||
}, [groupId]);
|
||||
|
||||
const handleContribution = async () => {
|
||||
const handleContribution = useCallback(async () => {
|
||||
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);
|
||||
} catch (error) {
|
||||
console.log("Error in handleContribution: ", error);
|
||||
}
|
||||
};
|
||||
}, [groupId]);
|
||||
|
||||
const fetchAllData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -105,14 +129,14 @@ const GroupDetails = () => {
|
||||
getCycleDashboard()
|
||||
]);
|
||||
setLoading(false);
|
||||
}, [group.id || route?.params?.groupId]);
|
||||
}, [fetchGroupDetails, getCycleDashboard, handleContribution]);
|
||||
|
||||
useEffect(() => {
|
||||
if (group.id || route?.params?.groupId) {
|
||||
if (groupId) {
|
||||
fetchAllData();
|
||||
getPaymentStatus();
|
||||
}
|
||||
}, [group.id || route?.params?.groupId, fetchAllData]);
|
||||
}, [fetchAllData, getPaymentStatus, groupId]);
|
||||
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
@@ -120,26 +144,19 @@ const GroupDetails = () => {
|
||||
await fetchAllData();
|
||||
await getPaymentStatus();
|
||||
setRefreshing(false);
|
||||
}, [fetchAllData]);
|
||||
}, [fetchAllData, getPaymentStatus]);
|
||||
|
||||
|
||||
const confirmPayment = async () => {
|
||||
try {
|
||||
const res = await apiCall(`${BASE_URL}payments/contribute/${group?.id}?amount=${payable?.totalPayable}`, 'POST') as any;
|
||||
console.log('payment res ', res)
|
||||
if (res.status === 200 || res.status === 201 || res.statusCode === 200) {
|
||||
ToastAndroid.show('Payment done!', ToastAndroid.SHORT);
|
||||
onRefresh();
|
||||
} else {
|
||||
ToastAndroid.show(res?.data?.message || 'Payment failed!', ToastAndroid.SHORT);
|
||||
}
|
||||
} catch (error) {
|
||||
ToastAndroid.show("An error occurred during payment.", ToastAndroid.SHORT);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const selectedCycleData = cycleDashboardData?.cycles?.find((c: any) => c.cycleNumber === selectedCycleNumber);
|
||||
const selectedCycleData =
|
||||
cycleDashboardData?.cycles?.find((c: any) => c.cycleNumber === selectedCycleNumber)
|
||||
|| cycleDashboardData?.cycles?.[0];
|
||||
const currentCycleNumber = cycleDashboardData?.currentCycle;
|
||||
const cycleCount = cycleDashboardData?.cycles?.length || 0;
|
||||
const shouldScrollCycles = cycleCount > 2;
|
||||
const cycleCardWidth = cycleCount <= 1
|
||||
? windowWidth - 64
|
||||
: shouldScrollCycles
|
||||
? Math.max(156, Math.min(188, (windowWidth - 92) / 2))
|
||||
: (windowWidth - 76) / 2;
|
||||
|
||||
const renderSkeletons = () => (
|
||||
<View style={styles.skeletonContainer}>
|
||||
@@ -150,7 +167,7 @@ const GroupDetails = () => {
|
||||
);
|
||||
|
||||
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 */}
|
||||
<View style={[styles.header, { backgroundColor: theme.colors.background }]}>
|
||||
<TouchableOpacity onPress={() => navigation.goBack()} style={[styles.iconButton, { backgroundColor: theme.colors.card }]}>
|
||||
@@ -163,11 +180,14 @@ const GroupDetails = () => {
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={[theme.colors.primary]} tintColor={theme.colors.onPrimary} />
|
||||
}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
nestedScrollEnabled={true}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{loading ? renderSkeletons() : (
|
||||
<View style={styles.contentPadding}>
|
||||
@@ -255,18 +275,156 @@ const GroupDetails = () => {
|
||||
</View>
|
||||
|
||||
{/* Cycle Timeline */}
|
||||
{cycleItems.length > 0 && (
|
||||
{cycleDashboardData?.cycles?.length > 0 && (
|
||||
<View style={styles.sectionContainer}>
|
||||
<Text style={[styles.sectionHeader, { color: theme.colors.textPrimary, fontFamily: FontsFamily.nunitoBold }]}>Cycle Statement</Text>
|
||||
{/* <CustomDropdown
|
||||
items={cycleItems}
|
||||
selectedValue={selectedCycleNumber}
|
||||
onValueChange={setSelectedCycleNumber}
|
||||
placeholder="Select Statement Cycle"
|
||||
/> */}
|
||||
<View style={[styles.cycleSelectorShell, { backgroundColor: theme.colors.card, borderColor: theme.colors.borderLight }]}>
|
||||
<View style={styles.cycleSelectorHeader}>
|
||||
<View style={styles.cycleSelectorCopy}>
|
||||
<Text style={[styles.cycleSelectorEyebrow, { color: theme.colors.textMuted, fontFamily: FontsFamily.nunitoSemiBold }]}>
|
||||
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 && (
|
||||
<View style={{ marginTop: 12 }}>
|
||||
<CycleDashboard data={{ ...cycleDashboardData, cycles: [selectedCycleData] }} />
|
||||
<CycleDashboard
|
||||
data={{
|
||||
...cycleDashboardData,
|
||||
currentCycle: selectedCycleData.cycleNumber,
|
||||
cycles: [selectedCycleData]
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -300,9 +458,7 @@ const GroupDetails = () => {
|
||||
{member.role === "Organizer" ? "Group Admin" : "Member"}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={[styles.contactAction, { backgroundColor: theme.colors.background }]}>
|
||||
<LucideIcon name="chatbubble-ellipses-outline" size={18} color={theme.colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -314,7 +470,7 @@ const GroupDetails = () => {
|
||||
onPress={() => setOpenModal(true)}
|
||||
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 }]}>
|
||||
Pay Contribution
|
||||
</Text>
|
||||
@@ -332,7 +488,8 @@ const GroupDetails = () => {
|
||||
onConfirm={() => {
|
||||
setOpenModal(false);
|
||||
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),
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 100,
|
||||
flexGrow: 1,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
contentPadding: {
|
||||
paddingHorizontal: 16,
|
||||
@@ -529,6 +687,112 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 12,
|
||||
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
|
||||
contactsContainer: {
|
||||
borderRadius: 20,
|
||||
|
||||
@@ -543,7 +543,7 @@ const EditGroupScreen: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{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>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
@@ -592,7 +592,7 @@ const EditGroupScreen: React.FC = () => {
|
||||
<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>
|
||||
{/* <LucideIcon name="arrow-forward" size={20} color={ theme.colors.reverseText} style={{ marginLeft: 8, opacity: 0.8 }} /> */}
|
||||
</>
|
||||
@@ -709,7 +709,7 @@ const getStyles = (theme: any) => StyleSheet.create({
|
||||
|
||||
// Participants
|
||||
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 },
|
||||
pRole: { fontSize: 12, color: theme.colors.textSecondary },
|
||||
viewAllBtn: { paddingVertical: 8, alignItems: 'center', borderTopWidth: 1, borderTopColor: '#f0f0f0', marginTop: 8 },
|
||||
@@ -718,9 +718,9 @@ const getStyles = (theme: any) => StyleSheet.create({
|
||||
// Actions
|
||||
actionsContainer: { padding: 20 },
|
||||
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' },
|
||||
secondaryBtnText: { color: theme.colors.textPrimary, fontSize: 16, fontFamily: FontsFamily.nunitoBold },
|
||||
secondaryBtnText: { color: theme.colors.reverseText, fontSize: 16, fontFamily: FontsFamily.nunitoBold },
|
||||
|
||||
// Modal
|
||||
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -84,83 +84,139 @@ const ManagementScreen: React.FC = () => {
|
||||
setActiveTab(routeTab);
|
||||
}, [route.params?.actionTab]);
|
||||
|
||||
const myGroupsParams = useMemo(() => {
|
||||
return {
|
||||
const myGroupsParams = {
|
||||
...(myGroupStatusFilter && myGroupStatusFilter !== 'ALL'
|
||||
? { status: myGroupStatusFilter }
|
||||
: {}),
|
||||
page: 0,
|
||||
size: 10,
|
||||
};
|
||||
}, [myGroupStatusFilter]);
|
||||
|
||||
const joinedGroupsParams = useMemo(() => {
|
||||
return {
|
||||
const joinedGroupsParams = {
|
||||
...(joinedGroupStatusFilter && joinedGroupStatusFilter !== 'ALL'
|
||||
? { status: joinedGroupStatusFilter }
|
||||
: {}),
|
||||
page: 0,
|
||||
size: 10,
|
||||
};
|
||||
}, [joinedGroupStatusFilter]);
|
||||
|
||||
const requestsParams = useMemo(() => ({ page: 0, size: 10 }), []);
|
||||
const myRequestsParams = useMemo(() => ({ page: 0, size: 10 }), []);
|
||||
const requestsParams = { 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
|
||||
const { data: myGroupsRes, isLoading: myLoading, refetch: refetchMy } = useFetch<any>(
|
||||
const {
|
||||
data: myGroupsRes,
|
||||
isLoading: myLoading,
|
||||
isFetching: myFetching,
|
||||
refetch: refetchMy,
|
||||
} = useFetch<any>(
|
||||
MY_GROUPS_URL,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
myRequestsParams
|
||||
myRequestsParams,
|
||||
{
|
||||
enabled:
|
||||
activeTab === 'My Requests' ||
|
||||
(user.role === 'ORGANIZER' && activeTab === 'Requests' && requestSubTab === 'My Requests'),
|
||||
...alwaysFreshFetchOptions,
|
||||
}
|
||||
);
|
||||
|
||||
const { mutate: updateRequest } = useUpdate<any, any>('');
|
||||
|
||||
const myGroups = useMemo(() => getListFromResponse<MyGroup>(myGroupsRes), [myGroupsRes]);
|
||||
const joinedGroups = useMemo(() => getListFromResponse<any>(joinedRes), [joinedRes]);
|
||||
const incomingRequests = useMemo(() => getListFromResponse<JoinRequest>(requestsRes), [requestsRes]);
|
||||
const myRequests = useMemo(() => getListFromResponse<any>(userReqRes), [userReqRes]);
|
||||
const myGroups = getListFromResponse<MyGroup>(myGroupsRes);
|
||||
const joinedGroups = getListFromResponse<any>(joinedRes);
|
||||
const incomingRequests = getListFromResponse<JoinRequest>(requestsRes);
|
||||
const myRequests = getListFromResponse<any>(userReqRes);
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const onRefresh = useCallback(() => {
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
Promise.all([
|
||||
refetchMy(),
|
||||
refetchJoined(),
|
||||
refetchRequests(),
|
||||
refetchUserReq(),
|
||||
]).finally(() => {
|
||||
const refreshTasks = [refetchJoined(), refetchUserReq()];
|
||||
|
||||
if (user.role === 'ORGANIZER') {
|
||||
refreshTasks.push(refetchMy(), refetchRequests());
|
||||
}
|
||||
|
||||
await Promise.all(refreshTasks).finally(() => {
|
||||
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(
|
||||
useCallback(() => {
|
||||
onRefresh();
|
||||
}, [])
|
||||
refetchActiveTab();
|
||||
}, [refetchActiveTab])
|
||||
);
|
||||
|
||||
const tabs: ActiveTab[] = useMemo(() =>
|
||||
const tabs: ActiveTab[] =
|
||||
user.role === 'ORGANIZER'
|
||||
? ['My Groups', 'Joined Groups', 'Requests']
|
||||
: ['Joined Groups', 'My Requests'],
|
||||
[user.role]);
|
||||
: ['Joined Groups', 'My Requests'];
|
||||
|
||||
const handleApprove = (req: JoinRequest) => {
|
||||
updateRequest({ resource: `${APPROVE_REQUEST_URL}${req.requestId}/approve`, payload: {} } as any, {
|
||||
@@ -173,7 +229,7 @@ const ManagementScreen: React.FC = () => {
|
||||
};
|
||||
|
||||
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: () => {
|
||||
ToastAndroid.show('Request declined', ToastAndroid.SHORT);
|
||||
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 (
|
||||
<AppView paddingHorizontal={0}>
|
||||
@@ -369,7 +441,7 @@ const ManagementScreen: React.FC = () => {
|
||||
)}
|
||||
|
||||
<FlatList
|
||||
data={getFilteredData()}
|
||||
data={listData}
|
||||
contentContainerStyle={styles.listContent}
|
||||
keyExtractor={(item, index) => String(item.id || item.requestId || index)}
|
||||
renderItem={({ item }) => {
|
||||
@@ -393,7 +465,7 @@ const ManagementScreen: React.FC = () => {
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
!isLoading ? (
|
||||
!isActiveTabLoading && !refreshing ? (
|
||||
<View style={styles.emptyState}>
|
||||
<LucideIcon name="documents-outline" size={64} color={theme.colors.textMuted} />
|
||||
<Text style={[styles.emptyText, { color: theme.colors.textSecondary }]}>Nothing to show here</Text>
|
||||
|
||||
@@ -13,18 +13,18 @@ import {
|
||||
import LucideIcon from '../../components/common/LucideIcon';
|
||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||
import { responsiveFontSize } from 'react-native-responsive-dimensions';
|
||||
|
||||
import AppView from '../../components/AppView';
|
||||
import { useTheme } from '../../hooks/ui/useTheme';
|
||||
import { useFetch } from '../../hooks/core/useFetch';
|
||||
import { useUpdate } from '../../hooks/core/useUpdate';
|
||||
import { apiCall } from '../../utils/ApiHelper';
|
||||
import { BASE_URL } from '../../utils/ApiUrls';
|
||||
import FontsFamily from '../../constents/FontsFamily';
|
||||
import SkeletonLoader from '../../components/common/SkeletonLoader';
|
||||
import Header from '../../components/common/Header';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const NUM_COLUMNS = 3;
|
||||
const ITEM_SIZE = width / NUM_COLUMNS - 28;
|
||||
const ITEM_SIZE = width / NUM_COLUMNS - 24; // Increased size slightly
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -159,7 +159,7 @@ const RandomSelectUserScreen: React.FC = () => {
|
||||
const itemAnims = useRef<Animated.Value[]>([]).current;
|
||||
const winnerScaleAnim = useRef(new Animated.Value(1)).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 headerPulseAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
@@ -173,13 +173,10 @@ const RandomSelectUserScreen: React.FC = () => {
|
||||
[itemAnims],
|
||||
);
|
||||
|
||||
// --- DATA FETCHING ---
|
||||
const { data: participantsRes, isLoading: isParticipantsLoading } = useFetch<any>(
|
||||
`${BASE_URL}fund/group/all/member?groupId=${groupId}`,
|
||||
{},
|
||||
{
|
||||
enabled: !!groupId,
|
||||
},
|
||||
{ enabled: !!groupId },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -194,35 +191,34 @@ const RandomSelectUserScreen: React.FC = () => {
|
||||
}
|
||||
}, [participantsRes, ensureAnims]);
|
||||
|
||||
// --- MUTATIONS ---
|
||||
const { mutate: selectWinnerApi } = useUpdate<any, any>(
|
||||
const selectWinnerApi = async () => {
|
||||
try {
|
||||
const res = await apiCall<any>(
|
||||
`${BASE_URL}fund/method/based/userSelect`,
|
||||
{
|
||||
onSuccess: (res: any) => {
|
||||
if (res.status === 'success' && res.data?.selectedUser?.id) {
|
||||
'POST',
|
||||
{ groupId },
|
||||
);
|
||||
if (res?.status === 'success' && res?.data?.selectedUser?.id) {
|
||||
const winnerData = {
|
||||
id: res.data.selectedUser.id,
|
||||
name: `${res.data.selectedUser.firstName} ${res.data.selectedUser.lastName}`,
|
||||
};
|
||||
const winnerIndex = users.findIndex(u => u.id === winnerData.id);
|
||||
|
||||
if (winnerIndex !== -1) {
|
||||
triggerWinnerReveal(winnerData, winnerIndex);
|
||||
} else {
|
||||
ToastAndroid.show('Winner not found among participants', ToastAndroid.SHORT);
|
||||
ToastAndroid.show('Winner lookup failed', ToastAndroid.SHORT);
|
||||
resetAnimation();
|
||||
}
|
||||
} else {
|
||||
ToastAndroid.show('Failed to determine a winner', ToastAndroid.SHORT);
|
||||
ToastAndroid.show('Failed to select winner', ToastAndroid.SHORT);
|
||||
resetAnimation();
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
ToastAndroid.show(err?.message || 'Selection failed', ToastAndroid.SHORT);
|
||||
} catch (err: any) {
|
||||
ToastAndroid.show(err?.message || 'Api Error', ToastAndroid.SHORT);
|
||||
resetAnimation();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const resetAnimation = () => {
|
||||
setIsAnimating(false);
|
||||
@@ -231,26 +227,17 @@ const RandomSelectUserScreen: React.FC = () => {
|
||||
setShowWinnerReveal(false);
|
||||
winnerScaleAnim.setValue(1);
|
||||
winnerGlowAnim.setValue(0);
|
||||
bannerSlideAnim.setValue(100);
|
||||
bannerSlideAnim.setValue(200);
|
||||
bannerOpacity.setValue(0);
|
||||
itemAnims.forEach(a => a.setValue(0));
|
||||
};
|
||||
|
||||
// --- HEADER PULSE during animation ---
|
||||
useEffect(() => {
|
||||
if (phase === 'scanning' || phase === 'slowing') {
|
||||
const pulse = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(headerPulseAnim, {
|
||||
toValue: 1.05,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(headerPulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(headerPulseAnim, { toValue: 1.05, duration: 600, useNativeDriver: true }),
|
||||
Animated.timing(headerPulseAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
|
||||
]),
|
||||
);
|
||||
pulse.start();
|
||||
@@ -260,100 +247,61 @@ const RandomSelectUserScreen: React.FC = () => {
|
||||
}
|
||||
}, [phase, headerPulseAnim]);
|
||||
|
||||
// --- WINNER REVEAL ANIMATION ---
|
||||
const triggerWinnerReveal = (winnerData: { id: string; name: string }, winnerIdx: number) => {
|
||||
setPhase('reveal');
|
||||
setWinner(winnerData);
|
||||
setHighlightedIndex(winnerIdx);
|
||||
setShowWinnerReveal(true);
|
||||
|
||||
// 1. Pop all items down except winner
|
||||
const itemAnimations = itemAnims.map((anim, i) => {
|
||||
if (i === winnerIdx) {
|
||||
return Animated.timing(anim, {
|
||||
toValue: 2, // special "winner" state
|
||||
duration: 400,
|
||||
toValue: 2,
|
||||
duration: 500,
|
||||
easing: Easing.out(Easing.back(1.5)),
|
||||
useNativeDriver: true,
|
||||
});
|
||||
}
|
||||
return Animated.timing(anim, {
|
||||
toValue: -1, // "dim" state
|
||||
toValue: -1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
});
|
||||
});
|
||||
|
||||
// 2. Winner scale + glow
|
||||
const winnerReveal = Animated.parallel([
|
||||
Animated.spring(winnerScaleAnim, {
|
||||
toValue: 1.2,
|
||||
friction: 4,
|
||||
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,
|
||||
}),
|
||||
const revealGroup = Animated.parallel([
|
||||
Animated.spring(winnerScaleAnim, { toValue: 1.25, friction: 3, tension: 70, useNativeDriver: true }),
|
||||
Animated.timing(winnerGlowAnim, { toValue: 1, duration: 800, useNativeDriver: true }),
|
||||
Animated.spring(bannerSlideAnim, { toValue: 0, friction: 6, tension: 50, useNativeDriver: true }),
|
||||
Animated.timing(bannerOpacity, { toValue: 1, duration: 600, useNativeDriver: true }),
|
||||
]);
|
||||
|
||||
Animated.sequence([
|
||||
Animated.parallel(itemAnimations),
|
||||
Animated.delay(200),
|
||||
winnerReveal,
|
||||
Animated.delay(300),
|
||||
bannerReveal,
|
||||
Animated.delay(100),
|
||||
revealGroup,
|
||||
]).start(() => {
|
||||
setTimeout(() => {
|
||||
navigation.replace('EditGroup', { groupId });
|
||||
}, 2500);
|
||||
}, 3500);
|
||||
});
|
||||
};
|
||||
|
||||
// --- MAIN SELECTION ANIMATION (3-phase spotlight roulette) ---
|
||||
const handleStartSelection = () => {
|
||||
const availableUsers = users.filter(u => !u.selected);
|
||||
|
||||
if (availableUsers.length === 0 || isAnimating) {
|
||||
ToastAndroid.show(
|
||||
'All users are already selected or selection in progress',
|
||||
ToastAndroid.SHORT,
|
||||
);
|
||||
ToastAndroid.show('All members selected already', ToastAndroid.SHORT);
|
||||
return;
|
||||
}
|
||||
|
||||
resetAnimation();
|
||||
setWinner(null);
|
||||
setIsAnimating(true);
|
||||
setPhase('scanning');
|
||||
|
||||
// Phase 1: Fast random scanning (accelerate then sustain)
|
||||
let count = 0;
|
||||
const totalPhase1 = 18;
|
||||
const totalPhase2 = 15;
|
||||
const totalPhase1 = 15;
|
||||
const totalPhase2 = 12;
|
||||
|
||||
const runPhase = (
|
||||
phaseCount: number,
|
||||
getDelay: (i: number) => number,
|
||||
onComplete: () => void,
|
||||
) => {
|
||||
const runPhase = (phaseCount: number, getDelay: (i: number) => number, onComplete: () => void) => {
|
||||
let i = 0;
|
||||
const step = () => {
|
||||
if (i >= phaseCount) {
|
||||
@@ -365,14 +313,9 @@ const RandomSelectUserScreen: React.FC = () => {
|
||||
const origIdx = users.findIndex(u => u.id === user.id);
|
||||
setHighlightedIndex(origIdx);
|
||||
|
||||
// Animate the highlighted item
|
||||
ensureAnims(users.length);
|
||||
itemAnims.forEach((anim, idx) => {
|
||||
Animated.timing(anim, {
|
||||
toValue: idx === origIdx ? 1 : 0,
|
||||
duration: 60,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
Animated.timing(anim, { toValue: idx === origIdx ? 1 : 0, duration: 60, useNativeDriver: true }).start();
|
||||
});
|
||||
|
||||
i++;
|
||||
@@ -381,372 +324,152 @@ const RandomSelectUserScreen: React.FC = () => {
|
||||
step();
|
||||
};
|
||||
|
||||
// Phase 1: Fast scanning — starts moderate, gets fast
|
||||
runPhase(
|
||||
totalPhase1,
|
||||
i => Math.max(50, 150 - i * 6),
|
||||
() => {
|
||||
runPhase(totalPhase1, i => Math.max(50, 150 - i * 8), () => {
|
||||
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 });
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
runPhase(totalPhase2, i => 100 + i * 50 + Math.pow(i, 1.5) * 10, () => {
|
||||
selectWinnerApi();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// --- RENDER ITEM ---
|
||||
const renderUserCard = (item: User, index: number) => {
|
||||
const isHighlighted = index === highlightedIndex;
|
||||
const isWinnerCard = winner?.id === item.id && phase === 'reveal';
|
||||
ensureAnims(users.length);
|
||||
const itemAnim = itemAnims[index] || new Animated.Value(0);
|
||||
|
||||
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 (
|
||||
<Animated.View
|
||||
key={item.id}
|
||||
style={[
|
||||
styles.gridItem,
|
||||
{
|
||||
backgroundColor: cardBg,
|
||||
borderColor: borderColor,
|
||||
borderWidth: isHighlighted || isWinnerCard ? 2.5 : 1,
|
||||
transform: [
|
||||
{
|
||||
scale: isWinnerCard
|
||||
? winnerScaleAnim
|
||||
: itemAnim.interpolate({
|
||||
inputRange: [-1, 0, 1],
|
||||
outputRange: [0.92, 1, 1.08],
|
||||
extrapolate: 'clamp',
|
||||
backgroundColor: isWinnerCard
|
||||
? theme.colors.success + '10'
|
||||
: item.selected ? theme.colors.surface : theme.colors.card,
|
||||
borderColor: isWinnerCard
|
||||
? theme.colors.success
|
||||
: isHighlighted ? theme.colors.primary : theme.colors.borderLight,
|
||||
borderWidth: isHighlighted || isWinnerCard ? 2 : 1,
|
||||
transform: [{
|
||||
scale: isWinnerCard ? winnerScaleAnim : itemAnim.interpolate({
|
||||
inputRange: [-1, 0, 1], outputRange: [0.94, 1, 1.1], extrapolate: 'clamp',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}],
|
||||
opacity: itemAnim.interpolate({
|
||||
inputRange: [-1, 0, 1, 2],
|
||||
outputRange: [0.4, 1, 1, 1],
|
||||
extrapolate: 'clamp',
|
||||
}),
|
||||
// Elevated shadow for highlighted / winner
|
||||
...(isHighlighted && !isWinnerCard
|
||||
? {
|
||||
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,
|
||||
inputRange: [-1, 0, 1], outputRange: [0.35, 1, 1], extrapolate: 'clamp',
|
||||
}),
|
||||
shadowColor: isWinnerCard ? theme.colors.success : theme.colors.primary,
|
||||
shadowOpacity: isHighlighted || isWinnerCard ? 0.3 : 0,
|
||||
elevation: isHighlighted || isWinnerCard ? 10 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* 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 && (
|
||||
<View style={[StyleSheet.absoluteFill, { alignItems: 'center', justifyContent: 'center' }]}>
|
||||
<PulseRing delay={0} color={theme.colors.success} size={ITEM_SIZE} />
|
||||
<PulseRing delay={250} color={theme.colors.success} size={ITEM_SIZE} />
|
||||
<PulseRing delay={500} color={theme.colors.success} size={ITEM_SIZE} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
<PulseRing delay={300} color={theme.colors.success} size={ITEM_SIZE} />
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<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} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Winner crown badge */}
|
||||
{isWinnerCard && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.crownBadge,
|
||||
{
|
||||
backgroundColor: theme.colors.success,
|
||||
opacity: winnerGlowAnim,
|
||||
transform: [
|
||||
{
|
||||
scale: winnerGlowAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, 1],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Animated.View style={[styles.winnerIcon, { backgroundColor: theme.colors.success, opacity: winnerGlowAnim }]}>
|
||||
<LucideIcon name="trophy" size={12} color={theme.colors.white} />
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Already selected badge */}
|
||||
{item.selected && !isWinnerCard && (
|
||||
<View style={[styles.selectedBadge, { backgroundColor: theme.colors.surface }]}>
|
||||
<LucideIcon name="checkmark" size={10} color={theme.colors.textMuted} />
|
||||
<View style={[styles.disabledOverlay, { backgroundColor: theme.colors.surface + '80' }]}>
|
||||
<LucideIcon name="checkmark-circle" size={20} color={theme.colors.textMuted} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Avatar circle */}
|
||||
<View
|
||||
style={[
|
||||
styles.avatarCircle,
|
||||
{
|
||||
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 style={[styles.avatarBox, {
|
||||
backgroundColor: isWinnerCard ? theme.colors.success + '20' : theme.colors.surface
|
||||
}]}>
|
||||
<LucideIcon name={isWinnerCard ? "trophy" : "person"} size={26} color={isWinnerCard ? theme.colors.success : theme.colors.textSecondary} />
|
||||
</View>
|
||||
|
||||
{/* Name */}
|
||||
<Text
|
||||
style={[
|
||||
styles.gridText,
|
||||
{
|
||||
color: textColor,
|
||||
fontFamily: isWinnerCard ? FontsFamily.nunitoBold : FontsFamily.nunitoMedium,
|
||||
},
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
<Text style={[styles.userName, {
|
||||
color: isWinnerCard ? theme.colors.success : item.selected ? theme.colors.textMuted : theme.colors.textPrimary,
|
||||
fontFamily: isWinnerCard ? FontsFamily.nunitoExtraBold : FontsFamily.nunitoBold,
|
||||
}]} numberOfLines={2}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</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) {
|
||||
return (
|
||||
<AppView style={{ backgroundColor: theme.colors.background }}>
|
||||
<View style={styles.header}>
|
||||
<SkeletonLoader width={220} height={36} style={{ marginBottom: 10, borderRadius: 8 }} />
|
||||
<SkeletonLoader width={140} height={18} style={{ borderRadius: 6 }} />
|
||||
</View>
|
||||
<View style={styles.skeletonGrid}>
|
||||
<AppView backgroundColor={theme.colors.background}>
|
||||
<Header title="Winner Selection" showBack={true} />
|
||||
<View style={styles.loadingContainer}>
|
||||
{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>
|
||||
</AppView>
|
||||
);
|
||||
}
|
||||
|
||||
// --- MAIN RENDER ---
|
||||
return (
|
||||
<AppView paddingHorizontal={0} style={{ backgroundColor: theme.colors.background }}>
|
||||
{/* Header */}
|
||||
<Animated.View style={[styles.header, { transform: [{ scale: headerPulseAnim }] }]}>
|
||||
<Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}>
|
||||
{phase === 'reveal' ? '🏆 Winner!' : '🎯 Winner Selection'}
|
||||
</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>
|
||||
<AppView paddingHorizontal={0} backgroundColor={theme.colors.surface}>
|
||||
<Header title="Payout Draw" showBack={true} />
|
||||
|
||||
<Animated.View style={[styles.hero, { transform: [{ scale: headerPulseAnim }] }]}>
|
||||
<View style={[styles.tag, { backgroundColor: theme.colors.primarySoft }]}>
|
||||
<Text style={[styles.tagText, { color: theme.colors.primary }]}>Cycle #{cycleNumber}</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.phaseText,
|
||||
{
|
||||
color:
|
||||
phase === 'reveal'
|
||||
? theme.colors.success
|
||||
: phase !== 'idle'
|
||||
? theme.mode === 'dark' ? '#818CF8' : '#6366F1'
|
||||
: theme.colors.textMuted,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{getPhaseText()}
|
||||
<Text style={[styles.phaseTitle, { color: theme.colors.textPrimary }]}>
|
||||
{phase === 'reveal' ? 'Lucky Winner!' : phase === 'idle' ? 'Select Receiver' : 'Selecting...'}
|
||||
</Text>
|
||||
<Text style={[styles.phaseSub, { color: phase === 'reveal' ? theme.colors.success : theme.colors.textSecondary }]}>
|
||||
{phase === 'reveal' ? 'Fund receiver for this month' : phase !== 'idle' ? 'Determining the fair winner...' : 'Tap the button below to draw'}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* Participants Grid */}
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.grid}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.gridInner}>
|
||||
{users.map((user, index) => renderUserCard(user, index))}
|
||||
<ScrollView contentContainerStyle={styles.scrollContainer} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.userGrid}>
|
||||
{users.map((u, i) => renderUserCard(u, i))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer */}
|
||||
<View
|
||||
style={[
|
||||
styles.footer,
|
||||
{
|
||||
backgroundColor: theme.colors.background,
|
||||
borderTopColor: theme.colors.borderLight,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Winner Banner */}
|
||||
<View style={[styles.stickyFooter, { backgroundColor: theme.colors.card, borderTopColor: theme.colors.borderLight }]}>
|
||||
{winner && phase === 'reveal' && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.winnerBanner,
|
||||
{
|
||||
backgroundColor: theme.colors.success,
|
||||
transform: [{ translateY: bannerSlideAnim }],
|
||||
opacity: bannerOpacity,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.trophyCircle}>
|
||||
<LucideIcon name="trophy" size={20} color={theme.colors.success} />
|
||||
<Animated.View style={[styles.winnerFloat, { backgroundColor: theme.colors.success, opacity: bannerOpacity, transform: [{ translateY: bannerSlideAnim }] }]}>
|
||||
<View style={styles.glassCircle}>
|
||||
<LucideIcon name="trophy" size={20} color={theme.colors.white} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.winnerLabel, { color: 'rgba(255,255,255,0.7)' }]}>
|
||||
WINNER
|
||||
</Text>
|
||||
<Text style={[styles.winnerName, { color: theme.colors.white }]}>
|
||||
{winner.name}
|
||||
</Text>
|
||||
<Text style={styles.winnerFloatLabel}>WINNER</Text>
|
||||
<Text style={styles.winnerFloatName}>{winner.name}</Text>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Action Button */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.actionButton,
|
||||
{
|
||||
backgroundColor: isAnimating
|
||||
? theme.colors.surface
|
||||
: phase === 'reveal'
|
||||
? theme.colors.success
|
||||
: theme.mode === 'dark' ? '#818CF8' : '#6366F1',
|
||||
},
|
||||
]}
|
||||
style={[styles.mainBtn, {
|
||||
backgroundColor: isAnimating ? theme.colors.surface : (winner ? theme.colors.success : theme.colors.primary)
|
||||
}]}
|
||||
onPress={handleStartSelection}
|
||||
disabled={isAnimating}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{isAnimating && phase !== 'reveal' ? (
|
||||
<View style={styles.buttonContent}>
|
||||
<View style={styles.btnContent}>
|
||||
<LucideIcon
|
||||
name="sync-outline"
|
||||
size={18}
|
||||
color={theme.colors.textMuted}
|
||||
name={isAnimating ? "sync-outline" : "sparkles-outline"}
|
||||
size={20}
|
||||
color={isAnimating ? theme.colors.reverseText : theme.colors.reverseText}
|
||||
/>
|
||||
<Text style={[styles.actionButtonText, { color: theme.colors.textMuted }]}>
|
||||
Selecting...
|
||||
<Text style={[styles.btnText, { color: isAnimating ? theme.colors.reverseText : theme.colors.reverseText }]}>
|
||||
{isAnimating ? 'Selecting...' : winner ? 'Select Again' : 'Shuffle & Select'}
|
||||
</Text>
|
||||
</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>
|
||||
</View>
|
||||
</AppView>
|
||||
@@ -754,154 +477,30 @@ const RandomSelectUserScreen: React.FC = () => {
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 16,
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: responsiveFontSize(2.8),
|
||||
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,
|
||||
},
|
||||
loadingContainer: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 12, padding: 16 },
|
||||
hero: { alignItems: 'center', paddingVertical: 24, paddingHorizontal: 20, gap: 6 },
|
||||
tag: { paddingHorizontal: 12, paddingVertical: 4, borderRadius: 20 },
|
||||
tagText: { fontSize: responsiveFontSize(1.4), fontFamily: FontsFamily.nunitoBold },
|
||||
phaseTitle: { fontSize: responsiveFontSize(3), fontFamily: FontsFamily.nunitoExtraBold },
|
||||
phaseSub: { fontSize: responsiveFontSize(1.6), fontFamily: FontsFamily.nunitoMedium },
|
||||
scrollContainer: { paddingBottom: 180, paddingHorizontal: 8 },
|
||||
userGrid: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center' },
|
||||
gridItem: {
|
||||
margin: 6,
|
||||
width: ITEM_SIZE,
|
||||
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,
|
||||
width: ITEM_SIZE, height: ITEM_SIZE + 20, margin: 6, borderRadius: 20,
|
||||
justifyContent: 'center', alignItems: 'center', padding: 10, position: 'relative'
|
||||
},
|
||||
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;
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { StyleSheet, Text, View, Animated, ToastAndroid } from 'react-native';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { StyleSheet, Text, View, Animated, ToastAndroid, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import LucideIcon from '../../components/common/LucideIcon';
|
||||
import { responsiveFontSize } from 'react-native-responsive-dimensions';
|
||||
import CustomModal from '../../components/common/CustomModal';
|
||||
import { useTheme } from '../../hooks/ui/useTheme';
|
||||
import { useUpdate } from '../../hooks/core/useUpdate';
|
||||
import { BASE_URL } from '../../utils/ApiUrls';
|
||||
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 {
|
||||
data: {
|
||||
@@ -41,78 +47,73 @@ interface WinnerUserProps {
|
||||
|
||||
const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId }) => {
|
||||
const theme = useTheme();
|
||||
const [selectedMode, setSelectedMode] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const {
|
||||
withdrawalDone,
|
||||
selectionDone,
|
||||
currentCycle,
|
||||
totalParticipants,
|
||||
paidParticipants,
|
||||
pendingParticipants,
|
||||
allPaid,
|
||||
selectedUser,
|
||||
cycleStatus,
|
||||
selectionMethod,
|
||||
canProceedToWithdrawal,
|
||||
} = data || {};
|
||||
|
||||
console.log("data",data)
|
||||
const scaleAnim = useRef(new Animated.Value(0)).current;
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
friction: 5,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
Animated.spring(scaleAnim, { toValue: 1, friction: 5, useNativeDriver: true }).start();
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.05,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, { toValue: 1.05, duration: 800, useNativeDriver: true }),
|
||||
Animated.timing(pulseAnim, { toValue: 1, duration: 800, useNativeDriver: true }),
|
||||
])
|
||||
).start();
|
||||
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
Animated.timing(fadeAnim, { toValue: 1, duration: 1000, useNativeDriver: true }).start();
|
||||
} else {
|
||||
scaleAnim.setValue(0);
|
||||
pulseAnim.setValue(1);
|
||||
fadeAnim.setValue(0);
|
||||
setSelectedMode(null);
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [visible]);
|
||||
}, [fadeAnim, pulseAnim, scaleAnim, visible]);
|
||||
|
||||
const { mutate: withdrawFunds, isPending } = useUpdate<any, any>(
|
||||
const handleWithdraw = async () => {
|
||||
if (!canProceedToWithdrawal) { onClose(); return; }
|
||||
if (!selectedMode) {
|
||||
ToastAndroid.show('Please select a payment mode', ToastAndroid.SHORT);
|
||||
return;
|
||||
}
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const res = await apiCall<any>(
|
||||
`${BASE_URL}payments/withdraw`,
|
||||
'PUT',
|
||||
{
|
||||
onSuccess: () => {
|
||||
ToastAndroid.show('Withdrawal successful', ToastAndroid.SHORT);
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
ToastAndroid.show(err?.message || 'Withdrawal failed', ToastAndroid.SHORT);
|
||||
},
|
||||
groupId: Number(groupId),
|
||||
userId: selectedUser?.userId ?? Number(selectedUser?.id),
|
||||
cycleNumber: currentCycle,
|
||||
paymentMode: selectedMode,
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
const handleWithdraw = () => {
|
||||
withdrawFunds({
|
||||
groupId,
|
||||
userId: selectedUser?.id,
|
||||
cycleNumber: currentCycle,
|
||||
});
|
||||
if (res?.status === 'success' || res?.statusCode === 200 || res?.statusCode === 201) {
|
||||
ToastAndroid.show('Withdrawal successful!', ToastAndroid.SHORT);
|
||||
onClose();
|
||||
} else {
|
||||
ToastAndroid.show(res?.message || 'Withdrawal failed', ToastAndroid.SHORT);
|
||||
}
|
||||
} catch (err: any) {
|
||||
ToastAndroid.show(err?.message || 'Withdrawal failed', ToastAndroid.SHORT);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderRow = (icon: any, label: string, value: any, color?: string) => (
|
||||
@@ -129,15 +130,24 @@ const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId
|
||||
<CustomModal
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
confirmText={canProceedToWithdrawal ? (isPending ? 'Processing...' : 'Withdraw Funds') : 'Close'}
|
||||
onConfirm={canProceedToWithdrawal ? handleWithdraw : onClose}
|
||||
confirmButton={theme.colors.primary}
|
||||
confirmText={
|
||||
!canProceedToWithdrawal
|
||||
? 'Close'
|
||||
: isProcessing
|
||||
? 'Processing...'
|
||||
: selectedMode
|
||||
? 'Withdraw Funds'
|
||||
: 'Select Payment Mode'
|
||||
}
|
||||
onConfirm={handleWithdraw}
|
||||
confirmButton={canProceedToWithdrawal && selectedMode ? theme.colors.success : theme.colors.textMuted}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.trophyContainer, { backgroundColor: theme.colors.background }]}>
|
||||
<LucideIcon name="trophy" size={32} color={theme.colors.primary} />
|
||||
<View style={[styles.trophyContainer, { backgroundColor: theme.colors.success + '15' }]}>
|
||||
<LucideIcon name="trophy" size={32} color={theme.colors.success} />
|
||||
</View>
|
||||
<Text style={[styles.cycle, { color: theme.colors.textSecondary }]}>Cycle {currentCycle} Winner</Text>
|
||||
<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>
|
||||
</View>
|
||||
|
||||
{/* Details */}
|
||||
<View style={[styles.detailsCard, { backgroundColor: theme.colors.background, borderColor: theme.colors.border }]}>
|
||||
{renderRow('call-outline', 'Mobile', selectedUser?.mobile)}
|
||||
{renderRow('mail-outline', 'Email', selectedUser?.email || 'N/A')}
|
||||
{renderRow('people-outline', 'Participants', `${paidParticipants}/${totalParticipants}`)}
|
||||
{renderRow('people-outline', 'Paid Members', `${paidParticipants}/${totalParticipants}`)}
|
||||
{renderRow(
|
||||
allPaid ? 'checkmark-circle-outline' : 'alert-circle-outline',
|
||||
allPaid ? 'checkmark-circle' : 'alert-circle-outline',
|
||||
'All Paid',
|
||||
allPaid ? 'Yes' : 'No',
|
||||
allPaid ? theme.colors.success : theme.colors.error
|
||||
@@ -163,9 +174,59 @@ const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Animated.Text style={[styles.congrats, { color: theme.colors.success, opacity: fadeAnim }]}>
|
||||
{selectionDone ? 'Congratulations!' : 'Selection Pending'}
|
||||
{/* Payment Mode Selector — only when withdrawal is pending */}
|
||||
{canProceedToWithdrawal && !withdrawalDone && (
|
||||
<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>
|
||||
</View>
|
||||
</CustomModal>
|
||||
@@ -173,26 +234,21 @@ const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 10,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
container: { padding: 4 },
|
||||
header: { alignItems: 'center', marginBottom: 20 },
|
||||
trophyContainer: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
cycle: {
|
||||
fontSize: responsiveFontSize(1.6),
|
||||
fontSize: responsiveFontSize(1.5),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
letterSpacing: 1.2,
|
||||
marginBottom: 4,
|
||||
},
|
||||
name: {
|
||||
@@ -201,9 +257,10 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'center',
|
||||
},
|
||||
detailsCard: {
|
||||
padding: 16,
|
||||
padding: 14,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
marginBottom: 20,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
@@ -212,25 +269,53 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
rowLabelContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
rowLabelContainer: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
label: { fontSize: responsiveFontSize(1.6), fontFamily: FontsFamily.nunitoMedium },
|
||||
value: { fontSize: responsiveFontSize(1.6), fontFamily: FontsFamily.nunitoBold },
|
||||
// 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),
|
||||
fontFamily: FontsFamily.nunitoMedium,
|
||||
},
|
||||
value: {
|
||||
fontSize: responsiveFontSize(1.6),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
},
|
||||
congrats: {
|
||||
fontSize: responsiveFontSize(2.2),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -8,7 +7,6 @@ import {
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Image,
|
||||
ToastAndroid,
|
||||
Modal,
|
||||
Easing,
|
||||
@@ -19,10 +17,9 @@ import LucideIcon from '../../components/common/LucideIcon';
|
||||
import AppView from '../../components/AppView';
|
||||
import { useTheme } from '../../hooks/ui/useTheme';
|
||||
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 Header from '../../components/common/Header';
|
||||
import { clampRGBA } from 'react-native-reanimated/lib/typescript/Colors';
|
||||
|
||||
interface PaymentMode {
|
||||
id: number;
|
||||
@@ -39,6 +36,18 @@ interface PaymentMode {
|
||||
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) ---
|
||||
const getBrandColors = (modeCode: string): { bg: string; text: string } => {
|
||||
switch (modeCode) {
|
||||
@@ -68,7 +77,7 @@ const ModeCard: React.FC<ModeCardProps> = ({ item, index, isSelected, isDisabled
|
||||
delay: index * 100,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, []);
|
||||
}, [cardAnim, index]);
|
||||
|
||||
const brandColors = getBrandColors(item.modeCode);
|
||||
|
||||
@@ -130,10 +139,15 @@ const ModeCard: React.FC<ModeCardProps> = ({ item, index, isSelected, isDisabled
|
||||
// --- Main Screen ---
|
||||
const PaymentModeScreen: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const navigation = useNavigation();
|
||||
const navigation = useNavigation<any>();
|
||||
const route = useRoute<any>();
|
||||
const { groupId, routeFrom, routeData = null } = route.params || {};
|
||||
const user = useSelector((state: any) => state.user);
|
||||
const {
|
||||
groupId,
|
||||
groupIds = [],
|
||||
amount = 0,
|
||||
routeFrom,
|
||||
routeData = null,
|
||||
} = (route.params || {}) as PaymentModeRouteParams;
|
||||
|
||||
const [modes, setModes] = useState<PaymentMode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -153,29 +167,63 @@ const PaymentModeScreen: React.FC = () => {
|
||||
// Animations
|
||||
const fadeAnim = useRef(new Animated.Value(0)).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(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }),
|
||||
Animated.timing(slideAnim, { toValue: 0, duration: 500, useNativeDriver: true }),
|
||||
]).start();
|
||||
}, []);
|
||||
}, [fadeAnim, slideAnim]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
{ routeFrom === 'GroupDetails' && handleContribution(); }
|
||||
console.log("data", payable);
|
||||
}, []);
|
||||
const handleContribution = async () => {
|
||||
const handleContribution = useCallback(async () => {
|
||||
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);
|
||||
} catch (error) {
|
||||
console.log("Error in handleContribution: ", error);
|
||||
}
|
||||
};
|
||||
}, [groupId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (routeFrom === 'GroupDetails') {
|
||||
handleContribution();
|
||||
}
|
||||
}, [handleContribution, routeFrom]);
|
||||
|
||||
// Spin animation for processing modal
|
||||
const startSpinAnimation = () => {
|
||||
const startSpinAnimation = useCallback(() => {
|
||||
spinAnim.setValue(0);
|
||||
spinLoop.current = Animated.loop(
|
||||
Animated.timing(spinAnim, {
|
||||
@@ -186,13 +234,13 @@ const PaymentModeScreen: React.FC = () => {
|
||||
})
|
||||
);
|
||||
spinLoop.current.start();
|
||||
};
|
||||
}, [spinAnim]);
|
||||
|
||||
const stopSpinAnimation = () => {
|
||||
const stopSpinAnimation = useCallback(() => {
|
||||
spinLoop.current?.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cleanupPolling = () => {
|
||||
const cleanupPolling = useCallback(() => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
@@ -202,12 +250,12 @@ const PaymentModeScreen: React.FC = () => {
|
||||
countdownRef.current = null;
|
||||
}
|
||||
stopSpinAnimation();
|
||||
};
|
||||
}, [stopSpinAnimation]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => cleanupPolling();
|
||||
}, []);
|
||||
}, [cleanupPolling]);
|
||||
|
||||
const pollTransactionStatus = (pgOrderNo: string) => {
|
||||
setProcessingVisible(true);
|
||||
@@ -304,6 +352,31 @@ const PaymentModeScreen: React.FC = () => {
|
||||
useEffect(() => {
|
||||
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 () => {
|
||||
try {
|
||||
|
||||
@@ -323,15 +396,8 @@ const PaymentModeScreen: React.FC = () => {
|
||||
|
||||
console.log("payment res", res)
|
||||
|
||||
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);
|
||||
}
|
||||
if (isPaymentRequestSuccessful(res)) {
|
||||
handleSuccessfulPaymentResponse(res, 'Payment initiated successfully');
|
||||
} else {
|
||||
ToastAndroid.show(res?.data?.message || res?.message || 'Payment failed', ToastAndroid.SHORT);
|
||||
setPaying(false);
|
||||
@@ -341,7 +407,7 @@ const PaymentModeScreen: React.FC = () => {
|
||||
setPaying(false);
|
||||
}
|
||||
}
|
||||
const handlePaymentForRouteFormGroupDetails = async (PaymentMode: number) => {
|
||||
const handlePaymentForRouteFormGroupDetails = async () => {
|
||||
try {
|
||||
|
||||
setPaying(true);
|
||||
@@ -361,50 +427,52 @@ const PaymentModeScreen: React.FC = () => {
|
||||
|
||||
console.log("payment payload ", payload)
|
||||
console.log("payment res ", res)
|
||||
switch (PaymentMode) {
|
||||
case 6:
|
||||
{
|
||||
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);
|
||||
}
|
||||
if (isPaymentRequestSuccessful(res)) {
|
||||
handleSuccessfulPaymentResponse(res, 'Payment initiated successfully');
|
||||
} 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) {
|
||||
ToastAndroid.show('An error occurred during payment', ToastAndroid.SHORT);
|
||||
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 () => {
|
||||
console.log("selectedMode", selectedMode)
|
||||
if (!selectedMode) {
|
||||
@@ -412,13 +480,17 @@ const PaymentModeScreen: React.FC = () => {
|
||||
ToastAndroid.show('Please select a payment method', ToastAndroid.SHORT);
|
||||
return;
|
||||
}
|
||||
console.log("routeFrom", routeFrom)
|
||||
switch (routeFrom) {
|
||||
case 'GroupDetails':
|
||||
handlePaymentForRouteFormGroupDetails(selectedMode);
|
||||
handlePaymentForRouteFormGroupDetails();
|
||||
break;
|
||||
case 'GroupScreen':
|
||||
handlePaymentForRouteFormGroupScreen();
|
||||
break;
|
||||
case 'UserProfileStatus':
|
||||
handlePaymentForUserProfileStatus();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -431,23 +503,19 @@ const PaymentModeScreen: React.FC = () => {
|
||||
|
||||
<Animated.View style={[styles.container, { opacity: fadeAnim, transform: [{ translateY: slideAnim }] }]}>
|
||||
{/* 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}>
|
||||
<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>
|
||||
<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()}
|
||||
{routeFrom === 'UserProfileStatus' && routeData?.groupCount ? (
|
||||
<Text style={[styles.amountMeta, { color: theme.colors.textMuted }]}>
|
||||
{routeData.groupCount} selected group{routeData.groupCount > 1 ? 's' : ''}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>}
|
||||
|
||||
{/* Payment Methods */}
|
||||
@@ -500,7 +568,7 @@ const PaymentModeScreen: React.FC = () => {
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
@@ -616,6 +684,11 @@ const styles = StyleSheet.create({
|
||||
fontSize: responsiveFontSize(3),
|
||||
fontFamily: FontsFamily.nunitoExtraBold,
|
||||
},
|
||||
amountMeta: {
|
||||
fontSize: responsiveFontSize(1.2),
|
||||
fontFamily: FontsFamily.nunitoSemiBold,
|
||||
marginTop: 6,
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: 100,
|
||||
gap: 10,
|
||||
|
||||
@@ -134,7 +134,7 @@ const BecomeOrg = () => {
|
||||
|
||||
<CustomInput
|
||||
label="ID Type"
|
||||
placeholder="e.g., Aadhaar, PAN Card"
|
||||
placeholder="e.g., National ID"
|
||||
value={idName}
|
||||
onChangeText={(val) => handleChange("idName", val)}
|
||||
icon="person-outline"
|
||||
|
||||
@@ -25,7 +25,7 @@ type BankFormErrors = {
|
||||
|
||||
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 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 theme = useTheme();
|
||||
@@ -79,7 +79,7 @@ const EditBankDetails = () => {
|
||||
nextErrors.bankAccountNumber = "Account number must be 9-18 digits.";
|
||||
}
|
||||
if (!isValidIfsc(branchCode)) {
|
||||
nextErrors.branchCode = "Enter a valid IFSC (e.g. HDFC0001234).";
|
||||
nextErrors.branchCode = "Enter a valid branch code.";
|
||||
}
|
||||
|
||||
setErrors(nextErrors);
|
||||
@@ -135,7 +135,7 @@ const EditBankDetails = () => {
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
label="Branch Code (IFSC)"
|
||||
label="Branch Code"
|
||||
placeholder="Enter the branch code"
|
||||
value={branchCode}
|
||||
onChangeText={(val) => handleChange("branchCode", val)}
|
||||
|
||||
@@ -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 REQUESTS_URL = `${BASE_URL}fund/group/pending/request`
|
||||
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 PUT_USER_PROFILE_IMG = `${BASE_URL}user/update/userImg`
|
||||
export const PUT_USER_PROFILE = `${BASE_URL}user/profile/update`
|
||||
|
||||