diff --git a/android/app/appKeyStore b/android/app/appKeyStore new file mode 100644 index 0000000..ae2b1c8 Binary files /dev/null and b/android/app/appKeyStore differ diff --git a/android/app/release/CircleCollect.apk b/android/app/release/CircleCollect.apk new file mode 100644 index 0000000..afc3d57 Binary files /dev/null and b/android/app/release/CircleCollect.apk differ diff --git a/android/app/release/baselineProfiles/0/app-release.dm b/android/app/release/baselineProfiles/0/app-release.dm new file mode 100644 index 0000000..13bc991 Binary files /dev/null and b/android/app/release/baselineProfiles/0/app-release.dm differ diff --git a/android/app/release/baselineProfiles/1/app-release.dm b/android/app/release/baselineProfiles/1/app-release.dm new file mode 100644 index 0000000..3822282 Binary files /dev/null and b/android/app/release/baselineProfiles/1/app-release.dm differ diff --git a/android/app/release/output-metadata.json b/android/app/release/output-metadata.json new file mode 100644 index 0000000..08b0ec4 --- /dev/null +++ b/android/app/release/output-metadata.json @@ -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 +} \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index a2f5908..ac80a28 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8b70127 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index 1b52399..649064e 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index ff10afd..34ee009 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..1724f22 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index 115a4c7..b142ed9 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index dcd3cd8..a260f92 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..28963a5 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index 459ca60..c965b67 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 8ca12fe..2ab193c 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..4b04185 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 8e19b41..0594440 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index b824ebd..08dfb90 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..05d6c9a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index 4c19a13..6406061 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/package-lock.json b/package-lock.json index d0d9fbe..c21c640 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "react-native-dialog": "^9.3.0", "react-native-elements": "^3.4.3", "react-native-fast-confetti": "^1.1.1", + "react-native-gesture-handler": "^2.28.0", "react-native-image-picker": "^8.2.1", "react-native-inset-shadow": "^1.0.3", "react-native-keychain": "^10.0.0", @@ -53,6 +54,7 @@ "react-native-svg": "^15.13.0", "react-native-vector-icons": "^10.3.0", "react-native-walkthrough-tooltip": "^1.6.0", + "react-native-webview": "^13.16.1", "react-native-worklets": "^0.6.0", "react-redux": "^9.2.0", "redux-persist": "^6.0.0" @@ -1806,7 +1808,6 @@ "node_modules/@egjs/hammerjs": { "version": "2.0.17", "license": "MIT", - "peer": true, "dependencies": { "@types/hammerjs": "^2.0.36" }, @@ -4453,8 +4454,7 @@ }, "node_modules/@types/hammerjs": { "version": "2.0.46", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -12639,7 +12639,6 @@ "node_modules/react-native-gesture-handler": { "version": "2.28.0", "license": "MIT", - "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", @@ -12896,6 +12895,20 @@ "@types/react": ">=16.8.24" } }, + "node_modules/react-native-webview": { + "version": "13.16.1", + "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz", + "integrity": "sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "invariant": "2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-worklets": { "version": "0.6.0", "license": "MIT", diff --git a/package.json b/package.json index b1d43e8..d805bb1 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "react-native-svg": "^15.13.0", "react-native-vector-icons": "^10.3.0", "react-native-walkthrough-tooltip": "^1.6.0", + "react-native-webview": "^13.16.1", "react-native-worklets": "^0.6.0", "react-redux": "^9.2.0", "redux-persist": "^6.0.0" diff --git a/src/components/JoinedGroupCard.tsx b/src/components/JoinedGroupCard.tsx index 4ac522a..ebc74a0 100644 --- a/src/components/JoinedGroupCard.tsx +++ b/src/components/JoinedGroupCard.tsx @@ -1,12 +1,11 @@ -import React, { FC } from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, ToastAndroid } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import React, { FC, useCallback, useState } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { responsiveFontSize } from 'react-native-responsive-dimensions'; import { useTheme } from '../hooks/ui/useTheme'; -import { useFetch } from '../hooks/core/useFetch'; -import { useCreate } from '../hooks/core/useCreate'; import { CONTRIBUTE } from '../utils/ApiUrls'; import FontsFamily from '../constents/FontsFamily'; +import { apiCall } from '../utils/ApiHelper'; interface JoinedGroup { id: string; @@ -22,7 +21,8 @@ interface JoinedGroup { currentParticipants: number; contributionAmount: number; cycleType: 'MONTHLY' | 'WEEKLY' | 'QUARTERLY' | 'CUSTOM'; - status: 'ACTIVE' | 'PENDING' | 'COMPLETED'| 'APPROVED'; + status: 'ACTIVE' | 'PENDING' | 'COMPLETED' | 'APPROVED' | 'DELAY'; + customCycleDays?: number; } interface JoinedGroupCardProps { @@ -42,24 +42,25 @@ const formatDate = (dateStr: string | null): string => { return `${dayNum} ${months[monthIndex]} ${year}`; }; -export const JoinedGroupCard: FC = ({ item }:any) => { +export const JoinedGroupCard: FC = ({ item }: any) => { const theme = useTheme(); const navigation = useNavigation(); - console.log("item",item) + const [payable, setPayable] = useState(null); - const { data: payableRes } = useFetch(`${CONTRIBUTE}${item.id}/calculate-contribution`); - const payable = payableRes?.data; - - const { mutate: contribute, isPending: loading } = useCreate( - `payments/contribute/${item?.id}?amount=${payable?.totalPayable}`, - { - onSuccess: () => { - ToastAndroid.show('Payment successful!', ToastAndroid.SHORT); - }, - onError: () => { - ToastAndroid.show('Payment failed. Please try again.', ToastAndroid.SHORT); - }, + const fetchPayable = useCallback(async () => { + try { + const res = await apiCall(`${CONTRIBUTE}${item.id}/calculate-contribution`, 'GET') as any; + setPayable(res?.data || null); + } catch (error) { + setPayable(null); + console.log(`Failed to fetch fresh payable details for group ${item.id}`, error); } + }, [item.id]); + + useFocusEffect( + useCallback(() => { + fetchPayable(); + }, [fetchPayable]) ); const getStatusColors = (status: JoinedGroup['status']) => { @@ -103,7 +104,7 @@ export const JoinedGroupCard: FC = ({ item }:any) => { Duration - { item?.cycleType === 'CUSTOM' ? `${item?.cycleType} (${item?.customCycleDays} days)` : item?.cycleType} + {item?.cycleType === 'CUSTOM' ? `${item?.cycleType} (${item?.customCycleDays} days)` : item?.cycleType} @@ -145,7 +146,7 @@ export const JoinedGroupCard: FC = ({ item }:any) => { ✓ PAID ) : ( - (item.status !== 'COMPLETED' || item?.status === "ACTIVE" || item?.status === "DELAY") && ( + (item?.status === "ACTIVE" || item?.status === "DELAY") && ( { @@ -154,10 +155,9 @@ export const JoinedGroupCard: FC = ({ item }:any) => { routeFrom: 'GroupDetails' }) }} - disabled={loading} > - {loading ? '...' : 'Pay'} + Pay ) diff --git a/src/components/UserRequested.tsx b/src/components/UserRequested.tsx index cf14145..4dfc031 100644 --- a/src/components/UserRequested.tsx +++ b/src/components/UserRequested.tsx @@ -27,7 +27,7 @@ const getStatusStyle = (status: UserJoinRequest["status"], theme: ReturnType = ({ item }) => { {" "}Group Created By:{" "} {item.createdBy} - {item.reason ? ( + {/* {item.reason ? ( {item.reason} - ) : null} + ) : null} */} ); diff --git a/src/components/common/Custom.tsx b/src/components/common/Custom.tsx index cbc82e7..463dd5c 100644 --- a/src/components/common/Custom.tsx +++ b/src/components/common/Custom.tsx @@ -3,6 +3,7 @@ import { StyleSheet, View, Text, TextInput, TouchableOpacity, TextInputProps } f import LucideIcon, { LucideIconName } from './LucideIcon'; import { responsiveFontSize } from 'react-native-responsive-dimensions'; import { useTheme } from '../../hooks/ui/useTheme'; +import { ActivityIndicator } from 'react-native-paper'; // --- Custom Input --- type CustomInputProps = TextInputProps & { @@ -143,7 +144,7 @@ const CustomButton: React.FC = ({ )} - {loading ? 'Processing...' : title} + {loading ? : title} diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index de11043..b213cf7 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -25,6 +25,7 @@ import ManagementScreen from "../screens/management/ManagementScreen"; import { navigationRef } from "../utils/navigationService"; import NotificationRedirection from "../screens/NotificationRedirection"; import PaymentModeScreen from "../screens/payment/PaymentModeScreen"; +import PaymentGatewayWebViewScreen from "../screens/payment/PaymentGatewayWebViewScreen"; // import screen from "../components/test"; export type RootStackParamList = { @@ -48,7 +49,18 @@ export type RootStackParamList = { TransactionList: undefined RandomSelectUserScreen: { groupId: string | number, cycleNumber: number }; Notification: undefined; - PaymentMode: { groupId: string | number; amount: number; payableData?: any }; + PaymentMode: { + groupId?: string | number; + groupIds?: Array; + amount?: number; + routeFrom?: string; + routeData?: any; + payableData?: any; + }; + PaymentGatewayWebView: { + paymentUrl: string; + paymentData?: any; + }; NotificationRedirection: { data: any }; Management: | { actionTab: "My Groups" } @@ -122,6 +134,7 @@ export default function AppNavigator() { + ); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 5517adb..62a9d16 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -1,6 +1,5 @@ // navigation/types.ts -import { NavigatorScreenParams } from '@react-navigation/native'; import { StackScreenProps } from '@react-navigation/stack'; // Type for the parameters passed to each screen in the Bottom Tab Navigator @@ -32,6 +31,18 @@ export type RootStackParamList = { ManageJoinedGroup: { groupId: string | number }; Invite: { groupId: string | number; code: string | number }; ManagementTabs:any + PaymentMode: { + groupId?: string | number; + groupIds?: Array; + amount?: number; + routeFrom?: string; + routeData?: any; + payableData?: any; + }; + PaymentGatewayWebView: { + paymentUrl: string; + paymentData?: any; + }; NotificationRedirection: { data: any }; }; diff --git a/src/screens/GroupScreen.tsx b/src/screens/GroupScreen.tsx index 711389c..fe68759 100644 --- a/src/screens/GroupScreen.tsx +++ b/src/screens/GroupScreen.tsx @@ -5,7 +5,6 @@ import { StyleSheet, FlatList, TouchableOpacity, - Dimensions, Image, RefreshControl, ToastAndroid, @@ -25,8 +24,6 @@ import { CustomInput, CustomButton } from '../components/common/Custom'; import SkeletonLoader from '../components/common/SkeletonLoader'; import FontsFamily from '../constents/FontsFamily'; -const { width } = Dimensions.get('window'); - // --- Types --- interface Group { id: string | number; @@ -76,7 +73,7 @@ const GroupCard = ({ const isFull = item.currentParticipants >= item.maxParticipants; const isPending = Boolean(item.joinRequestPending); const canJoin = !isJoined && !isPending && !isFull; - console.log("item>>>>>>>>>>>>>>", item); + return ( + + + + Contribution Amount + + + UGX {Number(item.contributionAmount || 0).toLocaleString('en-IN')} + + + + + + + @@ -190,7 +201,7 @@ export default function GroupScreen() { useFocusEffect( useCallback(() => { onRefresh(); - }, []) + }, [onRefresh]) ); const handleJoin = (groupId: string | number) => { console.log("Hitting here"); @@ -395,13 +406,42 @@ const styles = StyleSheet.create({ }, cardMeta: { flexDirection: 'row', - justifyContent: 'space-between', + flexWrap: 'wrap', + gap: 12, marginBottom: 12, }, + contributionBanner: { + borderRadius: 14, + paddingHorizontal: 14, + paddingVertical: 12, + marginBottom: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + contributionLabel: { + fontSize: responsiveFontSize(1.25), + fontFamily: FontsFamily.nunitoSemiBold, + marginBottom: 4, + textTransform: 'uppercase', + letterSpacing: 0.6, + }, + contributionValue: { + fontSize: responsiveFontSize(2), + fontFamily: FontsFamily.nunitoExtraBold, + }, + contributionIconWrap: { + width: 38, + height: 38, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + }, metaItem: { flexDirection: 'row', alignItems: 'center', gap: 4, + marginRight: 4, }, metaText: { fontSize: responsiveFontSize(1.3), diff --git a/src/screens/InviteScreen.tsx b/src/screens/InviteScreen.tsx index 341c125..8a2d099 100644 --- a/src/screens/InviteScreen.tsx +++ b/src/screens/InviteScreen.tsx @@ -19,6 +19,7 @@ import { responsiveFontSize } from 'react-native-responsive-dimensions'; import LucideIcon from '../components/common/LucideIcon'; import * as Keychain from 'react-native-keychain'; import FontsFamily from '../constents/FontsFamily'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; // --- Type Definitions --- interface Participant { @@ -56,6 +57,7 @@ type InviteScreenProps = NativeStackScreenProps; const InviteScreen: React.FC = ({ route, navigation }) => { const { groupId, code } = route.params; const theme = useTheme(); + const insets = useSafeAreaInsets(); useEffect(() => { const checkAuth = async () => { @@ -81,9 +83,20 @@ const InviteScreen: React.FC = ({ route, navigation }) => { ); const groupDetails: GroupDetails | null = detailsRes?.data || null; + const hasEntryFee = Number(groupDetails?.entryFee || 0) > 0; const handleJoin = () => { if (!groupDetails) return; + + if (hasEntryFee) { + navigation.navigate('PaymentMode' as never, { + groupId: groupDetails.id, + routeFrom: 'GroupScreen', + routeData: groupDetails, + } as never); + return; + } + joinGroup({}, { onSuccess: () => { ToastAndroid.show(`Join request sent for "${groupDetails.groupName}".`, ToastAndroid.SHORT); @@ -125,73 +138,95 @@ const InviteScreen: React.FC = ({ route, navigation }) => { return ( - - - - + + + + + + + You're Invited! + + Join {groupDetails.groupName} and start saving together. + - You're Invited! - - Join {groupDetails.groupName} and start saving together. - - - - Circle Overview - - - - - - - - - - + + Circle Overview + + + + + + + + + + - - - Members ({groupDetails.totalParticipants}/{groupDetails.maxParticipants}) - - {groupDetails.participants.length > 0 ? ( - groupDetails.participants.map((p) => ( - - - - - {p.firstName.charAt(0)} + + + Members ({groupDetails.totalParticipants}/{groupDetails.maxParticipants}) + + {groupDetails.participants.length > 0 ? ( + groupDetails.participants.map((p) => ( + + + + + {p.firstName.charAt(0)} + + + + {`${p.firstName} ${p.lastName || ''}`.trim()} - - {`${p.firstName} ${p.lastName || ''}`.trim()} - + {p.role === 'ORGANIZER' && ( + + Organizer + + )} - {p.role === 'ORGANIZER' && ( - - Organizer - - )} - - )) - ) : ( - Be the first to join! - )} - + )) + ) : ( + Be the first to join! + )} + + - - + + + + ); }; @@ -207,6 +242,9 @@ const InfoRow = ({ label, value }: { label: string; value: string | number }) => }; const styles = StyleSheet.create({ + screen: { + flex: 1, + }, scrollContent: { paddingBottom: 40, }, @@ -308,8 +346,17 @@ const styles = StyleSheet.create({ fontSize: responsiveFontSize(1.6), fontFamily: FontsFamily.nunitoMedium, }, + floatingButtonContainer: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + paddingTop: 12, + borderTopWidth: 1, + }, joinButton: { - marginTop: 16, + marginTop: 0, + marginBottom: 0, }, errorContainer: { flex: 1, diff --git a/src/screens/OtpVarificationScreen.tsx b/src/screens/OtpVarificationScreen.tsx index 74dbe37..59738ac 100644 --- a/src/screens/OtpVarificationScreen.tsx +++ b/src/screens/OtpVarificationScreen.tsx @@ -16,6 +16,7 @@ import * as Keychain from 'react-native-keychain'; import { RootStackParamList } from '../navigation/AppNavigator'; import { useTheme } from '../hooks/ui/useTheme'; import { useCreate } from '../hooks/core/useCreate'; +import { useFetch } from '../hooks/core/useFetch'; import { LOGIN_URL, VARIFY_OTP_URL } from '../utils/ApiUrls'; import { getFcmToken } from '../utils/firebase/firebaseServices'; import { CustomButton } from '../components/common/Custom'; @@ -57,7 +58,7 @@ export default function OtpVerificationScreen() { }, 1000); return () => clearInterval(interval); - }, []); + }, [fadeAnim, slideAnim]); const { mutate: verifyOtp, isPending: verifying } = useCreate(VARIFY_OTP_URL, { onSuccess: async (res) => { @@ -84,17 +85,31 @@ export default function OtpVerificationScreen() { }, }); - const { mutate: resendOtp, isPending: resending } = useCreate(LOGIN_URL, { - onSuccess: () => { - setTimer(30); - setCode(Array(CELL_COUNT).fill('')); - inputs.current[0]?.focus(); - ToastAndroid.show('OTP Resent', ToastAndroid.SHORT); - }, - onError: () => { - ToastAndroid.show('Failed to resend OTP', ToastAndroid.SHORT); - }, - }); + const { refetch: resendOtp, isFetching: resending } = useFetch( + LOGIN_URL + mobile, + {}, + { + enabled: false, + retry: false, + } + ); + + const handleResendOtp = async () => { + try { + const { data } = await resendOtp(); + + if (data?.statusCode === 200) { + setTimer(30); + setCode(Array(CELL_COUNT).fill('')); + inputs.current[0]?.focus(); + ToastAndroid.show('OTP Resent', ToastAndroid.SHORT); + } else { + ToastAndroid.show(data?.message || 'Failed to send OTP. Please try again.', ToastAndroid.SHORT); + } + } catch { + ToastAndroid.show('Something went wrong. Please check your connection.', ToastAndroid.SHORT); + } + }; const handleTextChange = (text: string, index: number) => { // If text length > 1, it's likely a paste operation @@ -159,7 +174,7 @@ export default function OtpVerificationScreen() { Verification Enter the 6-digit code sent to {'\n'} - +91 {mobile} + {mobile} @@ -200,10 +215,10 @@ export default function OtpVerificationScreen() { {timer > 0 ? ( - Resend code in 00:{timer.toString().padStart(2, '0')} + Resend code in 00:{timer.toString().padStart(2, '0')} ) : ( - resendOtp({ params: { mobile } })} disabled={resending}> + {resending ? 'Sending...' : 'Resend Code'} @@ -276,4 +291,7 @@ const styles = StyleSheet.create({ fontFamily: FontsFamily.nunitoBold, textDecorationLine: 'underline', }, + emphasisText: { + fontWeight: '700', + }, }); diff --git a/src/screens/SplashScreen.tsx b/src/screens/SplashScreen.tsx index 5c84e78..febf3b2 100644 --- a/src/screens/SplashScreen.tsx +++ b/src/screens/SplashScreen.tsx @@ -8,8 +8,9 @@ import { RootStackParamList } from '../navigation/AppNavigator'; import { PROFILE_URL } from '../utils/ApiUrls'; import { setUserData, clearUser } from '../redux/userSlice'; import { useTheme } from '../hooks/ui/useTheme'; -import { useFetch } from '../hooks/core/useFetch'; + import FontsFamily from '../constents/FontsFamily'; +import { useFetch } from '../hooks/core/useFetch'; const SplashScreen = () => { const navigation = useNavigation>(); diff --git a/src/screens/home/UserProfileStatus.tsx b/src/screens/home/UserProfileStatus.tsx index 341665c..16bb6d9 100644 --- a/src/screens/home/UserProfileStatus.tsx +++ b/src/screens/home/UserProfileStatus.tsx @@ -5,17 +5,13 @@ import { View, TouchableOpacity, Modal, - ToastAndroid, Image, ScrollView, - ActivityIndicator, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import LucideIcon from '../../components/common/LucideIcon'; import { responsiveFontSize } from 'react-native-responsive-dimensions'; import { useTheme } from '../../hooks/ui/useTheme'; -import { useCreate } from '../../hooks/core/useCreate'; -import { PAY_ALL_URL } from '../../utils/ApiUrls'; import FontsFamily from '../../constents/FontsFamily'; export type StatusData = { @@ -70,20 +66,6 @@ const UserProfileStatus: React.FC = ({ } }, [statusData]); - const { mutate: payAll, isPending: submitting } = useCreate(PAY_ALL_URL, { - onSuccess: (res) => { - if (res?.status) { - ToastAndroid.show('Payment initiated successfully', ToastAndroid.SHORT); - setShowPayDialog(false); - } else { - ToastAndroid.show(res?.message || 'Payment failed', ToastAndroid.SHORT); - } - }, - onError: () => { - ToastAndroid.show('Something went wrong during payment', ToastAndroid.SHORT); - }, - }); - const infoItems = useMemo( () => [ { icon: 'person-outline', label: 'Role', value: statusData?.role || '-' }, @@ -113,6 +95,24 @@ const UserProfileStatus: React.FC = ({ ); }; + 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 = () => ( @@ -214,15 +214,11 @@ const UserProfileStatus: React.FC = ({ UGX{totalDueSelected} payAll({ groupIds: selectedGroupIds })} - disabled={submitting || selectedGroupIds.length === 0} + style={[styles.confirmButton, { backgroundColor: theme.colors.primary }]} + onPress={handleProceedToPayment} + disabled={selectedGroupIds.length === 0} > - {submitting ? ( - - ) : ( - Confirm Payment - )} + Choose Method diff --git a/src/screens/home/user-home/CycleDashboard.tsx b/src/screens/home/user-home/CycleDashboard.tsx index 6ff98b9..791a702 100644 --- a/src/screens/home/user-home/CycleDashboard.tsx +++ b/src/screens/home/user-home/CycleDashboard.tsx @@ -1,9 +1,8 @@ -import React, { useState, useCallback } from 'react'; +import React, { useEffect, useState } from 'react'; import { StyleSheet, Text, View, - FlatList, TouchableOpacity, LayoutAnimation, UIManager, @@ -28,7 +27,7 @@ interface Contribution { waivedAmount: number; totalAmount: number; status: 'SUCCESS' | 'PENDING' | 'FAILED'; - paidDate: [number, number, number]; + paidDate: [number, number, number] | string | null; } interface Withdrawal { @@ -37,13 +36,13 @@ interface Withdrawal { baseAmount: number; totalAmount: number; status: 'SUCCESS' | 'PENDING' | 'FAILED'; - paidDate: [number, number, number]; + paidDate: [number, number, number] | string | null; } interface Cycle { cycleNumber: number; - cycleStart: [number, number, number]; - cycleEnd: [number, number, number]; + cycleStart: [number, number, number] | string | null; + cycleEnd: [number, number, number] | string | null; expectedContribution: number; contributions: Contribution[]; withdrawal: Withdrawal | null; @@ -87,11 +86,10 @@ const formatDate = (dateStr: string | null): string => { }; const formatCurrency = (amount: number): string => { - return new Intl.NumberFormat('en-IN', { - style: 'currency', - currency: 'INR', + return `UGX ${new Intl.NumberFormat('en-IN', { minimumFractionDigits: 0, - }).format(amount); + maximumFractionDigits: 0, + }).format(amount || 0)}`; }; @@ -132,24 +130,32 @@ const CycleItem = ({ item, isExpanded, onToggle }: { item: Cycle, isExpanded: bo {/* Contributions List */} Contributions ({item.paidParticipants}/{item.totalParticipants}) - {item.contributions.map(contribution => ( - - - - {contribution.userName} - {formatDate(contribution.paidDate as any)} - {contribution.status} + {item.contributions.length > 0 ? ( + item.contributions.map(contribution => ( + + + + {contribution.userName} + {formatDate(contribution.paidDate as any)} + {contribution.status} + + {formatCurrency(contribution.totalAmount)} - {formatCurrency(contribution.totalAmount)} + )) + ) : ( + + + No contributions recorded for this cycle yet. + - ))} + )} )} @@ -163,13 +169,17 @@ const CycleItem = ({ item, isExpanded, onToggle }: { item: Cycle, isExpanded: bo const CycleDashboard = ({ data }: { data: CycleDashboardData }) => { const [expandedCycle, setExpandedCycle] = useState(data.currentCycle); const theme = useTheme(); + const activeCycleNumber = data.cycles?.[0]?.cycleNumber ?? data.currentCycle ?? null; + + useEffect(() => { + setExpandedCycle(activeCycleNumber); + }, [activeCycleNumber]); const handleToggle = (cycleNumber: number) => { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); setExpandedCycle(expandedCycle === cycleNumber ? null : cycleNumber); }; - console.log("expanded cycle : ", data) const renderHeader = () => ( {data.groupName} @@ -184,26 +194,24 @@ const CycleDashboard = ({ data }: { data: CycleDashboardData }) => { Cycles - {data.currentCycle}/{data.totalParticipants} + {data.currentCycle}/{data.totalCycles} ); return ( - item.cycleNumber.toString()} - renderItem={({ item }) => ( + + {renderHeader()} + {data.cycles.map((item) => ( handleToggle(item.cycleNumber)} /> - )} - ListHeaderComponent={renderHeader} - contentContainerStyle={styles.container} - /> + ))} + ); }; @@ -320,5 +328,16 @@ const styles = StyleSheet.create({ fontSize: responsiveFontSize(1.5), fontWeight: '500', }, + emptyState: { + borderWidth: 1, + borderStyle: 'dashed', + borderRadius: 10, + paddingVertical: 14, + paddingHorizontal: 12, + }, + emptyStateText: { + fontSize: responsiveFontSize(1.4), + textAlign: 'center', + }, }); diff --git a/src/screens/home/user-home/GroupDetails.tsx b/src/screens/home/user-home/GroupDetails.tsx index 78540db..54d2183 100644 --- a/src/screens/home/user-home/GroupDetails.tsx +++ b/src/screens/home/user-home/GroupDetails.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useState } from "react"; import { - FlatList, RefreshControl, StyleSheet, Text, @@ -8,8 +7,7 @@ import { TouchableOpacity, View, ScrollView, - Image, - Modal + useWindowDimensions } from "react-native"; import LinearGradient from "react-native-linear-gradient"; @@ -25,77 +23,103 @@ import CycleDashboard from "./CycleDashboard"; import SkeletonLoader from "../../../components/common/SkeletonLoader"; import FontsFamily from "../../../constents/FontsFamily"; import AppView from "../../../components/AppView"; -// import CustomDropdown from "../../../components/CustomDropdown"; + +const formatCycleDateLabel = (dateValue?: string | null) => { + if (!dateValue || typeof dateValue !== "string") { + return "Date pending"; + } + + const parts = dateValue.split("-"); + if (parts.length !== 3) { + return dateValue; + } + + const [year, month, day] = parts; + const parsedDate = new Date(Number(year), Number(month) - 1, Number(day)); + + return parsedDate.toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + }); +}; const GroupDetails = () => { const theme = useTheme(); const route = useRoute(); const navigation = useNavigation(); + const { width: windowWidth } = useWindowDimensions(); + const groupId = route?.params?.item?.id || route?.params?.groupId; const [memberData, setMemberData] = useState([]); const [loading, setLoading] = useState(false); const [openModal, setOpenModal] = useState(false); const [payable, setPayable] = useState(); const [refreshing, setRefreshing] = useState(false); - const [cycleDashboardData, setCycleDashboardData] = useState([]); - const [selectedCycleNumber, setSelectedCycleNumber] = useState(null); - const [cycleItems, setCycleItems] = useState([]); + const [cycleDashboardData, setCycleDashboardData] = useState(null); + const [selectedCycleNumber, setSelectedCycleNumber] = useState(null); const [selectedUser, setSelectedUser] = useState() as string | any; const group = { - id: route.params.item?.id, - name: route.params.item?.groupName, - collected: route.params.item?.contributionAmount, - goal: route.params.item?.contributionAmount * route.params.item?.currentParticipants, - description: route.params.item?.description, - status: route.params.item?.status, + id: route?.params?.item?.id, + name: route?.params?.item?.groupName, + collected: route?.params?.item?.contributionAmount, + goal: route?.params?.item?.contributionAmount * route?.params?.item?.currentParticipants, + description: route?.params?.item?.description, + status: route?.params?.item?.status, members: memberData, }; - const getPaymentStatus = async () => { - const response = await apiCall(`${BASE_URL}payments/group/${group.id || route?.params?.groupId}/payment/paid`, 'GET') as any; + const getPaymentStatus = useCallback(async () => { + const response = await apiCall(`${BASE_URL}payments/group/${groupId}/payment/paid`, 'GET') as any; if (response.status === 'success') { setSelectedUser(response.data); } - } + }, [groupId]); - const getCycleDashboard = async () => { + const getCycleDashboard = useCallback(async () => { try { - const response = await apiCall(`${BASE_URL}payments/groups/${group.id || route?.params?.groupId}/cycle-dashboard`, "GET") as any; + const response = await apiCall(`${BASE_URL}payments/groups/${groupId}/cycle-dashboard`, "GET") as any; const responseData = response.data; - setCycleDashboardData(responseData || []); + setCycleDashboardData(responseData || null); if (responseData?.cycles) { - const items = responseData.cycles.map((cycle: any) => ({ - label: `Cycle ${cycle.cycleNumber}`, - value: cycle.cycleNumber - })); - setCycleItems(items); - if (responseData.currentCycle) { - setSelectedCycleNumber(responseData.currentCycle); - } + setSelectedCycleNumber((previousCycleNumber) => { + const hasPreviousSelection = responseData.cycles.some( + (cycle: any) => cycle.cycleNumber === previousCycleNumber + ); + + if (hasPreviousSelection) { + return previousCycleNumber; + } + + if (responseData.currentCycle) { + return responseData.currentCycle; + } + + return responseData.cycles[0]?.cycleNumber ?? null; + }); } } catch (error) { ToastAndroid.show("Failed to fetch cycle data", ToastAndroid.SHORT); } - }; + }, [groupId]); - const fetchGroupDetails = async () => { + const fetchGroupDetails = useCallback(async () => { try { - const res = await apiCall(`${GROUP_ALL_MEMBER}?groupId=${group.id || route?.params?.groupId}`, "GET") as any; + const res = await apiCall(`${GROUP_ALL_MEMBER}?groupId=${groupId}`, "GET") as any; setMemberData(res.data); } catch (error: any) { ToastAndroid.show(error?.message || "Failed to fetch group details", ToastAndroid.SHORT); } - }; + }, [groupId]); - const handleContribution = async () => { + const handleContribution = useCallback(async () => { try { - const res = await apiCall(`${CONTRIBUTE}${group.id || route?.params?.groupId}/calculate-contribution`, 'GET') as any; + const res = await apiCall(`${CONTRIBUTE}${groupId}/calculate-contribution`, 'GET') as any; setPayable(res.data); } catch (error) { console.log("Error in handleContribution: ", error); } - }; + }, [groupId]); const fetchAllData = useCallback(async () => { setLoading(true); @@ -105,14 +129,14 @@ const GroupDetails = () => { getCycleDashboard() ]); setLoading(false); - }, [group.id || route?.params?.groupId]); + }, [fetchGroupDetails, getCycleDashboard, handleContribution]); useEffect(() => { - if (group.id || route?.params?.groupId) { + if (groupId) { fetchAllData(); getPaymentStatus(); } - }, [group.id || route?.params?.groupId, fetchAllData]); + }, [fetchAllData, getPaymentStatus, groupId]); const onRefresh = useCallback(async () => { @@ -120,26 +144,19 @@ const GroupDetails = () => { await fetchAllData(); await getPaymentStatus(); setRefreshing(false); - }, [fetchAllData]); + }, [fetchAllData, getPaymentStatus]); - - const confirmPayment = async () => { - try { - const res = await apiCall(`${BASE_URL}payments/contribute/${group?.id}?amount=${payable?.totalPayable}`, 'POST') as any; - console.log('payment res ', res) - if (res.status === 200 || res.status === 201 || res.statusCode === 200) { - ToastAndroid.show('Payment done!', ToastAndroid.SHORT); - onRefresh(); - } else { - ToastAndroid.show(res?.data?.message || 'Payment failed!', ToastAndroid.SHORT); - } - } catch (error) { - ToastAndroid.show("An error occurred during payment.", ToastAndroid.SHORT); - } - }; - - - const selectedCycleData = cycleDashboardData?.cycles?.find((c: any) => c.cycleNumber === selectedCycleNumber); + const selectedCycleData = + cycleDashboardData?.cycles?.find((c: any) => c.cycleNumber === selectedCycleNumber) + || cycleDashboardData?.cycles?.[0]; + const currentCycleNumber = cycleDashboardData?.currentCycle; + const cycleCount = cycleDashboardData?.cycles?.length || 0; + const shouldScrollCycles = cycleCount > 2; + const cycleCardWidth = cycleCount <= 1 + ? windowWidth - 64 + : shouldScrollCycles + ? Math.max(156, Math.min(188, (windowWidth - 92) / 2)) + : (windowWidth - 76) / 2; const renderSkeletons = () => ( @@ -150,7 +167,7 @@ const GroupDetails = () => { ); return ( - + {/* Standard Header */} navigation.goBack()} style={[styles.iconButton, { backgroundColor: theme.colors.card }]}> @@ -163,11 +180,14 @@ const GroupDetails = () => { } contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false} + nestedScrollEnabled={true} + keyboardShouldPersistTaps="handled" > {loading ? renderSkeletons() : ( @@ -255,18 +275,156 @@ const GroupDetails = () => { {/* Cycle Timeline */} - {cycleItems.length > 0 && ( + {cycleDashboardData?.cycles?.length > 0 && ( Cycle Statement - {/* */} + + + + + Statement Viewer + + + Viewing Cycle {selectedCycleData?.cycleNumber} + + + Switch between past and current cycles to review collections. + + + + + + Current #{currentCycleNumber} + + + + + + {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 ( + 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", + } + ]} + > + + + + #{cycle.cycleNumber} + + + {isCurrent && ( + + + Current + + + )} + + + + Cycle {cycle.cycleNumber} + + + + {formatCycleDateLabel(cycle.cycleStart)} - {formatCycleDateLabel(cycle.cycleEnd)} + + + + + + + {isSelected ? "Showing" : "Open"} + + + + {completedCount}/{participantCount} paid + + + + ); + })} + + {selectedCycleData && cycleDashboardData && ( - + )} @@ -300,9 +458,7 @@ const GroupDetails = () => { {member.role === "Organizer" ? "Group Admin" : "Member"} - - - + ))} @@ -314,7 +470,7 @@ const GroupDetails = () => { onPress={() => setOpenModal(true)} style={[styles.payButton, { backgroundColor: theme.colors.primary }]} > - + Pay Contribution @@ -330,9 +486,10 @@ const GroupDetails = () => { visible={openModal} onClose={() => setOpenModal(false)} onConfirm={() => { - setOpenModal(false); - navigation.navigate('PaymentMode' as any, { - groupId: group.id || route?.params?.groupId, + setOpenModal(false); + navigation.navigate('PaymentMode' as any, { + groupId, + routeFrom: 'GroupDetails', }); }} /> @@ -365,7 +522,8 @@ const styles = StyleSheet.create({ fontSize: responsiveFontSize(2), }, scrollContent: { - paddingBottom: 100, + flexGrow: 1, + paddingBottom: 40, }, contentPadding: { paddingHorizontal: 16, @@ -529,6 +687,112 @@ const styles = StyleSheet.create({ marginBottom: 12, paddingLeft: 4, }, + cycleSelectorShell: { + borderRadius: 22, + borderWidth: 1, + paddingVertical: 18, + paddingLeft: 16, + marginBottom: 4, + }, + cycleSelectorHeader: { + paddingRight: 16, + marginBottom: 16, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + cycleSelectorCopy: { + flex: 1, + paddingRight: 12, + }, + cycleSelectorEyebrow: { + fontSize: responsiveFontSize(1.25), + letterSpacing: 1, + textTransform: 'uppercase', + marginBottom: 6, + }, + cycleSelectorTitle: { + fontSize: responsiveFontSize(2), + marginBottom: 4, + }, + cycleSelectorHint: { + fontSize: responsiveFontSize(1.5), + }, + currentCycleBadge: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 999, + flexDirection: 'row', + alignItems: 'center', + }, + currentCycleBadgeText: { + fontSize: responsiveFontSize(1.35), + marginLeft: 6, + }, + cycleRailContent: { + paddingRight: 16, + }, + cycleRailStaticContent: { + width: '100%', + paddingRight: 0, + justifyContent: 'space-between', + }, + cycleSwitchCard: { + minHeight: 142, + borderRadius: 20, + borderWidth: 1, + padding: 14, + marginRight: 12, + justifyContent: 'space-between', + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.12, + shadowRadius: 14, + elevation: 4, + }, + cycleSwitchTopRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 14, + }, + cycleNumberBadge: { + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 999, + }, + cycleNumberBadgeText: { + fontSize: responsiveFontSize(1.45), + }, + cycleStatusPill: { + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 999, + }, + cycleStatusPillText: { + fontSize: responsiveFontSize(1.2), + }, + cycleSwitchTitle: { + fontSize: responsiveFontSize(1.9), + marginBottom: 6, + }, + cycleSwitchDates: { + fontSize: responsiveFontSize(1.35), + lineHeight: 18, + }, + cycleSwitchFooter: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: 16, + }, + cycleSwitchFooterItem: { + flexDirection: 'row', + alignItems: 'center', + }, + cycleSwitchFooterText: { + fontSize: responsiveFontSize(1.3), + marginLeft: 6, + }, // Contacts List contactsContainer: { borderRadius: 20, diff --git a/src/screens/management/EditGroupScreen.tsx b/src/screens/management/EditGroupScreen.tsx index 5fc096c..8aaccb3 100644 --- a/src/screens/management/EditGroupScreen.tsx +++ b/src/screens/management/EditGroupScreen.tsx @@ -543,7 +543,7 @@ const EditGroupScreen: React.FC = () => { )} {paymentStatus?.cycleStatus === 'READY_FOR_WITHDRAWAL' && paymentStatus?.selectedUser && ( - setSelectedUserModel(true)} style={styles.secondaryBtn}> + setSelectedUserModel(true)} style={styles.primaryBtn}> View Winner )} @@ -592,9 +592,9 @@ const EditGroupScreen: React.FC = () => { ) : ( <> - + Activate Group - {/* */} + {/* */} )} @@ -709,7 +709,7 @@ const getStyles = (theme: any) => StyleSheet.create({ // Participants participantRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 }, - avatarPlaceholder: { width: 40, height: 40, borderRadius: 20, backgroundColor: '#6366F1', justifyContent: 'center', alignItems: 'center', marginRight: 12 }, + avatarPlaceholder: { width: 40, height: 40, borderRadius: 10, backgroundColor: theme.colors.primary, justifyContent: 'center', alignItems: 'center', marginRight: 12 }, pName: { fontSize: 16, fontFamily: FontsFamily.nunitoBold, color: theme.colors.textPrimary }, pRole: { fontSize: 12, color: theme.colors.textSecondary }, viewAllBtn: { paddingVertical: 8, alignItems: 'center', borderTopWidth: 1, borderTopColor: '#f0f0f0', marginTop: 8 }, @@ -718,9 +718,9 @@ const getStyles = (theme: any) => StyleSheet.create({ // Actions actionsContainer: { padding: 20 }, primaryBtn: { backgroundColor: theme.colors.primary, padding: 16, borderRadius: 12, alignItems: 'center', elevation: 2, marginBottom: 12 }, - btnText: { color: theme.colors.white, fontSize: 16, fontFamily: FontsFamily.nunitoBold }, + btnText: { color: theme.colors.reverseText, fontSize: 16, fontFamily: FontsFamily.nunitoBold }, secondaryBtn: { backgroundColor: '#f0f0f0', padding: 16, borderRadius: 12, alignItems: 'center' }, - secondaryBtnText: { color: theme.colors.textPrimary, fontSize: 16, fontFamily: FontsFamily.nunitoBold }, + secondaryBtnText: { color: theme.colors.reverseText, fontSize: 16, fontFamily: FontsFamily.nunitoBold }, // Modal modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }, diff --git a/src/screens/management/ManagementScreen.tsx b/src/screens/management/ManagementScreen.tsx index 908ae90..163d003 100644 --- a/src/screens/management/ManagementScreen.tsx +++ b/src/screens/management/ManagementScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { View, Text, @@ -84,83 +84,139 @@ const ManagementScreen: React.FC = () => { setActiveTab(routeTab); }, [route.params?.actionTab]); - const myGroupsParams = useMemo(() => { - return { - ...(myGroupStatusFilter && myGroupStatusFilter !== 'ALL' - ? { status: myGroupStatusFilter } - : {}), - page: 0, - size: 10, - }; - }, [myGroupStatusFilter]); + const myGroupsParams = { + ...(myGroupStatusFilter && myGroupStatusFilter !== 'ALL' + ? { status: myGroupStatusFilter } + : {}), + page: 0, + size: 10, + }; - const joinedGroupsParams = useMemo(() => { - return { - ...(joinedGroupStatusFilter && joinedGroupStatusFilter !== 'ALL' - ? { status: joinedGroupStatusFilter } - : {}), - page: 0, - size: 10, - }; - }, [joinedGroupStatusFilter]); + const joinedGroupsParams = { + ...(joinedGroupStatusFilter && joinedGroupStatusFilter !== 'ALL' + ? { status: joinedGroupStatusFilter } + : {}), + page: 0, + size: 10, + }; - const requestsParams = useMemo(() => ({ page: 0, size: 10 }), []); - const myRequestsParams = useMemo(() => ({ page: 0, size: 10 }), []); + const requestsParams = { page: 0, size: 10 }; + const myRequestsParams = { page: 0, size: 10 }; - console.log('joinedGroupsParams:', joinedGroupsParams); + const alwaysFreshFetchOptions = { + staleTime: 0, + gcTime: 0, + refetchOnMount: 'always' as const, + refetchOnReconnect: true, + }; // Data Fetching - const { data: myGroupsRes, isLoading: myLoading, refetch: refetchMy } = useFetch( + const { + data: myGroupsRes, + isLoading: myLoading, + isFetching: myFetching, + refetch: refetchMy, + } = useFetch( MY_GROUPS_URL, myGroupsParams, - { enabled: user.role === 'ORGANIZER' } + { enabled: user.role === 'ORGANIZER' && activeTab === 'My Groups', ...alwaysFreshFetchOptions } ); - const { data: joinedRes, isLoading: joinedLoading, refetch: refetchJoined } = useFetch( + const { + data: joinedRes, + isLoading: joinedLoading, + isFetching: joinedFetching, + refetch: refetchJoined, + } = useFetch( GET_JOINED_GROUP_URL, - joinedGroupsParams + joinedGroupsParams, + { enabled: activeTab === 'Joined Groups', ...alwaysFreshFetchOptions } ); - const { data: requestsRes, isLoading: requestsLoading, refetch: refetchRequests } = useFetch( + const { + data: requestsRes, + isLoading: requestsLoading, + isFetching: requestsFetching, + refetch: refetchRequests, + } = useFetch( REQUESTS_URL, requestsParams, - { enabled: user.role === 'ORGANIZER' } + { + enabled: user.role === 'ORGANIZER' && activeTab === 'Requests' && requestSubTab === 'Join Requests', + ...alwaysFreshFetchOptions, + } ); - const { data: userReqRes, isLoading: userReqLoading, refetch: refetchUserReq } = useFetch( + const { + data: userReqRes, + isLoading: userReqLoading, + isFetching: userReqFetching, + refetch: refetchUserReq, + } = useFetch( USER_REQUEST, - myRequestsParams + myRequestsParams, + { + enabled: + activeTab === 'My Requests' || + (user.role === 'ORGANIZER' && activeTab === 'Requests' && requestSubTab === 'My Requests'), + ...alwaysFreshFetchOptions, + } ); const { mutate: updateRequest } = useUpdate(''); - const myGroups = useMemo(() => getListFromResponse(myGroupsRes), [myGroupsRes]); - const joinedGroups = useMemo(() => getListFromResponse(joinedRes), [joinedRes]); - const incomingRequests = useMemo(() => getListFromResponse(requestsRes), [requestsRes]); - const myRequests = useMemo(() => getListFromResponse(userReqRes), [userReqRes]); + const myGroups = getListFromResponse(myGroupsRes); + const joinedGroups = getListFromResponse(joinedRes); + const incomingRequests = getListFromResponse(requestsRes); + const myRequests = getListFromResponse(userReqRes); const [refreshing, setRefreshing] = useState(false); - const onRefresh = useCallback(() => { + const onRefresh = useCallback(async () => { setRefreshing(true); - Promise.all([ - refetchMy(), - refetchJoined(), - refetchRequests(), - refetchUserReq(), - ]).finally(() => { + const refreshTasks = [refetchJoined(), refetchUserReq()]; + + if (user.role === 'ORGANIZER') { + refreshTasks.push(refetchMy(), refetchRequests()); + } + + await Promise.all(refreshTasks).finally(() => { setRefreshing(false); }); - }, [refetchMy, refetchJoined, refetchRequests, refetchUserReq]); + }, [refetchMy, refetchJoined, refetchRequests, refetchUserReq, user.role]); + + const refetchActiveTab = useCallback(async () => { + if (activeTab === 'My Groups' && user.role === 'ORGANIZER') { + await refetchMy(); + return; + } + + if (activeTab === 'Joined Groups') { + await refetchJoined(); + return; + } + + if (activeTab === 'Requests' && user.role === 'ORGANIZER') { + if (requestSubTab === 'Join Requests') { + await refetchRequests(); + } else { + await refetchUserReq(); + } + return; + } + + if (activeTab === 'My Requests') { + await refetchUserReq(); + } + }, [activeTab, requestSubTab, refetchMy, refetchJoined, refetchRequests, refetchUserReq, user.role]); useFocusEffect( useCallback(() => { - onRefresh(); - }, []) + refetchActiveTab(); + }, [refetchActiveTab]) ); - const tabs: ActiveTab[] = useMemo(() => + const tabs: ActiveTab[] = user.role === 'ORGANIZER' ? ['My Groups', 'Joined Groups', 'Requests'] - : ['Joined Groups', 'My Requests'], - [user.role]); + : ['Joined Groups', 'My Requests']; const handleApprove = (req: JoinRequest) => { updateRequest({ resource: `${APPROVE_REQUEST_URL}${req.requestId}/approve`, payload: {} } as any, { @@ -173,7 +229,7 @@ const ManagementScreen: React.FC = () => { }; const handleDecline = (req: JoinRequest) => { - updateRequest({ resource: `${DECLINE_REQUEST_URL}${req.requestId}/reject?reason=/`, payload: {} } as any, { + updateRequest({ resource: `${DECLINE_REQUEST_URL}${req.requestId}/reject?reason=no Reason`, payload: {} } as any, { onSuccess: () => { ToastAndroid.show('Request declined', ToastAndroid.SHORT); refetchRequests(); @@ -203,7 +259,23 @@ const ManagementScreen: React.FC = () => { } }; - const isLoading = myLoading || joinedLoading || requestsLoading || userReqLoading; + const isMyGroupsLoading = myLoading || myFetching; + const isJoinedGroupsLoading = joinedLoading || joinedFetching; + const isRequestsLoading = requestsLoading || requestsFetching; + const isUserRequestsLoading = userReqLoading || userReqFetching; + + const isActiveTabLoading = + activeTab === 'My Groups' + ? isMyGroupsLoading + : activeTab === 'Joined Groups' + ? isJoinedGroupsLoading + : activeTab === 'Requests' + ? requestSubTab === 'Join Requests' + ? isRequestsLoading + : isUserRequestsLoading + : isUserRequestsLoading; + + const listData = refreshing || isActiveTabLoading ? [] : getFilteredData(); return ( @@ -369,7 +441,7 @@ const ManagementScreen: React.FC = () => { )} String(item.id || item.requestId || index)} renderItem={({ item }) => { @@ -393,7 +465,7 @@ const ManagementScreen: React.FC = () => { /> } ListEmptyComponent={ - !isLoading ? ( + !isActiveTabLoading && !refreshing ? ( Nothing to show here diff --git a/src/screens/management/RandomSelectUserScreen.tsx b/src/screens/management/RandomSelectUserScreen.tsx index eb8e784..3bb1362 100644 --- a/src/screens/management/RandomSelectUserScreen.tsx +++ b/src/screens/management/RandomSelectUserScreen.tsx @@ -13,18 +13,18 @@ import { import LucideIcon from '../../components/common/LucideIcon'; import { useNavigation, useRoute } from '@react-navigation/native'; import { responsiveFontSize } from 'react-native-responsive-dimensions'; - import AppView from '../../components/AppView'; import { useTheme } from '../../hooks/ui/useTheme'; import { useFetch } from '../../hooks/core/useFetch'; -import { useUpdate } from '../../hooks/core/useUpdate'; +import { apiCall } from '../../utils/ApiHelper'; import { BASE_URL } from '../../utils/ApiUrls'; import FontsFamily from '../../constents/FontsFamily'; import SkeletonLoader from '../../components/common/SkeletonLoader'; +import Header from '../../components/common/Header'; const { width } = Dimensions.get('window'); const NUM_COLUMNS = 3; -const ITEM_SIZE = width / NUM_COLUMNS - 28; +const ITEM_SIZE = width / NUM_COLUMNS - 24; // Increased size slightly interface User { id: string; @@ -159,7 +159,7 @@ const RandomSelectUserScreen: React.FC = () => { const itemAnims = useRef([]).current; const winnerScaleAnim = useRef(new Animated.Value(1)).current; const winnerGlowAnim = useRef(new Animated.Value(0)).current; - const bannerSlideAnim = useRef(new Animated.Value(100)).current; + const bannerSlideAnim = useRef(new Animated.Value(200)).current; const bannerOpacity = useRef(new Animated.Value(0)).current; const headerPulseAnim = useRef(new Animated.Value(1)).current; @@ -173,13 +173,10 @@ const RandomSelectUserScreen: React.FC = () => { [itemAnims], ); - // --- DATA FETCHING --- const { data: participantsRes, isLoading: isParticipantsLoading } = useFetch( `${BASE_URL}fund/group/all/member?groupId=${groupId}`, {}, - { - enabled: !!groupId, - }, + { enabled: !!groupId }, ); useEffect(() => { @@ -194,35 +191,34 @@ const RandomSelectUserScreen: React.FC = () => { } }, [participantsRes, ensureAnims]); - // --- MUTATIONS --- - const { mutate: selectWinnerApi } = useUpdate( - `${BASE_URL}fund/method/based/userSelect`, - { - onSuccess: (res: any) => { - if (res.status === 'success' && res.data?.selectedUser?.id) { - const winnerData = { - id: res.data.selectedUser.id, - name: `${res.data.selectedUser.firstName} ${res.data.selectedUser.lastName}`, - }; - const winnerIndex = users.findIndex(u => u.id === winnerData.id); - - if (winnerIndex !== -1) { - triggerWinnerReveal(winnerData, winnerIndex); - } else { - ToastAndroid.show('Winner not found among participants', ToastAndroid.SHORT); - resetAnimation(); - } + const selectWinnerApi = async () => { + try { + const res = await apiCall( + `${BASE_URL}fund/method/based/userSelect`, + 'POST', + { groupId }, + ); + if (res?.status === 'success' && res?.data?.selectedUser?.id) { + const winnerData = { + id: res.data.selectedUser.id, + name: `${res.data.selectedUser.firstName} ${res.data.selectedUser.lastName}`, + }; + const winnerIndex = users.findIndex(u => u.id === winnerData.id); + if (winnerIndex !== -1) { + triggerWinnerReveal(winnerData, winnerIndex); } else { - ToastAndroid.show('Failed to determine a winner', ToastAndroid.SHORT); + ToastAndroid.show('Winner lookup failed', ToastAndroid.SHORT); resetAnimation(); } - }, - onError: (err: any) => { - ToastAndroid.show(err?.message || 'Selection failed', ToastAndroid.SHORT); + } else { + ToastAndroid.show('Failed to select winner', ToastAndroid.SHORT); resetAnimation(); - }, - }, - ); + } + } catch (err: any) { + ToastAndroid.show(err?.message || 'Api Error', ToastAndroid.SHORT); + resetAnimation(); + } + }; const resetAnimation = () => { setIsAnimating(false); @@ -231,26 +227,17 @@ const RandomSelectUserScreen: React.FC = () => { setShowWinnerReveal(false); winnerScaleAnim.setValue(1); winnerGlowAnim.setValue(0); - bannerSlideAnim.setValue(100); + bannerSlideAnim.setValue(200); bannerOpacity.setValue(0); itemAnims.forEach(a => a.setValue(0)); }; - // --- HEADER PULSE during animation --- useEffect(() => { if (phase === 'scanning' || phase === 'slowing') { const pulse = Animated.loop( Animated.sequence([ - Animated.timing(headerPulseAnim, { - toValue: 1.05, - duration: 600, - useNativeDriver: true, - }), - Animated.timing(headerPulseAnim, { - toValue: 1, - duration: 600, - useNativeDriver: true, - }), + Animated.timing(headerPulseAnim, { toValue: 1.05, duration: 600, useNativeDriver: true }), + Animated.timing(headerPulseAnim, { toValue: 1, duration: 600, useNativeDriver: true }), ]), ); pulse.start(); @@ -260,100 +247,61 @@ const RandomSelectUserScreen: React.FC = () => { } }, [phase, headerPulseAnim]); - // --- WINNER REVEAL ANIMATION --- const triggerWinnerReveal = (winnerData: { id: string; name: string }, winnerIdx: number) => { setPhase('reveal'); setWinner(winnerData); setHighlightedIndex(winnerIdx); setShowWinnerReveal(true); - // 1. Pop all items down except winner const itemAnimations = itemAnims.map((anim, i) => { if (i === winnerIdx) { return Animated.timing(anim, { - toValue: 2, // special "winner" state - duration: 400, + toValue: 2, + duration: 500, easing: Easing.out(Easing.back(1.5)), useNativeDriver: true, }); } return Animated.timing(anim, { - toValue: -1, // "dim" state + toValue: -1, duration: 300, useNativeDriver: true, }); }); - // 2. Winner scale + glow - const winnerReveal = Animated.parallel([ - Animated.spring(winnerScaleAnim, { - toValue: 1.2, - friction: 4, - tension: 80, - useNativeDriver: true, - }), - Animated.timing(winnerGlowAnim, { - toValue: 1, - duration: 600, - useNativeDriver: true, - }), - ]); - - // 3. Banner slide in - const bannerReveal = Animated.parallel([ - Animated.spring(bannerSlideAnim, { - toValue: 0, - friction: 6, - tension: 60, - useNativeDriver: true, - }), - Animated.timing(bannerOpacity, { - toValue: 1, - duration: 500, - useNativeDriver: true, - }), + const revealGroup = Animated.parallel([ + Animated.spring(winnerScaleAnim, { toValue: 1.25, friction: 3, tension: 70, useNativeDriver: true }), + Animated.timing(winnerGlowAnim, { toValue: 1, duration: 800, useNativeDriver: true }), + Animated.spring(bannerSlideAnim, { toValue: 0, friction: 6, tension: 50, useNativeDriver: true }), + Animated.timing(bannerOpacity, { toValue: 1, duration: 600, useNativeDriver: true }), ]); Animated.sequence([ Animated.parallel(itemAnimations), - Animated.delay(200), - winnerReveal, - Animated.delay(300), - bannerReveal, + Animated.delay(100), + revealGroup, ]).start(() => { setTimeout(() => { navigation.replace('EditGroup', { groupId }); - }, 2500); + }, 3500); }); }; - // --- MAIN SELECTION ANIMATION (3-phase spotlight roulette) --- const handleStartSelection = () => { const availableUsers = users.filter(u => !u.selected); - if (availableUsers.length === 0 || isAnimating) { - ToastAndroid.show( - 'All users are already selected or selection in progress', - ToastAndroid.SHORT, - ); + ToastAndroid.show('All members selected already', ToastAndroid.SHORT); return; } - resetAnimation(); setWinner(null); setIsAnimating(true); setPhase('scanning'); - // Phase 1: Fast random scanning (accelerate then sustain) - let count = 0; - const totalPhase1 = 18; - const totalPhase2 = 15; + const totalPhase1 = 15; + const totalPhase2 = 12; - const runPhase = ( - phaseCount: number, - getDelay: (i: number) => number, - onComplete: () => void, - ) => { + const runPhase = (phaseCount: number, getDelay: (i: number) => number, onComplete: () => void) => { let i = 0; const step = () => { if (i >= phaseCount) { @@ -365,14 +313,9 @@ const RandomSelectUserScreen: React.FC = () => { const origIdx = users.findIndex(u => u.id === user.id); setHighlightedIndex(origIdx); - // Animate the highlighted item ensureAnims(users.length); itemAnims.forEach((anim, idx) => { - Animated.timing(anim, { - toValue: idx === origIdx ? 1 : 0, - duration: 60, - useNativeDriver: true, - }).start(); + Animated.timing(anim, { toValue: idx === origIdx ? 1 : 0, duration: 60, useNativeDriver: true }).start(); }); i++; @@ -381,372 +324,152 @@ const RandomSelectUserScreen: React.FC = () => { step(); }; - // Phase 1: Fast scanning — starts moderate, gets fast - runPhase( - totalPhase1, - i => Math.max(50, 150 - i * 6), - () => { - setPhase('slowing'); - // Phase 2: Dramatic slowdown — each step takes longer - runPhase( - totalPhase2, - i => 100 + i * 40 + Math.pow(i, 1.6) * 8, - () => { - // Final api call to determine real winner - selectWinnerApi({ groupId }); - }, - ); - }, - ); + runPhase(totalPhase1, i => Math.max(50, 150 - i * 8), () => { + setPhase('slowing'); + runPhase(totalPhase2, i => 100 + i * 50 + Math.pow(i, 1.5) * 10, () => { + selectWinnerApi(); + }); + }); }; - // --- RENDER ITEM --- const renderUserCard = (item: User, index: number) => { const isHighlighted = index === highlightedIndex; const isWinnerCard = winner?.id === item.id && phase === 'reveal'; - ensureAnims(users.length); const itemAnim = itemAnims[index] || new Animated.Value(0); - const isDark = theme.mode === 'dark'; - // Dynamic colors - const cardBg = isWinnerCard - ? isDark ? '#1a3d2a' : '#e8f5ee' - : item.selected - ? theme.colors.surface - : theme.colors.background; - - const borderColor = isWinnerCard - ? theme.colors.success - : isHighlighted - ? isDark ? '#818CF8' : '#6366F1' - : theme.colors.borderLight; - - const textColor = isWinnerCard - ? theme.colors.success - : item.selected - ? theme.colors.textMuted - : theme.colors.textPrimary; - - const iconColor = isWinnerCard - ? theme.colors.success - : isHighlighted - ? isDark ? '#818CF8' : '#6366F1' - : item.selected - ? theme.colors.textMuted - : theme.colors.textSecondary; - return ( - {/* Glow overlay for highlighted */} - {isHighlighted && !isWinnerCard && ( - - )} - - {/* Winner pulse rings */} {isWinnerCard && showWinnerReveal && ( - - - - )} - - {/* Winner sparkle particles */} - {isWinnerCard && showWinnerReveal && ( - - {Array.from({ length: 12 }).map((_, i) => ( - + + {Array.from({ length: 10 }).map((_, i) => ( + ))} )} - {/* Winner crown badge */} {isWinnerCard && ( - + )} - {/* Already selected badge */} {item.selected && !isWinnerCard && ( - - + + )} - {/* Avatar circle */} - - + + - {/* Name */} - + {item.name} ); }; - // --- STATUS TEXT --- - const getPhaseText = () => { - switch (phase) { - case 'scanning': - return 'Scanning participants...'; - case 'slowing': - return 'Narrowing down...'; - case 'reveal': - return '🎉 Winner Found!'; - default: - return 'Tap below to start selection'; - } - }; - - // --- LOADING STATE --- if (isParticipantsLoading) { return ( - - - - - - + +
+ {Array.from({ length: 9 }).map((_, i) => ( - + ))} ); } - // --- MAIN RENDER --- return ( - - {/* Header */} - - - {phase === 'reveal' ? '🏆 Winner!' : '🎯 Winner Selection'} - - - - - Cycle #{cycleNumber} - + +
+ + + + Cycle #{cycleNumber} - - {getPhaseText()} + + {phase === 'reveal' ? 'Lucky Winner!' : phase === 'idle' ? 'Select Receiver' : 'Selecting...'} + + + {phase === 'reveal' ? 'Fund receiver for this month' : phase !== 'idle' ? 'Determining the fair winner...' : 'Tap the button below to draw'} - {/* Participants Grid */} - - - {users.map((user, index) => renderUserCard(user, index))} + + + {users.map((u, i) => renderUserCard(u, i))} - {/* Footer */} - - {/* Winner Banner */} + {winner && phase === 'reveal' && ( - - - + + + - - WINNER - - - {winner.name} - + WINNER + {winner.name} - + )} - {/* Action Button */} - {isAnimating && phase !== 'reveal' ? ( - - - - Selecting... - - - ) : ( - - - - {winner ? 'Select Again' : 'Start Selection'} - - - )} + + + + {isAnimating ? 'Selecting...' : winner ? 'Select Again' : 'Shuffle & Select'} + + @@ -754,154 +477,30 @@ const RandomSelectUserScreen: React.FC = () => { }; const styles = StyleSheet.create({ - header: { - paddingVertical: 20, - paddingHorizontal: 16, - alignItems: 'center', - gap: 8, - }, - headerTitle: { - fontSize: responsiveFontSize(2.8), - fontFamily: FontsFamily.nunitoBold, - letterSpacing: -0.5, - }, - cycleBadge: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - paddingVertical: 5, - paddingHorizontal: 12, - borderRadius: 20, - }, - cycleText: { - fontSize: responsiveFontSize(1.5), - fontFamily: FontsFamily.nunitoMedium, - }, - phaseText: { - fontSize: responsiveFontSize(1.6), - fontFamily: FontsFamily.nunitoMedium, - marginTop: 2, - }, - grid: { - paddingHorizontal: 12, - paddingBottom: 160, - }, - gridInner: { - flexDirection: 'row', - flexWrap: 'wrap', - justifyContent: 'center', - }, - skeletonGrid: { - padding: 16, - flexDirection: 'row', - flexWrap: 'wrap', - justifyContent: 'center', - gap: 12, - }, + loadingContainer: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 12, padding: 16 }, + hero: { alignItems: 'center', paddingVertical: 24, paddingHorizontal: 20, gap: 6 }, + tag: { paddingHorizontal: 12, paddingVertical: 4, borderRadius: 20 }, + tagText: { fontSize: responsiveFontSize(1.4), fontFamily: FontsFamily.nunitoBold }, + phaseTitle: { fontSize: responsiveFontSize(3), fontFamily: FontsFamily.nunitoExtraBold }, + phaseSub: { fontSize: responsiveFontSize(1.6), fontFamily: FontsFamily.nunitoMedium }, + scrollContainer: { paddingBottom: 180, paddingHorizontal: 8 }, + userGrid: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center' }, gridItem: { - margin: 6, - width: ITEM_SIZE, - height: ITEM_SIZE + 10, - justifyContent: 'center', - alignItems: 'center', - borderRadius: 16, - padding: 8, - position: 'relative', - overflow: 'visible', - }, - avatarCircle: { - width: 44, - height: 44, - borderRadius: 22, - alignItems: 'center', - justifyContent: 'center', - marginBottom: 6, - }, - gridText: { - textAlign: 'center', - fontSize: responsiveFontSize(1.25), - lineHeight: responsiveFontSize(1.7), - }, - selectedBadge: { - position: 'absolute', - top: 6, - right: 6, - width: 18, - height: 18, - borderRadius: 9, - alignItems: 'center', - justifyContent: 'center', - }, - crownBadge: { - position: 'absolute', - top: -6, - right: -6, - width: 24, - height: 24, - borderRadius: 12, - alignItems: 'center', - justifyContent: 'center', - zIndex: 10, - elevation: 8, - }, - footer: { - position: 'absolute', - bottom: 0, - width: '100%', - padding: 16, - paddingBottom: 20, - borderTopWidth: 1, - alignItems: 'center', - gap: 10, - }, - winnerBanner: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - paddingVertical: 12, - paddingHorizontal: 16, - borderRadius: 14, - width: '100%', - }, - trophyCircle: { - width: 36, - height: 36, - borderRadius: 18, - backgroundColor: 'rgba(255,255,255,0.2)', - alignItems: 'center', - justifyContent: 'center', - }, - winnerLabel: { - fontSize: responsiveFontSize(1.1), - fontFamily: FontsFamily.nunitoBold, - letterSpacing: 1.5, - }, - winnerName: { - fontSize: responsiveFontSize(1.9), - fontFamily: FontsFamily.nunitoBold, - marginTop: 1, - }, - actionButton: { - width: '100%', - paddingVertical: 15, - borderRadius: 14, - alignItems: 'center', - justifyContent: 'center', - elevation: 6, - shadowColor: '#6366F1', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.25, - shadowRadius: 12, - }, - buttonContent: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - actionButtonText: { - fontSize: responsiveFontSize(1.9), - fontFamily: FontsFamily.nunitoBold, + width: ITEM_SIZE, height: ITEM_SIZE + 20, margin: 6, borderRadius: 20, + justifyContent: 'center', alignItems: 'center', padding: 10, position: 'relative' }, + winnerIcon: { position: 'absolute', top: -10, right: -10, width: 28, height: 28, borderRadius: 14, justifyContent: 'center', alignItems: 'center', zIndex: 10, elevation: 5 }, + disabledOverlay: { position: 'absolute', inset: 0, borderRadius: 20, justifyContent: 'center', alignItems: 'center', zIndex: 5 }, + avatarBox: { width: 48, height: 48, borderRadius: 24, justifyContent: 'center', alignItems: 'center', marginBottom: 8 }, + userName: { fontSize: responsiveFontSize(1.4), textAlign: 'center' }, + stickyFooter: { position: 'absolute', bottom: 0, left: 0, right: 0, padding: 16, borderTopWidth: 1 }, + winnerFloat: { flexDirection: 'row', alignItems: 'center', gap: 14, padding: 12, borderRadius: 16, marginBottom: 16, elevation: 8 }, + glassCircle: { width: 40, height: 40, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.2)', justifyContent: 'center', alignItems: 'center' }, + winnerFloatLabel: { fontSize: responsiveFontSize(1.1), fontFamily: FontsFamily.nunitoBold, color: 'rgba(255,255,255,0.7)', letterSpacing: 1.2 }, + winnerFloatName: { fontSize: responsiveFontSize(2), fontFamily: FontsFamily.nunitoExtraBold, color: '#fff' }, + mainBtn: { width: '100%', height: 56, borderRadius: 16, justifyContent: 'center', alignItems: 'center', elevation: 4 }, + btnContent: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + btnText: { fontSize: responsiveFontSize(2), fontFamily: FontsFamily.nunitoBold }, }); export default RandomSelectUserScreen; diff --git a/src/screens/management/WinnerUser.tsx b/src/screens/management/WinnerUser.tsx index f97e78c..8f4fb56 100644 --- a/src/screens/management/WinnerUser.tsx +++ b/src/screens/management/WinnerUser.tsx @@ -1,12 +1,18 @@ -import React, { useEffect, useRef } from 'react'; -import { StyleSheet, Text, View, Animated, ToastAndroid } from 'react-native'; +import React, { useEffect, useRef, useState } from 'react'; +import { StyleSheet, Text, View, Animated, ToastAndroid, TouchableOpacity, ActivityIndicator } from 'react-native'; import LucideIcon from '../../components/common/LucideIcon'; import { responsiveFontSize } from 'react-native-responsive-dimensions'; import CustomModal from '../../components/common/CustomModal'; import { useTheme } from '../../hooks/ui/useTheme'; -import { useUpdate } from '../../hooks/core/useUpdate'; import { BASE_URL } from '../../utils/ApiUrls'; import FontsFamily from '../../constents/FontsFamily'; +import { apiCall } from '../../utils/ApiHelper'; + +const PAYMENT_MODES = [ + { label: 'Cash', value: 'CASH', icon: 'cash-outline' }, + { label: 'Online', value: 'ONLINE', icon: 'globe-outline' }, + +]; interface WinnerUserProps { data: { @@ -41,78 +47,73 @@ interface WinnerUserProps { const WinnerUser: React.FC = ({ data, visible, onClose, groupId }) => { const theme = useTheme(); + const [selectedMode, setSelectedMode] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + const { withdrawalDone, selectionDone, currentCycle, totalParticipants, paidParticipants, - pendingParticipants, allPaid, selectedUser, - cycleStatus, - selectionMethod, canProceedToWithdrawal, } = data || {}; - +console.log("data",data) const scaleAnim = useRef(new Animated.Value(0)).current; const pulseAnim = useRef(new Animated.Value(1)).current; const fadeAnim = useRef(new Animated.Value(0)).current; useEffect(() => { if (visible) { - Animated.spring(scaleAnim, { - toValue: 1, - friction: 5, - useNativeDriver: true, - }).start(); - + Animated.spring(scaleAnim, { toValue: 1, friction: 5, useNativeDriver: true }).start(); Animated.loop( Animated.sequence([ - Animated.timing(pulseAnim, { - toValue: 1.05, - duration: 800, - useNativeDriver: true, - }), - Animated.timing(pulseAnim, { - toValue: 1, - duration: 800, - useNativeDriver: true, - }), + Animated.timing(pulseAnim, { toValue: 1.05, duration: 800, useNativeDriver: true }), + Animated.timing(pulseAnim, { toValue: 1, duration: 800, useNativeDriver: true }), ]) ).start(); - - Animated.timing(fadeAnim, { - toValue: 1, - duration: 1000, - useNativeDriver: true, - }).start(); + Animated.timing(fadeAnim, { toValue: 1, duration: 1000, useNativeDriver: true }).start(); } else { scaleAnim.setValue(0); pulseAnim.setValue(1); fadeAnim.setValue(0); + setSelectedMode(null); + setIsProcessing(false); } - }, [visible]); + }, [fadeAnim, pulseAnim, scaleAnim, visible]); - const { mutate: withdrawFunds, isPending } = useUpdate( - `${BASE_URL}payments/withdraw`, - { - onSuccess: () => { - ToastAndroid.show('Withdrawal successful', ToastAndroid.SHORT); + 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( + `${BASE_URL}payments/withdraw`, + 'PUT', + { + groupId: Number(groupId), + userId: selectedUser?.userId ?? Number(selectedUser?.id), + cycleNumber: currentCycle, + paymentMode: selectedMode, + + } + ); + if (res?.status === 'success' || res?.statusCode === 200 || res?.statusCode === 201) { + ToastAndroid.show('Withdrawal successful!', ToastAndroid.SHORT); onClose(); - }, - onError: (err) => { - ToastAndroid.show(err?.message || 'Withdrawal failed', ToastAndroid.SHORT); - }, + } else { + ToastAndroid.show(res?.message || 'Withdrawal failed', ToastAndroid.SHORT); + } + } catch (err: any) { + ToastAndroid.show(err?.message || 'Withdrawal failed', ToastAndroid.SHORT); + } finally { + setIsProcessing(false); } - ); - - const handleWithdraw = () => { - withdrawFunds({ - groupId, - userId: selectedUser?.id, - cycleNumber: currentCycle, - }); }; const renderRow = (icon: any, label: string, value: any, color?: string) => ( @@ -129,15 +130,24 @@ const WinnerUser: React.FC = ({ data, visible, onClose, groupId + {/* Header */} - - + + Cycle {currentCycle} Winner @@ -145,12 +155,13 @@ const WinnerUser: React.FC = ({ data, visible, onClose, groupId + {/* Details */} {renderRow('call-outline', 'Mobile', selectedUser?.mobile)} {renderRow('mail-outline', 'Email', selectedUser?.email || 'N/A')} - {renderRow('people-outline', 'Participants', `${paidParticipants}/${totalParticipants}`)} + {renderRow('people-outline', 'Paid Members', `${paidParticipants}/${totalParticipants}`)} {renderRow( - allPaid ? 'checkmark-circle-outline' : 'alert-circle-outline', + allPaid ? 'checkmark-circle' : 'alert-circle-outline', 'All Paid', allPaid ? 'Yes' : 'No', allPaid ? theme.colors.success : theme.colors.error @@ -163,9 +174,59 @@ const WinnerUser: React.FC = ({ data, visible, onClose, groupId )} - - {selectionDone ? 'Congratulations!' : 'Selection Pending'} - + {/* Payment Mode Selector — only when withdrawal is pending */} + {canProceedToWithdrawal && !withdrawalDone && ( + + Select Payment Mode + + Ask the user how the withdrawal was paid and choose Cash or Online. + + + {PAYMENT_MODES.map((mode) => { + const isActive = selectedMode === mode.value; + return ( + 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, + }, + ]} + > + + + {mode.label} + + + ); + })} + + + )} + + {/* Status label */} + {!canProceedToWithdrawal && ( + + {selectionDone ? 'Awaiting all payments before withdrawal' : 'Selection Pending'} + + )} + {canProceedToWithdrawal && isProcessing && ( + + + Processing withdrawal... + + )} @@ -173,26 +234,21 @@ const WinnerUser: React.FC = ({ data, visible, onClose, groupId }; const styles = StyleSheet.create({ - container: { - padding: 10, - }, - header: { - alignItems: 'center', - marginBottom: 24, - }, + container: { padding: 4 }, + header: { alignItems: 'center', marginBottom: 20 }, trophyContainer: { - width: 64, - height: 64, - borderRadius: 32, + width: 72, + height: 72, + borderRadius: 36, justifyContent: 'center', alignItems: 'center', marginBottom: 12, }, cycle: { - fontSize: responsiveFontSize(1.6), + fontSize: responsiveFontSize(1.5), fontFamily: FontsFamily.nunitoBold, textTransform: 'uppercase', - letterSpacing: 1, + letterSpacing: 1.2, marginBottom: 4, }, name: { @@ -201,9 +257,10 @@ const styles = StyleSheet.create({ textAlign: 'center', }, detailsCard: { - padding: 16, + padding: 14, borderRadius: 16, borderWidth: 1, + marginBottom: 20, }, row: { flexDirection: 'row', @@ -212,25 +269,53 @@ const styles = StyleSheet.create({ paddingVertical: 10, borderBottomWidth: 1, }, - rowLabelContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, + rowLabelContainer: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + label: { fontSize: responsiveFontSize(1.6), fontFamily: FontsFamily.nunitoMedium }, + value: { fontSize: responsiveFontSize(1.6), fontFamily: FontsFamily.nunitoBold }, + // Payment mode + modeSection: { marginBottom: 16 }, + modeTitle: { + fontSize: responsiveFontSize(1.7), + fontFamily: FontsFamily.nunitoBold, }, - label: { + modeHint: { + fontSize: responsiveFontSize(1.35), + fontFamily: FontsFamily.nunitoMedium, + marginTop: 4, + marginBottom: 10, + }, + modeRow: { flexDirection: 'row', gap: 8 }, + modeChip: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 10, + borderRadius: 12, + gap: 4, + }, + modeChipActive: { + borderWidth: 2, + }, + modeChipInactive: { + borderWidth: 1, + }, + modeLabel: { fontSize: responsiveFontSize(1.35), textAlign: 'center' }, + // Status + statusNote: { fontSize: responsiveFontSize(1.6), fontFamily: FontsFamily.nunitoMedium, - }, - value: { - fontSize: responsiveFontSize(1.6), - fontFamily: FontsFamily.nunitoBold, - }, - congrats: { - fontSize: responsiveFontSize(2.2), - fontFamily: FontsFamily.nunitoBold, textAlign: 'center', - marginTop: 24, + marginTop: 8, + paddingHorizontal: 16, }, + processingRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + marginTop: 8, + }, + processingText: { fontSize: responsiveFontSize(1.5), fontFamily: FontsFamily.nunitoMedium }, }); export default WinnerUser; diff --git a/src/screens/notification/Notification.tsx b/src/screens/notification/Notification.tsx index fcf81fc..7e2fb53 100644 --- a/src/screens/notification/Notification.tsx +++ b/src/screens/notification/Notification.tsx @@ -1,36 +1,50 @@ -import React, { useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { + useNavigation, +} from '@react-navigation/native'; import { - View, - Text, - TouchableOpacity, - FlatList, - StyleSheet, Alert, Dimensions, + FlatList, + GestureResponderEvent, + LayoutAnimation, + Platform, + StyleSheet, + Text, + TouchableOpacity, + UIManager, + View, } from 'react-native'; - +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useDispatch, useSelector } from 'react-redux'; -import { removeNotification, clearAllNotifications, markAllAsRead } from '../../redux/notificationSlice'; - -import LinearGradient from 'react-native-linear-gradient'; -import LucideIcon from '../../components/common/LucideIcon'; -import AppView from '../../components/AppView'; -import Header from '../../components/common/Header'; -import { useNotificationHandler } from '../hook/useNotificationHandler'; -import { useTheme } from '../../theme/ThemeProvider'; import Swipeable from 'react-native-gesture-handler/ReanimatedSwipeable'; import Animated, { - useSharedValue, - useAnimatedStyle, - withSpring, - interpolate, Extrapolate, SharedValue, + interpolate, + useAnimatedStyle, + useSharedValue, + withSpring, } from 'react-native-reanimated'; + +import LucideIcon from '../../components/common/LucideIcon'; +import AppView from '../../components/AppView'; import FontsFamily from '../../constents/FontsFamily'; +import { useTheme } from '../../hooks/ui/useTheme'; +import { + Notification as AppNotification, + clearAllNotifications, + markAllAsRead, + removeNotification, +} from '../../redux/notificationSlice'; +import { useNotificationHandler } from '../hook/useNotificationHandler'; const { width } = Dimensions.get('window'); +if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { + UIManager.setLayoutAnimationEnabledExperimental(true); +} + type FilterType = | 'all' | 'JOIN_REQUEST' @@ -46,181 +60,131 @@ type FilterType = | 'JOIN_APPROVED' | 'JOIN_VIA_INVITE'; -const Notification = () => { - const dispatch = useDispatch(); - const { handleMarkAsReadAndNavigate } = useNotificationHandler(); - const { theme } = useTheme(); +type NotificationPressHandler = ( + messageId: string, + screen: string, + groupId: string, + cycleNumber: string, + contributionId: string +) => void; - const notifications = useSelector( - (state: any) => state.notifications.notifications ?? [] - ); +type SwipeableRefMap = Record void } | null>; - const handleDelete = (messageId: string) => { - dispatch(removeNotification(messageId)); - }; +const withOpacity = (color: string, alphaHex: string) => { + if (typeof color === 'string' && color.startsWith('#') && color.length === 7) { + return `${color}${alphaHex}`; + } - const handleClearAll = () => { - if (notifications.length === 0) return; + return color; +}; - Alert.alert( - 'Clear All', - 'Are you sure you want to delete all notifications?', - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Clear All', - style: 'destructive', - onPress: () => dispatch(clearAllNotifications()) - }, - ] - ); - }; +const DeleteRightAction = ({ + messageId, + onDeleteAction, + drag, + theme, +}: { + messageId: string; + onDeleteAction: (id: string) => void; + drag: SharedValue; + theme: any; +}) => { + const animatedStyle = useAnimatedStyle(() => { + const translateX = interpolate(drag.value, [-96, 0], [0, 96], Extrapolate.CLAMP); + const opacity = interpolate(drag.value, [-96, -16, 0], [1, 0.7, 0.35], Extrapolate.CLAMP); + const scale = interpolate(drag.value, [-96, -32, 0], [1, 0.97, 0.92], Extrapolate.CLAMP); - const handleMarkAllRead = () => { - if (notifications.length === 0) return; - dispatch(markAllAsRead()); - }; - - const handleMarkAsRead = ( - messageId: string, - screen: string, - groupId: string, - cycleNumber: string, - contributionId: string - ) => { - handleMarkAsReadAndNavigate(messageId, screen, { - groupId, - cycleNumber, - contributionId, - actionTab3: 'Your Requests', - actionTab1: 'Joined Groups', - GroupDetails: { item: {} }, - }); - }; - - const formatTime = (timestamp: number) => { - const date = new Date(timestamp); - const now = new Date(); - const diff = (now.getTime() - date.getTime()) / (1000 * 60); - - if (diff < 1) return 'Just now'; - if (diff < 60) return `${Math.floor(diff)}m ago`; - if (diff < 1440) return `${Math.floor(diff / 60)}h ago`; - - const dayDiff = Math.floor(diff / 1440); - if (dayDiff === 1) return 'Yesterday'; - if (dayDiff < 7) return `${dayDiff}d ago`; - - return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short' }); - }; - - const getNotificationIcon = (type: string) => { - const map: any = { - JOIN_REQUEST: 'person-add-outline', - CONTRIBUTION: 'cash-outline', - CONTRIBUTION_ACK: 'checkmark-circle-outline', - CYCLE_COMPLETED: 'trophy', - WITHDRAWAL: 'wallet-outline', - WITHDRAWAL_NOTIFY: 'notifications-outline', - GROUP_STARTED: 'sparkles-outline', - JOIN_APPROVED: 'checkmark-circle-outline', - JOIN_VIA_INVITE: 'mail-outline', + return { + opacity, + transform: [{ translateX }, { scale }], }; - return map[type] || 'notifications-outline'; - }; + }); - const getNotificationColor = (type: string) => { - const map: any = { - JOIN_REQUEST: '#FF6B6B', // Vibrant Red - CONTRIBUTION: '#4ECDC4', // Modern Teal - CONTRIBUTION_ACK: '#45B7D1', // Sky Blue - CYCLE_COMPLETED: '#FFD93D', // Golden Yellow - WITHDRAWAL: '#6C5CE7', // Deep Purple - WITHDRAWAL_NOTIFY: '#FF8066', // Coral - GROUP_STARTED: '#A8E6CF', // Mint Green - JOIN_APPROVED: '#32D74B', // iOS Green - JOIN_VIA_INVITE: '#007AFF', // iOS Blue - }; - return map[type] || '#8E8E93'; - }; - - const getFilterDisplayName = (filter: FilterType) => { - return filter.replace(/_/g, ' '); - }; - - const unreadCount = notifications.filter((i: any) => !i.read).length; - - const renderRightActions = ( - messageId: string, - onDeleteAction: (id: string) => void, - swipeableMethods: { close: () => void }, - drag: SharedValue - ) => { - const styleAnimation = useAnimatedStyle(() => { - const translateX = interpolate( - drag.value, - [-100, 0], - [0, 100], - Extrapolate.CLAMP - ); - return { - transform: [{ translateX }], - }; - }); - - return ( + return ( + { - onDeleteAction(messageId); - swipeableMethods.close(); - }} - activeOpacity={0.8} - style={styles(theme).deleteSwipeContainer} + onPress={() => onDeleteAction(messageId)} + activeOpacity={0.85} + style={styles(theme).deleteSwipe} > - - - Delete - + + Delete - ); + + ); +}; + +const NotificationItem = ({ + item, + isExpanded, + onDelete, + onOpenSwipeable, + onPressNotification, + onToggleExpand, + theme, + formatTime, + getNotificationIcon, + getNotificationColor, + getFilterDisplayName, + registerSwipeable, +}: { + item: AppNotification; + isExpanded: boolean; + onDelete: (id: string) => void; + onOpenSwipeable: (id: string) => void; + onPressNotification: NotificationPressHandler; + onToggleExpand: (id: string) => void; + theme: any; + formatTime: (timestamp: number) => string; + getNotificationIcon: (type: string) => string; + getNotificationColor: (type: string) => string; + getFilterDisplayName: (filter: FilterType) => string; + registerSwipeable: (id: string, ref: { close?: () => void } | null) => void; +}) => { + const scale = useSharedValue(1); + const translateY = useSharedValue(0); + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }, { translateY: translateY.value }], + })); + + const notificationType = item?.data?.msgType || 'all'; + const accentColor = getNotificationColor(notificationType); + const bodyText = String(item?.data?.body || ''); + const hasExpandableContent = bodyText.length > 95 || bodyText.includes('\n'); + + const handleToggleExpand = (event: GestureResponderEvent) => { + event.stopPropagation(); + onToggleExpand(item.messageId); }; - const NotificationItem = ({ - item, - onDelete, - onPressNotification, - theme, - }: { - item: any; - onDelete: (id: string) => void; - onPressNotification: ( - messageId: string, - screen: string, - groupId: string, - cycleNumber: string, - contributionId: string - ) => void; - theme: any; - }) => { - const scale = useSharedValue(1); - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - })); - - return ( + return ( + - renderRightActions(item.messageId, onDelete, swipeableMethods, drag) - } + ref={(ref) => registerSwipeable(item.messageId, ref)} + friction={1.8} + overshootRight={false} + rightThreshold={28} + onSwipeableOpen={() => onOpenSwipeable(item.messageId)} + renderRightActions={(_, drag) => ( + + )} > (scale.value = withSpring(0.98))} - onPressOut={() => (scale.value = withSpring(1))} + activeOpacity={0.94} + onPressIn={() => { + scale.value = withSpring(0.992, { damping: 18, stiffness: 220 }); + translateY.value = withSpring(2, { damping: 18, stiffness: 220 }); + }} + onPressOut={() => { + scale.value = withSpring(1, { damping: 18, stiffness: 220 }); + translateY.value = withSpring(0, { damping: 18, stiffness: 220 }); + }} onPress={() => onPressNotification( item.messageId, @@ -234,69 +198,91 @@ const Notification = () => { - - {item.data.title} + + {item?.data?.title || 'Notification'} {formatTime(item.sentTime)} - - {item.data.body} + + {bodyText} - {getFilterDisplayName(item.data.msgType)} + {getFilterDisplayName(notificationType)} - {!item.read && ( - - )} + + {hasExpandableContent && ( + + + {isExpanded ? 'Less' : 'More'} + + + + )} + + {!item.read && ( + + )} + + + @@ -304,59 +290,284 @@ const Notification = () => { - ); - }; + + ); +}; - const renderItem = ({ item }: any) => ( - +const Notification = () => { + const dispatch = useDispatch(); + const { handleMarkAsReadAndNavigate } = useNotificationHandler(); + const theme = useTheme(); + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const swipeableRefs = useRef({}); + const openSwipeableIdRef = useRef(null); + const [expandedItems, setExpandedItems] = useState>({}); + + const notifications = useSelector( + (state: any) => state.notifications.notifications ?? [] + ) as AppNotification[]; + + const sortedNotifications = useMemo( + () => [...notifications].sort((first, second) => second.sentTime - first.sentTime), + [notifications] ); + const registerSwipeable = useCallback((id: string, ref: { close?: () => void } | null) => { + swipeableRefs.current[id] = ref; + }, []); + + const closeSwipeable = useCallback((id?: string | null) => { + const targetId = id ?? openSwipeableIdRef.current; + + if (!targetId) { + return; + } + + swipeableRefs.current[targetId]?.close?.(); + + if (!id || openSwipeableIdRef.current === targetId) { + openSwipeableIdRef.current = null; + } + }, []); + + const handleOpenSwipeable = useCallback((id: string) => { + if (openSwipeableIdRef.current && openSwipeableIdRef.current !== id) { + swipeableRefs.current[openSwipeableIdRef.current]?.close?.(); + } + + openSwipeableIdRef.current = id; + }, []); + + const handleDelete = useCallback((messageId: string) => { + closeSwipeable(messageId); + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + dispatch(removeNotification(messageId)); + + setExpandedItems((current) => { + if (!current[messageId]) { + return current; + } + + const next = { ...current }; + delete next[messageId]; + return next; + }); + }, [closeSwipeable, dispatch]); + + const handleClearAll = useCallback(() => { + if (sortedNotifications.length === 0) { + return; + } + + Alert.alert( + 'Clear All', + 'Are you sure you want to delete all notifications?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Clear All', + style: 'destructive', + onPress: () => { + closeSwipeable(); + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + dispatch(clearAllNotifications()); + setExpandedItems({}); + }, + }, + ] + ); + }, [closeSwipeable, dispatch, sortedNotifications.length]); + + const handleMarkAllRead = useCallback(() => { + if (sortedNotifications.length === 0) { + return; + } + + dispatch(markAllAsRead()); + }, [dispatch, sortedNotifications.length]); + + const handleMarkAsRead = useCallback(( + messageId: string, + screen: string, + groupId: string, + cycleNumber: string, + contributionId: string + ) => { + closeSwipeable(); + handleMarkAsReadAndNavigate(messageId, screen, { + groupId, + cycleNumber, + contributionId, + actionTab3: 'Your Requests', + actionTab1: 'Joined Groups', + GroupDetails: { item: {} }, + }); + }, [closeSwipeable, handleMarkAsReadAndNavigate]); + + const toggleExpand = useCallback((messageId: string) => { + closeSwipeable(messageId); + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setExpandedItems((current) => ({ + ...current, + [messageId]: !current[messageId], + })); + }, [closeSwipeable]); + + const formatTime = useCallback((timestamp: number) => { + const date = new Date(timestamp); + const now = new Date(); + const diff = (now.getTime() - date.getTime()) / (1000 * 60); + + if (diff < 1) return 'Just now'; + if (diff < 60) return `${Math.floor(diff)}m ago`; + if (diff < 1440) return `${Math.floor(diff / 60)}h ago`; + + const dayDiff = Math.floor(diff / 1440); + if (dayDiff === 1) return 'Yesterday'; + if (dayDiff < 7) return `${dayDiff}d ago`; + + return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short' }); + }, []); + + const getNotificationIcon = useCallback((type: string) => { + const map: Record = { + JOIN_REQUEST: 'person-add-outline', + CONTRIBUTION: 'cash-outline', + CONTRIBUTION_ACK: 'checkmark-circle-outline', + CYCLE_COMPLETED: 'trophy', + WITHDRAWAL: 'wallet-outline', + WITHDRAWAL_NOTIFY: 'notifications-outline', + GROUP_STARTED: 'sparkles-outline', + JOIN_APPROVED: 'checkmark-circle-outline', + JOIN_VIA_INVITE: 'mail-outline', + }; + + return map[type] || 'notifications-outline'; + }, []); + + const getNotificationColor = useCallback((type: string) => { + const map: Record = { + JOIN_REQUEST: '#FF6B6B', + CONTRIBUTION: '#4ECDC4', + CONTRIBUTION_ACK: '#45B7D1', + CYCLE_COMPLETED: '#FFD93D', + WITHDRAWAL: '#6C5CE7', + WITHDRAWAL_NOTIFY: '#FF8066', + GROUP_STARTED: '#A8E6CF', + JOIN_APPROVED: '#32D74B', + JOIN_VIA_INVITE: '#007AFF', + }; + + return map[type] || theme.colors.primary; + }, [theme.colors.primary]); + + const getFilterDisplayName = useCallback((filter: FilterType | string) => { + return String(filter || 'Notification').replace(/_/g, ' '); + }, []); + + const renderItem = useCallback(({ item }: { item: AppNotification }) => ( + + ), [ + expandedItems, + formatTime, + getFilterDisplayName, + getNotificationColor, + getNotificationIcon, + handleDelete, + handleMarkAsRead, + handleOpenSwipeable, + registerSwipeable, + theme, + toggleExpand, + ]); + return ( - -
+ + + navigation.goBack()} + style={styles(theme).headerBackButton} + activeOpacity={0.85} + > + + + + + + Notifications + + + - {notifications.length > 0 && ( + {/* {sortedNotifications.length > 0 && ( - + - )} - {notifications.length > 0 && ( + )} */} + {sortedNotifications.length > 0 && ( - + )} - } - /> + + item.messageId} renderItem={renderItem} showsVerticalScrollIndicator={false} - contentContainerStyle={styles(theme).listContainer} + removeClippedSubviews={false} + contentContainerStyle={[ + styles(theme).listContainer, + sortedNotifications.length === 0 && styles(theme).listContainerEmpty, + ]} ListEmptyComponent={ Stay Tuned! @@ -374,35 +585,64 @@ export default Notification; const styles = (theme: any) => StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: theme.colors.background, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingLeft: 12, + paddingVertical: 10, + }, + headerSideSlot: { + width: 96, + justifyContent: 'center', + }, + headerBackButton: { + width: 36, + height: 36, + borderRadius: 12, + justifyContent: 'center', + alignItems: 'center', + }, + headerTitle: { + flex: 1, + textAlign: 'center', + fontSize: 20, + fontFamily: FontsFamily.nunitoBold, + color: theme.colors.textPrimary, + }, listContainer: { - padding: 16, - paddingBottom: 40, + paddingHorizontal: 16, + paddingTop: 6, + paddingBottom: 28, + }, + listContainerEmpty: { + flexGrow: 1, + }, + swipeableRow: { + marginBottom: 12, + overflow: 'visible', }, card: { - backgroundColor: theme.colors.white || '#FFFFFF', + backgroundColor: theme.colors.card, borderRadius: 20, padding: 14, - marginBottom: 12, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.05, - shadowRadius: 8, - elevation: 2, borderWidth: 1, - borderColor: '#F4F4F5', - }, - unreadCard: { - borderColor: (theme.colors.primary || '#6366F1') + '20', - backgroundColor: (theme.colors.primary || '#6366F1') + '05', + borderColor: theme.colors.border, + + }, cardContent: { flexDirection: 'row', alignItems: 'flex-start', }, iconContainer: { - width: 48, - height: 48, - borderRadius: 14, + width: 50, + height: 50, + borderRadius: 15, justifyContent: 'center', alignItems: 'center', marginRight: 12, @@ -413,30 +653,28 @@ const styles = (theme: any) => headerRow: { flexDirection: 'row', justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 4, + alignItems: 'flex-start', + marginBottom: 6, }, title: { fontFamily: FontsFamily.nunitoBold, fontSize: 15, - color: theme.colors.textPrimary || '#18181B', + color: theme.colors.textPrimary, flex: 1, - marginRight: 8, - }, - unreadTitle: { - color: theme.colors.primary || '#6366F1', + marginRight: 10, }, time: { - fontFamily: FontsFamily.nunitoRegular, + fontFamily: FontsFamily.nunitoSemiBold, fontSize: 11, - color: '#71717A', + color: theme.colors.textMuted, + marginTop: 2, }, bodyText: { fontFamily: FontsFamily.nunitoRegular, fontSize: 13, - color: theme.colors.textSecondary || '#52525B', - lineHeight: 18, - marginBottom: 8, + color: theme.colors.textSecondary, + lineHeight: 19, + marginBottom: 10, }, footerRow: { flexDirection: 'row', @@ -446,9 +684,10 @@ const styles = (theme: any) => badge: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 8, + paddingHorizontal: 9, + paddingVertical: 5, + borderRadius: 999, + maxWidth: '58%', }, badgeDot: { width: 6, @@ -457,38 +696,56 @@ const styles = (theme: any) => marginRight: 6, }, badgeText: { - fontFamily: FontsFamily.nunitoSemiBold, + fontFamily: FontsFamily.nunitoBold, fontSize: 10, textTransform: 'capitalize', }, + footerActions: { + flexDirection: 'row', + alignItems: 'center', + }, + expandButton: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderRadius: 999, + paddingHorizontal: 10, + paddingVertical: 5, + marginRight: 10, + }, + expandText: { + fontFamily: FontsFamily.nunitoSemiBold, + fontSize: 11, + marginRight: 4, + }, unreadIndicator: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: theme.colors.primary || '#6366F1', + width: 9, + height: 9, + borderRadius: 4.5, + marginRight: 10, }, deleteSwipeContainer: { - width: 90, + width: 104, height: '100%', justifyContent: 'center', alignItems: 'flex-end', - marginBottom: 12, // Match card margin + paddingLeft: 12, }, deleteSwipe: { - backgroundColor: '#FF3B30', - width: 80, - height: '84%', // A bit shorter than the container for visual balance + backgroundColor: theme.colors.error, + width: 88, + height: '92%', borderRadius: 20, justifyContent: 'center', alignItems: 'center', - shadowColor: '#FF3B30', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 8, - elevation: 5, + shadowColor: theme.colors.error, + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.28, + shadowRadius: 14, + elevation: 6, }, deleteText: { - color: '#FFFFFF', + color: theme.colors.onPrimary, fontFamily: FontsFamily.nunitoBold, fontSize: 12, marginTop: 4, @@ -496,27 +753,32 @@ const styles = (theme: any) => headerActions: { flexDirection: 'row', alignItems: 'center', - gap: 8, + justifyContent: 'flex-end', }, headerIconButton: { width: 36, height: 36, - borderRadius: 10, - backgroundColor: '#F4F4F5', + borderRadius: 12, + backgroundColor: theme.colors.surface, + borderWidth: 1, + borderColor: theme.colors.border, justifyContent: 'center', alignItems: 'center', + marginLeft: 8, }, emptyContainer: { alignItems: 'center', justifyContent: 'center', - marginTop: width * 0.4, - paddingHorizontal: 40, + marginTop: width * 0.28, + paddingHorizontal: 36, }, emptyCircle: { - width: 100, - height: 100, - borderRadius: 50, - backgroundColor: '#F4F4F5', + width: 104, + height: 104, + borderRadius: 52, + backgroundColor: theme.colors.surface, + borderWidth: 1, + borderColor: theme.colors.border, justifyContent: 'center', alignItems: 'center', marginBottom: 24, @@ -530,7 +792,7 @@ const styles = (theme: any) => emptySubText: { fontFamily: FontsFamily.nunitoRegular, fontSize: 14, - color: '#71717A', + color: theme.colors.textSecondary, textAlign: 'center', lineHeight: 20, }, diff --git a/src/screens/payment/PaymentGatewayWebViewScreen.tsx b/src/screens/payment/PaymentGatewayWebViewScreen.tsx new file mode 100644 index 0000000..1c668c9 --- /dev/null +++ b/src/screens/payment/PaymentGatewayWebViewScreen.tsx @@ -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(); + const route = useRoute(); + const webViewRef = useRef(null); + const autoCheckedUrlRef = useRef(null); + const appStateRef = useRef(AppState.currentState); + const isCheckingStatusRef = useRef(false); + const hasTerminalStatusRef = useRef(false); + const pollStartTimeoutRef = useRef | null>(null); + const pollingIntervalRef = useRef | 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 = () => ( + + + + Opening secure payment page... + + + ); + + if (!paymentUrl) { + return ( + + + + + Payment link unavailable + + + We could not open the payment page for this transaction. + + navigation.goBack()} + style={[styles.primaryButton, { backgroundColor: theme.colors.primary }]} + > + Go Back + + + + ); + } + + return ( + + + 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()} + + {/* + + navigation.goBack()} + style={styles.iconButton} + > + + + + + + Secure Payment + + + {currencyCode} {amount.toLocaleString()} {pgOrderNo ? `| ${pgOrderNo}` : ''} + + + + webViewRef.current?.reload()} + style={styles.iconButton} + > + + + + */} + + + verifyPaymentStatus()} + style={[ + styles.primaryButton, + styles.bottomButton, + isCheckingStatus && styles.primaryButtonDisabled, + { + backgroundColor: theme.colors.primary, + }, + ]} + > + {isCheckingStatus ? ( + + ) : ( + <> + + Check Status + + )} + + + + + ); +}; + +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, + }, +}); diff --git a/src/screens/payment/PaymentModeScreen.tsx b/src/screens/payment/PaymentModeScreen.tsx index 2cac4c7..bbddba2 100644 --- a/src/screens/payment/PaymentModeScreen.tsx +++ b/src/screens/payment/PaymentModeScreen.tsx @@ -1,5 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useSelector } from 'react-redux'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { View, Text, @@ -8,7 +7,6 @@ import { TouchableOpacity, ActivityIndicator, Animated, - Image, ToastAndroid, Modal, Easing, @@ -19,10 +17,9 @@ import LucideIcon from '../../components/common/LucideIcon'; import AppView from '../../components/AppView'; import { useTheme } from '../../hooks/ui/useTheme'; import { apiCall } from '../../utils/ApiHelper'; -import { BASE_URL, CONTRIBUTE, PAYMENT_MODES_URL } from '../../utils/ApiUrls'; +import { BASE_URL, CONTRIBUTE, PAYMENT_MODES_URL, PAY_ALL_URL } from '../../utils/ApiUrls'; import FontsFamily from '../../constents/FontsFamily'; import Header from '../../components/common/Header'; -import { clampRGBA } from 'react-native-reanimated/lib/typescript/Colors'; interface PaymentMode { id: number; @@ -39,6 +36,18 @@ interface PaymentMode { iconUrl: string; } +interface PaymentModeRouteParams { + groupId?: string | number; + groupIds?: Array; + amount?: number; + routeFrom?: string; + routeData?: any; +} + +const isPaymentRequestSuccessful = (res: any) => + res?.status === true || res?.status === 'success' || res?.statusCode === 200; +const WEBVIEW_PAYMENT_MODE_ID = 2; + // --- Extracted ModeCard Component (hooks must be in a component, not a render function) --- const getBrandColors = (modeCode: string): { bg: string; text: string } => { switch (modeCode) { @@ -68,7 +77,7 @@ const ModeCard: React.FC = ({ item, index, isSelected, isDisabled delay: index * 100, useNativeDriver: true, }).start(); - }, []); + }, [cardAnim, index]); const brandColors = getBrandColors(item.modeCode); @@ -130,10 +139,15 @@ const ModeCard: React.FC = ({ item, index, isSelected, isDisabled // --- Main Screen --- const PaymentModeScreen: React.FC = () => { const theme = useTheme(); - const navigation = useNavigation(); + const navigation = useNavigation(); const route = useRoute(); - const { groupId, routeFrom, routeData = null } = route.params || {}; - const user = useSelector((state: any) => state.user); + const { + groupId, + groupIds = [], + amount = 0, + routeFrom, + routeData = null, + } = (route.params || {}) as PaymentModeRouteParams; const [modes, setModes] = useState([]); const [loading, setLoading] = useState(true); @@ -153,29 +167,63 @@ const PaymentModeScreen: React.FC = () => { // Animations const fadeAnim = useRef(new Animated.Value(0)).current; const slideAnim = useRef(new Animated.Value(30)).current; + const normalizedGroupIds = useMemo( + () => + (Array.isArray(groupIds) ? groupIds : []).map((id) => { + const numericId = Number(id); + return Number.isNaN(numericId) ? id : numericId; + }), + [groupIds] + ); + const paymentSummary = useMemo(() => { + switch (routeFrom) { + case 'GroupDetails': + return { + label: 'Total Payable', + currencyCode: payable?.currencyCode || 'UGX', + value: Number(payable?.totalPayable || 0), + }; + case 'GroupScreen': + return { + label: 'Entry Fee', + currencyCode: routeData?.currencyCode || 'UGX', + value: Number(routeData?.entryFee || amount || 0), + }; + case 'UserProfileStatus': + return { + label: 'Selected Due', + currencyCode: routeData?.currencyCode || 'UGX', + value: Number(amount || routeData?.totalPayable || 0), + }; + default: + return null; + } + }, [amount, payable, routeData, routeFrom]); useEffect(() => { Animated.parallel([ Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }), Animated.timing(slideAnim, { toValue: 0, duration: 500, useNativeDriver: true }), ]).start(); - }, []); + }, [fadeAnim, slideAnim]); - - useEffect(() => { - { routeFrom === 'GroupDetails' && handleContribution(); } - console.log("data", payable); - }, []); - const handleContribution = async () => { + const handleContribution = useCallback(async () => { try { - const res = await apiCall(`${CONTRIBUTE}${groupId || route?.params?.groupId}/calculate-contribution`, 'GET') as any; + const res = await apiCall(`${CONTRIBUTE}${groupId}/calculate-contribution`, 'GET') as any; setPayable(res.data); } catch (error) { console.log("Error in handleContribution: ", error); } - }; + }, [groupId]); + + useEffect(() => { + if (routeFrom === 'GroupDetails') { + handleContribution(); + } + }, [handleContribution, routeFrom]); + // Spin animation for processing modal - const startSpinAnimation = () => { + const startSpinAnimation = useCallback(() => { spinAnim.setValue(0); spinLoop.current = Animated.loop( Animated.timing(spinAnim, { @@ -186,13 +234,13 @@ const PaymentModeScreen: React.FC = () => { }) ); spinLoop.current.start(); - }; + }, [spinAnim]); - const stopSpinAnimation = () => { + const stopSpinAnimation = useCallback(() => { spinLoop.current?.stop(); - }; + }, []); - const cleanupPolling = () => { + const cleanupPolling = useCallback(() => { if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; @@ -202,12 +250,12 @@ const PaymentModeScreen: React.FC = () => { countdownRef.current = null; } stopSpinAnimation(); - }; + }, [stopSpinAnimation]); // Cleanup on unmount useEffect(() => { return () => cleanupPolling(); - }, []); + }, [cleanupPolling]); const pollTransactionStatus = (pgOrderNo: string) => { setProcessingVisible(true); @@ -304,6 +352,31 @@ const PaymentModeScreen: React.FC = () => { useEffect(() => { fetchPaymentModes(); }, [fetchPaymentModes]); + + const handleSuccessfulPaymentResponse = (res: any, fallbackMessage: string) => { + const paymentData = res?.data || res; + const paymentUrl = paymentData?.paymentUrl; + const pgOrderNo = paymentData?.pgOrderNo || res?.pgOrderNo; + + if (selectedMode === WEBVIEW_PAYMENT_MODE_ID && paymentUrl) { + setPaying(false); + navigation.replace('PaymentGatewayWebView', { + paymentUrl, + paymentData, + }); + return; + } + + if (pgOrderNo) { + pollTransactionStatus(pgOrderNo); + return; + } + + ToastAndroid.show(res?.message || fallbackMessage, ToastAndroid.SHORT); + setPaying(false); + navigation.goBack(); + }; + const handlePaymentForRouteFormGroupScreen = async () => { try { @@ -323,15 +396,8 @@ const PaymentModeScreen: React.FC = () => { console.log("payment res", res) - if (res?.status === 'success' || res?.statusCode === 200) { - const pgOrderNo = res?.data?.pgOrderNo; - if (pgOrderNo) { - // Start polling for transaction status - pollTransactionStatus(pgOrderNo); - } else { - ToastAndroid.show('Payment initiated but no order number received', ToastAndroid.SHORT); - setPaying(false); - } + if (isPaymentRequestSuccessful(res)) { + handleSuccessfulPaymentResponse(res, 'Payment initiated successfully'); } else { ToastAndroid.show(res?.data?.message || res?.message || 'Payment failed', ToastAndroid.SHORT); setPaying(false); @@ -341,7 +407,7 @@ const PaymentModeScreen: React.FC = () => { setPaying(false); } } - const handlePaymentForRouteFormGroupDetails = async (PaymentMode: number) => { + const handlePaymentForRouteFormGroupDetails = async () => { try { setPaying(true); @@ -361,43 +427,11 @@ const PaymentModeScreen: React.FC = () => { console.log("payment payload ", payload) console.log("payment res ", res) - switch (PaymentMode) { - case 6: - { - if (res?.status === 'success' || res?.statusCode === 200) { - - const pgOrderNo = res?.data?.pgOrderNo; - if (pgOrderNo) { - // Start polling for transaction status - pollTransactionStatus(pgOrderNo); - } else { - ToastAndroid.show('Payment initiated but no order number received', ToastAndroid.SHORT); - setPaying(false); - } - } else { - ToastAndroid.show(res?.data?.message || res?.message || 'Payment failed', ToastAndroid.SHORT); - setPaying(false); - } - } - break; - default: - { - if (res?.status === 'success' || res?.statusCode === 200) { - - const pgOrderNo = res?.data?.pgOrderNo; - if (pgOrderNo) { - // Start polling for transaction status - pollTransactionStatus(pgOrderNo); - } else { - ToastAndroid.show('Payment initiated but no order number received', ToastAndroid.SHORT); - setPaying(false); - } - } else { - ToastAndroid.show(res?.data?.message || res?.message || 'Payment failed', ToastAndroid.SHORT); - setPaying(false); - } - } - break; + if (isPaymentRequestSuccessful(res)) { + handleSuccessfulPaymentResponse(res, 'Payment initiated successfully'); + } else { + ToastAndroid.show(res?.data?.message || res?.message || 'Payment failed', ToastAndroid.SHORT); + setPaying(false); } } catch (error) { @@ -405,6 +439,40 @@ const PaymentModeScreen: React.FC = () => { setPaying(false); } } + + const handlePaymentForUserProfileStatus = async () => { + try { + if (normalizedGroupIds.length === 0) { + ToastAndroid.show('Please select at least one due payment', ToastAndroid.SHORT); + return; + } + + setPaying(true); + const payload = { + groupIds: normalizedGroupIds, + payAllDue: true, + paymentId: selectedMode || 0, + mobileNumber: '', + }; + + const res = await apiCall( + PAY_ALL_URL, + 'POST', + payload + ) as any; + + if (isPaymentRequestSuccessful(res)) { + handleSuccessfulPaymentResponse(res, 'Payment initiated successfully'); + } else { + ToastAndroid.show(res?.data?.message || res?.message || 'Payment failed', ToastAndroid.SHORT); + setPaying(false); + } + } catch (error) { + ToastAndroid.show('An error occurred during payment', ToastAndroid.SHORT); + setPaying(false); + } + } + const handleConfirmPayment = async () => { console.log("selectedMode", selectedMode) if (!selectedMode) { @@ -412,13 +480,17 @@ const PaymentModeScreen: React.FC = () => { ToastAndroid.show('Please select a payment method', ToastAndroid.SHORT); return; } + console.log("routeFrom", routeFrom) switch (routeFrom) { case 'GroupDetails': - handlePaymentForRouteFormGroupDetails(selectedMode); + handlePaymentForRouteFormGroupDetails(); break; case 'GroupScreen': handlePaymentForRouteFormGroupScreen(); break; + case 'UserProfileStatus': + handlePaymentForUserProfileStatus(); + break; default: break; } @@ -431,23 +503,19 @@ const PaymentModeScreen: React.FC = () => { {/* Amount Summary */} - {routeFrom === 'GroupDetails' && + {paymentSummary && - Total Payable + {paymentSummary.label} - {payable?.currencyCode || 'UGX'} {Number(payable?.totalPayable || 0).toLocaleString()} - - } - {routeFrom === 'GroupScreen' && - - - Entry Fee - - - {payable?.currencyCode || 'UGX'} {Number(routeData?.entryFee || 0).toLocaleString()} + {paymentSummary.currencyCode} {paymentSummary.value.toLocaleString()} + {routeFrom === 'UserProfileStatus' && routeData?.groupCount ? ( + + {routeData.groupCount} selected group{routeData.groupCount > 1 ? 's' : ''} + + ) : null} } {/* Payment Methods */} @@ -500,7 +568,7 @@ const PaymentModeScreen: React.FC = () => { ) : ( <> - Confirm & Pay + Confirm & Pay )} @@ -616,6 +684,11 @@ const styles = StyleSheet.create({ fontSize: responsiveFontSize(3), fontFamily: FontsFamily.nunitoExtraBold, }, + amountMeta: { + fontSize: responsiveFontSize(1.2), + fontFamily: FontsFamily.nunitoSemiBold, + marginTop: 6, + }, listContent: { paddingBottom: 100, gap: 10, diff --git a/src/screens/profile/BecomeOrg.tsx b/src/screens/profile/BecomeOrg.tsx index 512627a..1304b9a 100644 --- a/src/screens/profile/BecomeOrg.tsx +++ b/src/screens/profile/BecomeOrg.tsx @@ -134,7 +134,7 @@ const BecomeOrg = () => { handleChange("idName", val)} icon="person-outline" diff --git a/src/screens/profile/EditBankDetails.tsx b/src/screens/profile/EditBankDetails.tsx index dae2e43..9505313 100644 --- a/src/screens/profile/EditBankDetails.tsx +++ b/src/screens/profile/EditBankDetails.tsx @@ -25,7 +25,7 @@ type BankFormErrors = { const isValidBankName = (value: string) => /^[A-Za-z][A-Za-z\s.&-]{1,}$/.test(value.trim()); const isValidAccountNumber = (value: string) => /^\d{9,18}$/.test(value.trim()); -const isValidIfsc = (value: string) => /^[A-Z]{4}0[A-Z0-9]{6}$/.test(value.trim().toUpperCase()); +const isValidIfsc = (value: string) => /^[A-Z0-9]{6}$/.test(value.trim().toUpperCase()); const EditBankDetails = () => { const theme = useTheme(); @@ -79,7 +79,7 @@ const EditBankDetails = () => { nextErrors.bankAccountNumber = "Account number must be 9-18 digits."; } if (!isValidIfsc(branchCode)) { - nextErrors.branchCode = "Enter a valid IFSC (e.g. HDFC0001234)."; + nextErrors.branchCode = "Enter a valid branch code."; } setErrors(nextErrors); @@ -135,7 +135,7 @@ const EditBankDetails = () => { /> handleChange("branchCode", val)} diff --git a/src/utils/ApiUrls.tsx b/src/utils/ApiUrls.tsx index d1ef603..2761e83 100644 --- a/src/utils/ApiUrls.tsx +++ b/src/utils/ApiUrls.tsx @@ -11,7 +11,7 @@ export const JOIN_GROUP_URL = `${BASE_URL}fund/`; export const MY_GROUPS_URL = `${BASE_URL}fund/getAll/my/groups` export const REQUESTS_URL = `${BASE_URL}fund/group/pending/request` export const APPROVE_REQUEST_URL = `${BASE_URL}fund/requests/` -export const DECLINE_REQUEST_URL = `${BASE_URL}fund/user/my/groups` +export const DECLINE_REQUEST_URL = `${BASE_URL}fund/requests/` export const GET_JOINED_GROUP_URL = `${BASE_URL}fund/my/join/groups` export const PUT_USER_PROFILE_IMG = `${BASE_URL}user/update/userImg` export const PUT_USER_PROFILE = `${BASE_URL}user/profile/update`