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