theme update latest project

This commit is contained in:
Abin Toppo
2026-02-16 10:41:38 +05:30
parent bd7dbd864c
commit a98e0b6cdd
75 changed files with 10173 additions and 7915 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
{ {
"postman.settings.dotenv-detection-notification-visibility": false, "postman.settings.dotenv-detection-notification-visibility": false,
"java.compile.nullAnalysis.mode": "automatic" "java.compile.nullAnalysis.mode": "automatic",
"java.configuration.updateBuildConfiguration": "interactive"
} }
+14 -3
View File
@@ -1,7 +1,8 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { StyleSheet, useColorScheme } from 'react-native'; import { StyleSheet } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
import { PaperProvider } from 'react-native-paper'; import { PaperProvider } from 'react-native-paper';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import AppNavigator from './src/navigation/AppNavigator'; import AppNavigator from './src/navigation/AppNavigator';
import notifee from '@notifee/react-native'; import notifee from '@notifee/react-native';
@@ -15,8 +16,16 @@ import {
} from './src/utils/firebase/firebaseServices'; } from './src/utils/firebase/firebaseServices';
import { dark, light } from './src/theme/theme'; import { ThemeProvider } from './src/theme/ThemeProvider';
import { ThemeProvider} from './src/theme/ThemeProvider';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 1000 * 60 * 5, // 5 minutes
},
},
});
function App() { function App() {
@@ -46,11 +55,13 @@ function App() {
return ( return (
<SafeAreaProvider> <SafeAreaProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider> <ThemeProvider>
<PaperProvider > <PaperProvider >
<AppNavigator /> <AppNavigator />
</PaperProvider> </PaperProvider>
</ThemeProvider> </ThemeProvider>
</QueryClientProvider>
</SafeAreaProvider> </SafeAreaProvider>
); );
} }
@@ -0,0 +1,13 @@
arguments=
auto.sync=false
build.scans.enabled=false
connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)
connection.project.dir=
eclipse.preferences.version=1
gradle.user.home=
java.home=
jvm.arguments=
offline.mode=false
override.workspace.settings=false
show.console.view=false
show.executions.view=false
+1
View File
@@ -118,3 +118,4 @@ dependencies {
} }
} }
apply plugin: 'com.google.gms.google-services' apply plugin: 'com.google.gms.google-services'
apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
Binary file not shown.
-37
View File
@@ -1,37 +0,0 @@
{
"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
}
+514 -342
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -28,9 +28,11 @@
"@react-navigation/native-stack": "^7.3.26", "@react-navigation/native-stack": "^7.3.26",
"@react-navigation/stack": "^7.4.8", "@react-navigation/stack": "^7.4.8",
"@reduxjs/toolkit": "^2.9.0", "@reduxjs/toolkit": "^2.9.0",
"@tanstack/react-query": "^5.90.21",
"@types/react-native-material-ripple": "^0.9.6", "@types/react-native-material-ripple": "^0.9.6",
"axios": "^1.12.2", "axios": "^1.12.2",
"install": "^0.13.0", "install": "^0.13.0",
"lucide-react-native": "^0.563.0",
"npm": "^11.6.0", "npm": "^11.6.0",
"react": "19.1.0", "react": "19.1.0",
"react-native": "0.81.1", "react-native": "0.81.1",
@@ -61,9 +63,9 @@
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3", "@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0", "@babel/runtime": "^7.25.0",
"@react-native-community/cli": "20.0.0", "@react-native-community/cli": "^14.0.1",
"@react-native-community/cli-platform-android": "20.0.0", "@react-native-community/cli-platform-android": "^15.1.0",
"@react-native-community/cli-platform-ios": "20.0.0", "@react-native-community/cli-platform-ios": "^14.0.1",
"@react-native/babel-preset": "0.81.1", "@react-native/babel-preset": "0.81.1",
"@react-native/eslint-config": "0.81.1", "@react-native/eslint-config": "0.81.1",
"@react-native/metro-config": "0.81.1", "@react-native/metro-config": "0.81.1",
+129
View File
@@ -0,0 +1,129 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import * as Keychain from 'react-native-keychain';
import { BASE_URL } from '../utils/ApiUrls';
const apiClient = axios.create({
baseURL: BASE_URL,
headers: {
Accept: 'application/json',
},
});
const buildRequestUrl = (config: InternalAxiosRequestConfig) => {
const requestUrl = config.url ?? '';
if (/^https?:\/\//i.test(requestUrl)) return requestUrl;
const base = config.baseURL ?? '';
if (!base) return requestUrl;
return `${base.replace(/\/+$/, '')}/${requestUrl.replace(/^\/+/, '')}`;
};
const getPayloadByMethod = (config: InternalAxiosRequestConfig) => {
const method = (config.method || 'GET').toUpperCase();
if (method === 'GET' || method === 'DELETE') {
return config.params ?? null;
}
return config.data ?? null;
};
const isFormDataPayload = (payload: unknown): payload is FormData => {
return typeof FormData !== 'undefined' && payload instanceof FormData;
};
const getHeaderValue = (headers: InternalAxiosRequestConfig['headers'], name: string) => {
const normalizedName = name.toLowerCase();
const axiosHeaders = headers as unknown as {
get?: (key: string) => string | undefined;
[key: string]: unknown;
};
if (axiosHeaders?.get) {
return axiosHeaders.get(name) || axiosHeaders.get(normalizedName);
}
const plainHeaders = (headers ?? {}) as Record<string, string | undefined>;
return plainHeaders[name] || plainHeaders[normalizedName];
};
const setHeaderValue = (config: InternalAxiosRequestConfig, name: string, value: string) => {
const axiosHeaders = config.headers as unknown as {
set?: (key: string, value: string) => void;
};
if (axiosHeaders?.set) {
axiosHeaders.set(name, value);
return;
}
config.headers = {
...(config.headers as Record<string, string>),
[name]: value,
};
};
const applyRequestContentType = (config: InternalAxiosRequestConfig) => {
const method = (config.method || 'GET').toUpperCase();
const hasBody = !['GET', 'DELETE', 'HEAD'].includes(method);
const currentContentType = getHeaderValue(config.headers, 'Content-Type');
if (isFormDataPayload(config.data)) {
setHeaderValue(config, 'Content-Type', 'multipart/form-data');
return;
}
if (hasBody && !currentContentType) {
setHeaderValue(config, 'Content-Type', 'application/json');
}
};
apiClient.interceptors.request.use(
async (config) => {
try {
const credentials = await Keychain.getGenericPassword();
if (credentials) {
config.headers.Authorization = `Bearer ${credentials.password}`;
}
} catch (error) {
console.error('Error fetching credentials', error);
}
applyRequestContentType(config);
const method = (config.method || 'GET').toUpperCase();
const url = buildRequestUrl(config);
console.log(`[HOOK_API][${method}] URL:`, url);
console.log(`[HOOK_API][${method}] PAYLOAD:`, getPayloadByMethod(config));
return config;
},
(error) => Promise.reject(error)
);
apiClient.interceptors.response.use(
(response) => {
const method = (response.config.method || 'GET').toUpperCase();
const url = buildRequestUrl(response.config);
console.log(`[HOOK_API][${method}] SUCCESS URL:`, url);
console.log(`[HOOK_API][${method}] SUCCESS META:`, {
statusCode: response.status ?? null,
status: response.statusText ?? null,
message: (response.data as { message?: string })?.message ?? null,
});
console.log(`[HOOK_API][${method}] SUCCESS RESPONSE:`, response.data);
return response;
},
(error: AxiosError) => {
const method = (error.config?.method || 'GET').toUpperCase();
const url = error.config ? buildRequestUrl(error.config as InternalAxiosRequestConfig) : '';
console.error(`[HOOK_API][${method}] ERROR URL:`, url || null);
console.error(`[HOOK_API][${method}] ERROR RESPONSE:`, error.response?.data ?? error.message);
return Promise.reject(error);
}
);
export default apiClient;
+73 -13
View File
@@ -1,40 +1,97 @@
import React from "react"; import React from 'react';
import { StatusBar, StyleSheet, View, ViewStyle } from "react-native"; import {
import { SafeAreaView } from "react-native-safe-area-context"; Keyboard,
KeyboardAvoidingView,
Platform,
StatusBar,
StyleSheet,
TouchableWithoutFeedback,
View,
ViewStyle,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTheme } from '../hooks/ui/useTheme';
type AppViewProps = { type AppViewProps = {
children: React.ReactNode; children: React.ReactNode;
backgroundColor?: string; backgroundColor?: string;
barStyle?: "default" | "light-content" | "dark-content"; barStyle?: 'default' | 'light-content' | 'dark-content';
paddingHorizontal?: number; paddingHorizontal?: number;
paddingTop?: number; paddingTop?: number;
style?: ViewStyle; style?: ViewStyle;
keyboardSafe?: boolean;
keyboardVerticalOffset?: number;
dismissKeyboardOnTap?: boolean;
}; };
export default function AppView({ export default function AppView({
children, children,
backgroundColor = "#f5f5f5", backgroundColor,
barStyle = "dark-content", barStyle,
paddingHorizontal = 12, // default paddingHorizontal,
paddingTop = 8, // default paddingTop,
style, style,
keyboardSafe = true,
keyboardVerticalOffset = 0,
dismissKeyboardOnTap = true,
}: AppViewProps) { }: AppViewProps) {
const theme = useTheme();
const finalBackgroundColor = backgroundColor || theme.colors.background;
const finalBarStyle = barStyle || (theme.mode === 'dark' ? 'light-content' : 'dark-content');
const finalPaddingHorizontal = paddingHorizontal !== undefined ? paddingHorizontal : theme.spacing.sm;
const finalPaddingTop = paddingTop !== undefined ? paddingTop : 0;
return ( return (
<SafeAreaView style={[styles.safeArea, { backgroundColor }]}> <SafeAreaView style={[styles.safeArea, { backgroundColor: finalBackgroundColor }]}>
<StatusBar <StatusBar
translucent={false} translucent={false}
backgroundColor={backgroundColor} backgroundColor={finalBackgroundColor}
barStyle={barStyle} barStyle={finalBarStyle}
/> />
{keyboardSafe ? (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={keyboardVerticalOffset}
style={styles.keyboardContainer}
>
{dismissKeyboardOnTap ? (
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
<View style={styles.keyboardContainer}>
<View <View
style={[ style={[
styles.container, styles.container,
{ paddingHorizontal, paddingTop }, { paddingHorizontal: finalPaddingHorizontal, paddingTop: finalPaddingTop },
style, // user custom overrides style,
]} ]}
> >
{children} {children}
</View> </View>
</View>
</TouchableWithoutFeedback>
) : (
<View
style={[
styles.container,
{ paddingHorizontal: finalPaddingHorizontal, paddingTop: finalPaddingTop },
style,
]}
>
{children}
</View>
)}
</KeyboardAvoidingView>
) : (
<View
style={[
styles.container,
{ paddingHorizontal: finalPaddingHorizontal, paddingTop: finalPaddingTop },
style,
]}
>
{children}
</View>
)}
</SafeAreaView> </SafeAreaView>
); );
} }
@@ -43,6 +100,9 @@ const styles = StyleSheet.create({
safeArea: { safeArea: {
flex: 1, flex: 1,
}, },
keyboardContainer: {
flex: 1,
},
container: { container: {
flex: 1, flex: 1,
}, },
+2 -2
View File
@@ -8,7 +8,7 @@ import {
Platform, Platform,
} from "react-native"; } from "react-native";
import { BlurView } from "@react-native-community/blur"; import { BlurView } from "@react-native-community/blur";
import { Ionicons } from "@react-native-vector-icons/ionicons"; import LucideIcon from './common/LucideIcon';
import colors from "./colors"; import colors from "./colors";
import { responsiveFontSize } from "react-native-responsive-dimensions"; import { responsiveFontSize } from "react-native-responsive-dimensions";
import { useTheme } from "../theme/ThemeProvider"; import { useTheme } from "../theme/ThemeProvider";
@@ -121,7 +121,7 @@ const CustomAlert: React.FC<CustomAlertProps> = ({
<View style={styles.contentContainer}> <View style={styles.contentContainer}>
<View style={styles.iconContainer}> <View style={styles.iconContainer}>
<Ionicons name={icon.name} size={36} color={theme.colors.reverseBackground} /> <LucideIcon name={icon.name} size={36} color={theme.colors.reverseBackground} />
</View> </View>
<Text style={[styles.alertTitle, { color: theme.colors.textPrimary }]}>{title}</Text> <Text style={[styles.alertTitle, { color: theme.colors.textPrimary }]}>{title}</Text>
+176 -206
View File
@@ -1,10 +1,12 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ToastAndroid } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, ToastAndroid } from 'react-native';
import colors from './colors';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { BASE_URL, CONTRIBUTE } from '../utils/ApiUrls';
import { apiCall } from '../utils/ApiHelper';
import { responsiveFontSize } from 'react-native-responsive-dimensions'; 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';
interface JoinedGroup { interface JoinedGroup {
id: string; id: string;
@@ -13,8 +15,8 @@ interface JoinedGroup {
visibility: 'PUBLIC' | 'PRIVATE'; visibility: 'PUBLIC' | 'PRIVATE';
createdByUserId: number; createdByUserId: number;
createdByName: string; createdByName: string;
startDate: [number, number, number] | null; startDate: string | null;
endDate: [number, number, number] | null; endDate: string | null;
minParticipants: number; minParticipants: number;
maxParticipants: number; maxParticipants: number;
currentParticipants: number; currentParticipants: number;
@@ -24,138 +26,141 @@ interface JoinedGroup {
} }
interface JoinedGroupCardProps { interface JoinedGroupCardProps {
item: JoinedGroup | any; item: JoinedGroup;
onViewPress: () => void; hasPaid?: boolean;
onPayPress: () => void; // function for Pay button
hasPaid?: boolean; // whether user has already paid
} }
const formatDate = (dateStr: string | null): string => { const formatDate = (dateStr: string | null): string => {
if (!dateStr) return 'N/A'; if (!dateStr) return 'N/A';
const [year, month, day] = dateStr.split('-'); const [year, month, day] = dateStr.split('-');
return `${day}-${month}-${year}`; const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
];
const monthIndex = parseInt(month, 10) - 1;
const dayNum = parseInt(day, 10);
return `${dayNum} ${months[monthIndex]} ${year}`;
}; };
export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item }) => {
const theme = useTheme();
const navigation = useNavigation<any>();
const { data: payableRes } = useFetch<any>(`${CONTRIBUTE}${item.id}/calculate-contribution`);
const payable = payableRes?.data;
const getStatusStyle = (status: JoinedGroup['status']) => { const { mutate: contribute, isPending: loading } = useCreate<any, any>(
`payments/contribute/${item?.id}?amount=${payable?.totalPayable}`,
{
onSuccess: () => {
ToastAndroid.show('Payment successful!', ToastAndroid.SHORT);
},
onError: () => {
ToastAndroid.show('Payment failed. Please try again.', ToastAndroid.SHORT);
},
}
);
const getStatusColors = (status: JoinedGroup['status']) => {
switch (status) { switch (status) {
case 'ACTIVE': case 'ACTIVE':
return { return { bg: theme.palette.greyExtraLight, text: theme.palette.black };
backgroundColor: '#D1FAE5',
textColor: '#065F46',
};
case 'PENDING':
return {
backgroundColor: '#FEF3C7',
textColor: '#92400E',
};
case 'COMPLETED': case 'COMPLETED':
return { return { bg: theme.palette.greyLight, text: theme.palette.greyDark };
backgroundColor: '#E5E7EB',
textColor: '#4B5563',
};
default: default:
return { return { bg: theme.palette.greyExtraLight, text: theme.palette.greyDark };
backgroundColor: '#E5E7EB',
textColor: '#4B5563',
};
}
};
export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item, onViewPress, hasPaid }) => {
const progress = (item.currentParticipants / item.maxParticipants) * 100;
const statusStyle = getStatusStyle(item.status);
const navigation = useNavigation<any>();
const [loading, setLoading] = React.useState(false);
const [payable, setPayable] = React.useState<any>();
const onPayPress = async () => {
// Navigate to payment screen or handle payment logic
// navigation.navigate('PaymentScreen', { groupId: item.id, amount: item.contributionAmount });
setLoading(true);
try {
const res = await apiCall(`${BASE_URL}payments/contribute/${item?.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);
setLoading(false);
}
} catch (error) {
ToastAndroid.show("An error occurred during payment.", ToastAndroid.SHORT);
} finally {
setLoading(false);
} }
}; };
const handleContribution = async () => { const statusColors = getStatusColors(item.status);
try {
const res = await apiCall(`${CONTRIBUTE}${item.id}/calculate-contribution`, 'GET') as any;
setPayable(res.data);
} catch (error) {
console.log("Error in handleContribution: ", error);
}
};
React.useEffect(() => {
handleContribution();
}, []);
return ( return (
<TouchableOpacity style={styles.card} onPress={() => navigation.navigate("GroupDetails", { item, color: colors.black })}> <TouchableOpacity
{/* Header */} style={[styles.card, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}
onPress={() => navigation.navigate('GroupDetails', { item })}
activeOpacity={0.7}
>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.groupName}>{item.groupName}</Text> <View style={styles.titleContainer}>
<View style={[styles.statusBadge, { backgroundColor: statusStyle.backgroundColor }]}> <Text style={[styles.groupName, { color: theme.colors.textPrimary }]}>{item.groupName}</Text>
<Text style={[styles.statusText, { color: statusStyle.textColor }]}>{item.status}</Text> <Text style={[styles.createdBy, { color: theme.colors.textSecondary }]}>
</View> by {item.createdByName}
</View>
<Text style={styles.createdBy}>Created by {item.createdByName}</Text>
{/* Financial Info */}
<View style={styles.infoRow}>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>Contribution</Text>
<Text style={styles.infoValue}>₹{item.contributionAmount.toLocaleString('en-IN')}</Text>
</View>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>Cycle</Text>
<Text style={styles.infoValue}>{item.cycleType}</Text>
</View>
</View>
{payable?.effectiveLateFee && (
<>
<Text style={{ marginBottom: 8, fontSize: 14, color: '#050911ff' }}>Total Payable: ₹{payable?.totalPayable.toLocaleString('en-IN')}</Text>
<Text style={{ marginBottom: 8, fontSize: 14, color: '#050911ff' }}>Effective Late Fee : ₹{payable?.effectiveLateFee.toLocaleString('en-IN')}</Text>
</>
)}
{/* Footer with Dates & Buttons */}
<View style={styles.footer}>
<Text style={styles.dateText}>
{formatDate(item.startDate)} to {formatDate(item.endDate)}
</Text> </Text>
{/* Add Pay / Paid button here */} </View>
{/* {hasPaid ? ( <View style={[styles.statusBadge, { backgroundColor: statusColors.bg }]}>
<View style={styles.paidButton}> <Text style={[styles.statusText, { color: statusColors.text }]}>{item.status}</Text>
<Text style={styles.paidButtonText}>Paid</Text> </View>
</View>
{/* Contribution Amount — always visible */}
<View style={[styles.infoRow, { borderColor: theme.colors.border }]}>
<View style={styles.infoItem}>
<Text style={[styles.infoLabel, { color: theme.colors.textMuted }]}>Contribution</Text>
<Text style={[styles.infoValue, { color: theme.colors.textPrimary }]}>
UGX {item.contributionAmount.toLocaleString()}
</Text>
</View>
<View style={styles.infoItem}>
<Text style={[styles.infoLabel, { color: theme.colors.textMuted }]}>Type</Text>
<Text style={[styles.infoValue, { color: theme.colors.textPrimary }]}>{item.cycleType}</Text>
</View>
</View>
{/* Payment Details — shown when NOT paid and there's an amount due */}
{payable && !payable.hasPaid && payable.totalPayable > 0 && (
<View style={styles.payableSection}>
<View style={styles.payableRow}>
<Text style={[styles.payableLabel, { color: theme.colors.textSecondary }]}>Total Payable</Text>
<Text style={[styles.payableValue, { color: theme.colors.textPrimary }]}>
UGX {payable.totalPayable.toLocaleString()}
</Text>
</View>
{payable.effectiveLateFee > 0 && (
<View style={styles.payableRow}>
<Text style={[styles.payableLabel, { color: theme.colors.warning }]}>Late Fee</Text>
<Text style={[styles.payableValue, { color: theme.colors.warning }]}>
+ UGX {payable.effectiveLateFee.toLocaleString()}
</Text>
</View>
)}
{payable.alreadyPaid > 0 && (
<View style={styles.payableRow}>
<Text style={[styles.payableLabel, { color: theme.colors.success }]}>Already Paid</Text>
<Text style={[styles.payableValue, { color: theme.colors.success }]}>
UGX {payable.alreadyPaid.toLocaleString()}
</Text>
</View>
)}
</View>
)}
<View style={styles.footer}>
<Text style={[styles.dateText, { color: theme.colors.textMuted }]}>
{formatDate(item.startDate)} — {formatDate(item.endDate)}
</Text>
{payable?.hasPaid ? (
<View style={[styles.paidBadge, { backgroundColor: theme.colors.success + '18' }]}>
<Text style={[styles.paidText, { color: theme.colors.success }]}>✓ PAID</Text>
</View> </View>
) : ( ) : (
item.status !== 'COMPLETED' && (
<TouchableOpacity style={styles.payButton} onPress={onPayPress}> <TouchableOpacity
<Text style={styles.payButtonText}>{loading ? 'Loading...' : ' Pay'}</Text> style={[styles.payButton, { backgroundColor: theme.colors.primary }]}
onPress={() => {
navigation.navigate('PaymentMode' as any, {
groupId: item.id,
routeFrom: 'GroupDetails'
})
}}
disabled={loading}
>
<Text style={[styles.payButtonText, { color: theme.colors.onPrimary }]}>
{loading ? '...' : 'Pay'}
</Text>
</TouchableOpacity> </TouchableOpacity>
)} */} )
{ )}
hasPaid && <View style={styles.paidButton}>
<Text style={styles.paidButtonText}>Paid</Text>
</View>
}
{
item.status !== 'COMPLETED' && <TouchableOpacity style={styles.payButton} onPress={onPayPress}>
<Text style={styles.payButtonText}>{loading ? 'Loading...' : ' Pay'}</Text>
</TouchableOpacity>
}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
); );
@@ -163,144 +168,109 @@ export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item, onViewPress, h
const styles = StyleSheet.create({ const styles = StyleSheet.create({
card: { card: {
backgroundColor: colors.pastelOrange,
borderRadius: 16, borderRadius: 16,
padding: 20, padding: 14,
marginBottom: 16, marginBottom: 12,
shadowColor: '#374151', marginHorizontal: 12,
shadowOpacity: 0.08, borderWidth: 1,
shadowRadius: 10, shadowColor: '#000',
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 2 },
elevation: 5, shadowOpacity: 0.05,
marginHorizontal: 12 shadowRadius: 8,
elevation: 2,
}, },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'flex-start', alignItems: 'flex-start',
marginBottom: 4, marginBottom: 10,
},
titleContainer: {
flex: 1,
}, },
groupName: { groupName: {
fontSize: responsiveFontSize(2), fontSize: responsiveFontSize(2),
fontWeight: 'bold', fontFamily: FontsFamily.nunitoExtraBold,
color: '#1F2937', marginBottom: 2,
flexShrink: 1,
}, },
createdBy: { createdBy: {
fontSize: responsiveFontSize(1.4), fontSize: responsiveFontSize(1.4),
color: '#6B7280', fontFamily: FontsFamily.nunitoRegular,
marginBottom: 16,
}, },
statusBadge: { statusBadge: {
paddingHorizontal: 10, paddingHorizontal: 8,
paddingVertical: 5, paddingVertical: 4,
borderRadius: 12, borderRadius: 6,
}, },
statusText: { statusText: {
fontSize: responsiveFontSize(1.2), fontSize: responsiveFontSize(1.2),
fontWeight: '600', fontFamily: FontsFamily.nunitoBold,
textTransform: 'uppercase',
}, },
infoRow: { infoRow: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', paddingVertical: 10,
paddingVertical: 16,
borderTopWidth: 1, borderTopWidth: 1,
borderBottomWidth: 1, borderBottomWidth: 1,
borderColor: colors.pastelOrange, marginBottom: 10,
marginBottom: 16,
backgroundColor: colors.white,
borderRadius: 15,
}, },
infoItem: { infoItem: {
padding: 5, flex: 1,
paddingHorizontal: 10,
backgroundColor: colors.white,
alignItems: 'flex-start',
}, },
infoLabel: { infoLabel: {
fontSize: responsiveFontSize(1.4), fontSize: responsiveFontSize(1.3),
color: '#6B7280', fontFamily: FontsFamily.nunitoRegular,
marginBottom: 4, marginBottom: 2,
}, },
infoValue: { infoValue: {
fontSize: responsiveFontSize(1.6), fontSize: responsiveFontSize(1.6),
fontWeight: '600', fontFamily: FontsFamily.nunitoBold,
color: '#111827',
textTransform: 'capitalize',
}, },
progressContainer: { payableSection: {
marginBottom: 16, marginBottom: 12,
gap: 6,
}, },
progressLabels: { payableRow: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: 8, alignItems: 'center',
}, },
participantText: { payableLabel: {
fontSize: responsiveFontSize(1.4), fontSize: responsiveFontSize(1.35),
color: '#6B7280', fontFamily: FontsFamily.nunitoSemiBold,
}, },
participantCount: { payableValue: {
fontSize: responsiveFontSize(1.4), fontSize: responsiveFontSize(1.5),
fontWeight: '600', fontFamily: FontsFamily.nunitoBold,
color: '#111827',
},
progressBarBackground: {
height: 8,
backgroundColor: '#E5E7EB',
borderRadius: 4,
overflow: 'hidden',
},
progressBarFill: {
height: '100%',
backgroundColor: colors.pastelOrange,
borderRadius: 4,
}, },
footer: { footer: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginTop: 4,
}, },
dateText: { dateText: {
fontSize: responsiveFontSize(1.4), fontSize: responsiveFontSize(1.3),
color: '#4B5563', fontFamily: FontsFamily.nunitoMedium,
fontWeight: '500',
}, },
// Pay button
payButton: { payButton: {
elevation: 5, paddingHorizontal: 16,
backgroundColor: colors.white, paddingVertical: 8,
paddingHorizontal: 25, borderRadius: 8,
paddingVertical: 10, minWidth: 80,
borderRadius: 10, alignItems: 'center',
}, },
payButtonText: { payButtonText: {
color: colors.black, fontSize: responsiveFontSize(1.5),
fontSize: responsiveFontSize(1.4), fontFamily: FontsFamily.nunitoBold,
fontWeight: '600',
}, },
// Paid button paidBadge: {
paidButton: { paddingHorizontal: 12,
backgroundColor: '#D1FAE5', paddingVertical: 6,
paddingHorizontal: 20, borderRadius: 6,
paddingVertical: 10,
borderRadius: 10,
}, },
paidButtonText: { paidText: {
color: '#065F46', fontSize: responsiveFontSize(1.3),
fontSize: responsiveFontSize(1.4), fontFamily: FontsFamily.nunitoBold,
fontWeight: '600', letterSpacing: 0.5,
},
viewButton: {
backgroundColor: colors.pastelOrange,
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 10,
},
viewButtonText: {
color: '#000000ff',
fontSize: responsiveFontSize(1.4),
fontWeight: '600',
}, },
}); });
+92 -86
View File
@@ -1,26 +1,21 @@
// components/MyGroupCard.tsx import React from 'react';
import React from "react"; import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { import LucideIcon from './common/LucideIcon';
View, import { responsiveFontSize } from 'react-native-responsive-dimensions';
Text, import { useTheme } from '../hooks/ui/useTheme';
StyleSheet, import FontsFamily from '../constents/FontsFamily';
TouchableOpacity,
} from "react-native";
import { Ionicons } from "@react-native-vector-icons/ionicons";
import colors from "./colors";
import { responsiveFontSize } from "react-native-responsive-dimensions";
interface MyGroup { interface MyGroup {
id: string; id: string;
groupName: string; groupName: string;
description: string; description: string;
visibility: "PUBLIC" | "PRIVATE"; visibility: 'PUBLIC' | 'PRIVATE';
createdByName: string; createdByName: string;
currentParticipants: number; currentParticipants: number;
maxParticipants: number; maxParticipants: number;
contributionAmount: number; contributionAmount: number;
cycleType: "MONTHLY" | "QUARTERLY" | "YEARLY"; cycleType: 'CUSTOM' | 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'HALF_YEARLY' | 'YEARLY';
status: "PENDING" | "ACTIVE" | "COMPLETED"; status: 'PENDING' | 'APPROVED' | 'ACTIVE' | 'DELAY' | 'COMPLETED' | 'REJECTED' | 'CANCELLED';
} }
interface Props { interface Props {
@@ -29,55 +24,74 @@ interface Props {
} }
export const MyGroupCard: React.FC<Props> = ({ item, onManage }) => { export const MyGroupCard: React.FC<Props> = ({ item, onManage }) => {
const getStatusColor = () => { const theme = useTheme();
const getStatusColors = () => {
switch (item.status) { switch (item.status) {
case "ACTIVE": case 'ACTIVE':
return colors.pastelGreen; return { bg: theme.palette.greyExtraLight, text: theme.palette.black };
case "PENDING": case 'APPROVED':
return colors.pastelOrange; // amber return { bg: theme.colors.success + '22', text: theme.colors.success };
case "COMPLETED": case 'DELAY':
return colors.pastelBlue; return { bg: theme.colors.warning + '22', text: theme.colors.warning };
case 'PENDING':
return { bg: theme.palette.greyExtraLight, text: theme.palette.greyDark };
case 'COMPLETED':
return { bg: theme.palette.greyLight, text: theme.palette.greyDark };
case 'REJECTED':
case 'CANCELLED':
return { bg: theme.colors.error + '22', text: theme.colors.error };
default: default:
return "#adb5bd"; return { bg: theme.palette.greyExtraLight, text: theme.palette.greyDark };
} }
}; };
const statusColors = getStatusColors();
return ( return (
<View style={styles.card}> <View style={[styles.card, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}>
{/* Left Content */}
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<Text style={styles.cardTitle} numberOfLines={1}> <Text style={[styles.cardTitle, { color: theme.colors.textPrimary }]} numberOfLines={1}>
{item.groupName} {item.groupName}
</Text> </Text>
<View style={[styles.statusBadge, { backgroundColor: getStatusColor() }]}> <View style={[styles.statusBadge, { backgroundColor: statusColors.bg }]}>
<Text style={styles.statusBadgeText}>{item.status}</Text> <Text style={[styles.statusBadgeText, { color: statusColors.text }]}>{item.status}</Text>
</View> </View>
</View> </View>
<Text style={styles.cardSubtitle} numberOfLines={2}> <Text style={[styles.cardSubtitle, { color: theme.colors.textSecondary }]} numberOfLines={2}>
{item.description} {item.description}
</Text> </Text>
<View style={styles.cardMetaContainer}> <View style={styles.cardMetaContainer}>
<Text style={styles.cardMetaText}> <View style={styles.metaItem}>
<Ionicons name="person-outline" size={14} /> {item.currentParticipants}/{item.maxParticipants} <LucideIcon name="person-outline" size={14} color={theme.colors.textSecondary} />
</Text> <Text style={[styles.cardMetaText, { color: theme.colors.textSecondary }]}>
<Text style={styles.cardMetaText}> {item.currentParticipants}/{item.maxParticipants}
<Ionicons name="cash-outline" size={14} /> ₹{item.contributionAmount}
</Text>
<Text style={styles.cardMetaText}>
<Ionicons name="time-outline" size={14} /> {item.cycleType}
</Text> </Text>
</View> </View>
<View style={styles.metaItem}>
<Text style={styles.createdBy}>Created by {item.createdByName}</Text> <LucideIcon name="cash-outline" size={14} color={theme.colors.textSecondary} />
<Text style={[styles.cardMetaText, { color: theme.colors.textSecondary }]}>
₹{item.contributionAmount}
</Text>
</View>
<View style={styles.metaItem}>
<LucideIcon name="time-outline" size={14} color={theme.colors.textSecondary} />
<Text style={[styles.cardMetaText, { color: theme.colors.textSecondary }]}>
{item.cycleType}
</Text>
</View>
</View>
</View> </View>
{/* Manage Button */} <TouchableOpacity
<TouchableOpacity style={styles.manageButton} onPress={() => onManage(item)}> style={[styles.manageButton, { backgroundColor: theme.colors.surface }]}
<Ionicons name="settings-outline" size={24} color={colors.textDark} /> onPress={() => onManage(item)}
<Text style={styles.manageButtonText}>Manage</Text> >
<LucideIcon name="settings-outline" size={24} color={theme.colors.textPrimary} />
<Text style={[styles.manageButtonText, { color: theme.colors.textPrimary }]}>Manage</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );
@@ -85,80 +99,72 @@ export const MyGroupCard: React.FC<Props> = ({ item, onManage }) => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
card: { card: {
backgroundColor: "#fff",
borderRadius: 12, borderRadius: 12,
padding: 16, padding: 14,
marginBottom: 12, marginBottom: 10,
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
elevation: 2, borderWidth: 1,
shadowColor: "#000", shadowColor: '#000',
shadowOpacity: 0.05, shadowOpacity: 0.05,
shadowRadius: 4, shadowRadius: 4,
marginHorizontal:12 marginHorizontal: 12,
elevation: 2,
}, },
cardHeader: { cardHeader: {
flexDirection: "row", flexDirection: 'row',
justifyContent: "space-between", justifyContent: 'space-between',
alignItems: "center", alignItems: 'center',
marginBottom: 6, marginBottom: 8,
}, },
cardTitle: { cardTitle: {
fontSize: responsiveFontSize(1.8), fontSize: responsiveFontSize(1.8),
fontWeight: "bold", fontFamily: FontsFamily.nunitoExtraBold,
color: "#343a40",
flex: 1, flex: 1,
marginRight: 10, marginRight: 10,
}, },
cardSubtitle: { cardSubtitle: {
fontSize: responsiveFontSize(1.4), fontSize: responsiveFontSize(1.4),
color: "#6c757d", fontFamily: FontsFamily.nunitoRegular,
marginBottom: 8, marginBottom: 10,
}, },
cardMetaContainer: { cardMetaContainer: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
flexWrap: "wrap", flexWrap: 'wrap',
marginBottom: 6,
}, },
manageButtonText: { metaItem: {
fontSize: responsiveFontSize(1.2), flexDirection: 'row',
color: colors.textDark, alignItems: 'center',
marginRight: 16,
marginBottom: 4,
}, },
cardMetaText: { cardMetaText: {
fontSize: responsiveFontSize(1.3), fontSize: responsiveFontSize(1.3),
color: "#495057", fontFamily: FontsFamily.nunitoSemiBold,
fontWeight: "500", marginLeft: 4,
marginRight: 16,
flexDirection: "row",
alignItems: "center",
}, },
statusBadge: { statusBadge: {
paddingHorizontal: 10, paddingHorizontal: 8,
paddingVertical: 4, paddingVertical: 4,
borderRadius: 5, borderRadius: 4,
elevation: 1,
}, },
statusBadgeText: { statusBadgeText: {
color: colors.textDark,
fontSize: responsiveFontSize(1), fontSize: responsiveFontSize(1),
fontWeight: "bold", fontFamily: FontsFamily.nunitoBold,
textTransform: "uppercase", textTransform: 'uppercase',
}, },
manageButton: { manageButton: {
width: 100, width: 84,
borderRadius:10, borderRadius: 8,
flexDirection:"row", alignItems: 'center',
alignItems: "center", justifyContent: 'center',
justifyContent: "space-around", paddingVertical: 10,
padding: 6,
marginLeft: 10, marginLeft: 10,
elevation:2,
backgroundColor: colors.pastelOrange,
}, },
createdBy: { manageButtonText: {
fontSize: responsiveFontSize(1.2), fontSize: responsiveFontSize(1.2),
color: "#868e96", fontFamily: FontsFamily.nunitoBold,
marginTop: 4, marginTop: 4,
}, },
}); });
+66 -84
View File
@@ -1,11 +1,10 @@
// src/components/RequestCard.tsx
import React, { FC } from 'react'; import React, { FC } from 'react';
import { View, Text, Image, TouchableOpacity, StyleSheet, Platform } from 'react-native'; import { View, Text, Image, TouchableOpacity, StyleSheet, Platform } from 'react-native';
import { Ionicons } from "@react-native-vector-icons/ionicons"; // Assuming Expo for icons import LucideIcon from './common/LucideIcon';
import colors from './colors'; // Your color palette
import { JoinRequest } from '../types'; // Your type definition
import { responsiveFontSize } from 'react-native-responsive-dimensions'; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useTheme } from '../hooks/ui/useTheme';
import { JoinRequest } from '../types';
import FontsFamily from '../constents/FontsFamily';
interface RequestCardProps { interface RequestCardProps {
item: JoinRequest; item: JoinRequest;
@@ -14,46 +13,57 @@ interface RequestCardProps {
} }
export const RequestCard: FC<RequestCardProps> = ({ item, onApprove, onDecline }) => { export const RequestCard: FC<RequestCardProps> = ({ item, onApprove, onDecline }) => {
const theme = useTheme();
return ( return (
<View style={styles.card}> <View
{/* User Information Section */} style={[
styles.card,
{
backgroundColor: theme.colors.card,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.xl,
},
]}
>
<View style={styles.userInfoSection}> <View style={styles.userInfoSection}>
<Image <Image source={require('../assets/images/placeholder.png')} style={styles.avatar} />
source={require('../assets/images/placeholder.png')}
style={styles.avatar}
/>
<View style={styles.userInfo}> <View style={styles.userInfo}>
<Text style={styles.userName} numberOfLines={1}>{item.userName}</Text> <Text style={[styles.userName, { color: theme.colors.textPrimary }]} numberOfLines={1}>
{item.userName}
</Text>
<View style={styles.userMetaContainer}> <View style={styles.userMetaContainer}>
<Ionicons name="call-outline" size={14} color={colors.textLight} /> <LucideIcon name="call-outline" size={14} color={theme.colors.textSecondary} />
<Text style={styles.userMeta}>{item.mobile}</Text> <Text style={[styles.userMeta, { color: theme.colors.textSecondary }]}>{item.mobile}</Text>
</View> </View>
</View> </View>
</View> </View>
{/* Group Information Section */} <View style={[styles.groupInfoSection, { backgroundColor: theme.colors.surface }]}>
<View style={styles.groupInfoSection}> <Text style={[styles.requestText, { color: theme.colors.textSecondary }]}>
<Text style={styles.requestText}>Wants to join group</Text> Wants to join group
<Text style={styles.groupName} numberOfLines={2}>{item.groupName}</Text> </Text>
<Text style={[styles.groupName, { color: theme.colors.textPrimary }]} numberOfLines={2}>
{item.groupName}
</Text>
</View> </View>
{/* Action Buttons Section */}
<View style={styles.actionsSection}> <View style={styles.actionsSection}>
<TouchableOpacity <TouchableOpacity
style={[styles.actionButton, styles.declineButton]} style={[styles.actionButton, { backgroundColor: theme.colors.surface }]}
onPress={() => onDecline(item)} onPress={() => onDecline(item)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Ionicons name="close" size={20} color={colors.textDark} /> <LucideIcon name="close" size={20} color={theme.colors.textPrimary} />
<Text style={styles.actionButtonText}>Decline</Text> <Text style={[styles.actionButtonText, { color: theme.colors.textPrimary }]}>Decline</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.actionButton, styles.approveButton]} style={[styles.actionButton, { backgroundColor: theme.colors.primary }]}
onPress={() => onApprove(item)} onPress={() => onApprove(item)}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Ionicons name="checkmark" size={20} color={colors.black} /> <LucideIcon name="checkmark" size={20} color={theme.colors.onPrimary} />
<Text style={[styles.actionButtonText, { color: colors.black }]}>Approve</Text> <Text style={[styles.actionButtonText, { color: theme.colors.onPrimary }]}>Approve</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -62,110 +72,82 @@ export const RequestCard: FC<RequestCardProps> = ({ item, onApprove, onDecline }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
card: { card: {
marginHorizontal: 16, marginHorizontal: 12,
marginVertical: 12, marginVertical: 8,
backgroundColor: colors.white, padding: 16,
borderRadius: 24, borderWidth: 1,
padding: 20,
...Platform.select({ ...Platform.select({
ios: { ios: {
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.08, shadowOpacity: 0.05,
shadowRadius: 12, shadowRadius: 10,
}, },
android: { android: {
elevation: 8, elevation: 3,
shadowColor: '#00000020', // Softer shadow color for Android
}, },
}), }),
borderWidth: 1,
borderColor: '#f0f0f0',
}, },
userInfoSection: { userInfoSection: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
}, },
avatar: { avatar: {
width: 60, width: 52,
height: 60, height: 52,
borderRadius: 30, borderRadius: 26,
marginRight: 16, marginRight: 14,
borderWidth: 2, backgroundColor: '#E6E6E6',
borderColor: colors.pastelOrange,
}, },
userInfo: { userInfo: {
flex: 1, flex: 1,
}, },
userName: { userName: {
fontSize: responsiveFontSize(1.9), fontSize: responsiveFontSize(1.8),
fontWeight: 'bold', fontFamily: FontsFamily.nunitoExtraBold,
color: colors.textDark,
}, },
userMetaContainer: { userMetaContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginTop: 5, marginTop: 4,
}, },
userMeta: { userMeta: {
fontSize: responsiveFontSize(1.4), fontSize: responsiveFontSize(1.4),
color: colors.textLight, fontFamily: FontsFamily.nunitoRegular,
marginLeft: 8, marginLeft: 6,
}, },
groupInfoSection: { groupInfoSection: {
marginTop: 18, marginTop: 14,
paddingVertical: 16, padding: 14,
paddingHorizontal: 16, borderRadius: 12,
backgroundColor: '#F7F8FA',
borderRadius: 16,
alignItems: 'center', alignItems: 'center',
}, },
requestText: { requestText: {
fontSize: responsiveFontSize(1.4), fontSize: responsiveFontSize(1.3),
color: colors.textLight, fontFamily: FontsFamily.nunitoRegular,
}, },
groupName: { groupName: {
fontSize: responsiveFontSize(1.7), fontSize: responsiveFontSize(1.6),
fontWeight: '600', fontFamily: FontsFamily.nunitoBold,
color: colors.textDark, marginTop: 4,
marginTop: 6,
textAlign: 'center', textAlign: 'center',
}, },
actionsSection: { actionsSection: {
flexDirection: 'row', flexDirection: 'row',
marginTop: 20, marginTop: 16,
gap: 12, gap: 10,
}, },
actionButton: { actionButton: {
flex: 1, flex: 1,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingVertical: 16, paddingVertical: 12,
borderRadius: 16, borderRadius: 12,
},
declineButton: {
backgroundColor: '#F1F3F6',
},
approveButton: {
backgroundColor: colors.pastelOrange,
...Platform.select({
ios: {
shadowColor: colors.pastelOrange,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
},
android: {
elevation: 6,
shadowColor: colors.pastelOrange,
},
}),
}, },
actionButtonText: { actionButtonText: {
fontSize: responsiveFontSize(1.6), fontSize: responsiveFontSize(1.5),
fontWeight: 'bold', fontFamily: FontsFamily.nunitoBold,
marginLeft: 8, marginLeft: 8,
color: colors.textDark,
}, },
}); });
+52 -58
View File
@@ -1,42 +1,43 @@
import React, { FC } from "react"; import React, { FC } from "react";
import { View, Text, StyleSheet } from "react-native"; import { View, Text, StyleSheet } from "react-native";
import { Ionicons } from "@react-native-vector-icons/ionicons"; import LucideIcon from './common/LucideIcon';
import colors from "./colors";
import { UserJoinRequest } from "../types"; import { UserJoinRequest } from "../types";
import { formatDate } from "../utils/HelperFunction"; import { formatDate } from "../utils/HelperFunction";
import { responsiveFontSize } from "react-native-responsive-dimensions"; import { responsiveFontSize } from "react-native-responsive-dimensions";
import { useTheme } from "../hooks/ui/useTheme";
import FontsFamily from "../constents/FontsFamily";
interface UserRequestedProps { interface UserRequestedProps {
item: UserJoinRequest; item: UserJoinRequest;
} }
const getStatusStyle = (status: UserJoinRequest["status"]) => { const getStatusStyle = (status: UserJoinRequest["status"], theme: ReturnType<typeof useTheme>) => {
switch (status) { switch (status) {
case "APPROVED": case "APPROVED":
return { return {
backgroundColor: "#D1FAE5", backgroundColor: theme.colors.success + "22",
textColor: "#065F46", textColor: theme.colors.success,
label: "Approved", label: "Approved",
icon: "checkmark-circle-outline", icon: "checkmark-circle-outline",
}; };
case "PENDING": case "PENDING":
return { return {
backgroundColor: "#FEF3C7", backgroundColor: theme.colors.warning + "22",
textColor: "#92400E", textColor: theme.colors.warning,
label: "Pending", label: "Pending",
icon: "time-outline", icon: "time-outline",
}; };
case "REJECT": case "REJECT":
return { return {
backgroundColor: "#FEE2E2", backgroundColor: theme.colors.error + "22",
textColor: "#B91C1C", textColor: theme.colors.error,
label: "Rejected", label: "Rejected",
icon: "close-circle-outline", icon: "close-circle-outline",
}; };
default: default:
return { return {
backgroundColor: "#E5E7EB", backgroundColor: theme.colors.borderLight,
textColor: "#4B5563", textColor: theme.colors.textSecondary,
label: "Unknown", label: "Unknown",
icon: "help-outline", icon: "help-outline",
}; };
@@ -44,41 +45,40 @@ const getStatusStyle = (status: UserJoinRequest["status"]) => {
}; };
export const UserRequested: FC<UserRequestedProps> = ({ item }) => { export const UserRequested: FC<UserRequestedProps> = ({ item }) => {
const { backgroundColor, textColor, label, icon } = getStatusStyle(item.status); const theme = useTheme();
const { backgroundColor, textColor, label, icon } = getStatusStyle(item.status, theme);
// Optionally, initials avatar (remove/comment if you want): const initials = item.createdBy?.trim().split(" ").map((w) => w[0])?.join("").toUpperCase() || "?";
const initials = item.createdBy?.trim().split(" ").map((w) => w[0])?.join("").toUpperCase();
return ( return (
<View style={styles.card}> <View style={[styles.card, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}>
<View style={styles.topRow}> <View style={[styles.topRow, { borderBottomColor: theme.colors.border }]}>
<View style={styles.avatarContainer}> <View style={styles.avatarContainer}>
<View style={styles.avatarCircle}> <View style={[styles.avatarCircle, { backgroundColor: theme.colors.primary }]}>
<Text style={styles.avatarText}>{initials}</Text> <Text style={[styles.avatarText, { color: theme.colors.onPrimary }]}>{initials}</Text>
</View> </View>
</View> </View>
<View style={styles.userInfoSection}> <View style={styles.userInfoSection}>
<Text style={styles.groupName}>{item.groupName}</Text> <Text style={[styles.groupName, { color: theme.colors.textPrimary }]}>{item.groupName}</Text>
<Text style={styles.userMeta}> <Text style={[styles.userMeta, { color: theme.colors.textSecondary }]}>
<Ionicons name="time-outline" size={13} color={colors.textLight} />{" "} <LucideIcon name="time-outline" size={13} color={theme.colors.textMuted} />{" "}
{formatDate(item.requestDate)} {formatDate(item.requestDate)}
</Text> </Text>
</View> </View>
<View style={[styles.statusBadge, { backgroundColor }]}> <View style={[styles.statusBadge, { backgroundColor }]}>
<Ionicons name={icon as any} size={17} color={textColor} /> <LucideIcon name={icon as any} size={17} color={textColor} />
<Text style={[styles.statusText, { color: textColor }]}>{label}</Text> <Text style={[styles.statusText, { color: textColor }]}>{label}</Text>
</View> </View>
</View> </View>
<View style={styles.groupInfoSection}> <View style={styles.groupInfoSection}>
<Text style={styles.requestText}> <Text style={[styles.requestText, { color: theme.colors.textSecondary }]}>
<Ionicons name="person-outline" size={13} color={colors.textLight} /> <LucideIcon name="person-outline" size={13} color={theme.colors.textMuted} />
{" "}Group Created By:{" "} {" "}Group Created By:{" "}
<Text style={styles.userName} numberOfLines={1}>{item.createdBy}</Text> <Text style={[styles.userName, { color: theme.colors.textPrimary }]} numberOfLines={1}>{item.createdBy}</Text>
</Text> </Text>
{item.reason ? ( {item.reason ? (
<Text style={styles.reasonText} numberOfLines={2}> <Text style={[styles.reasonText, { color: theme.colors.textMuted }]} numberOfLines={2}>
<Ionicons name="chatbubbles-outline" size={13} color={colors.textLight} /> {item.reason} <LucideIcon name="chatbubbles-outline" size={13} color={theme.colors.textMuted} /> {item.reason}
</Text> </Text>
) : null} ) : null}
</View> </View>
@@ -88,79 +88,73 @@ export const UserRequested: FC<UserRequestedProps> = ({ item }) => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
card: { card: {
backgroundColor: colors.white, borderRadius: 14,
borderRadius: 16, marginVertical: 8,
marginVertical: 14, // More space marginHorizontal: 12,
marginHorizontal: 10, elevation: 2,
elevation: 4, borderWidth: 1,
shadowColor: "#000", shadowColor: "#000",
shadowOpacity: 0.12, shadowOpacity: 0.08,
shadowRadius: 8, shadowRadius: 6,
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
padding: 16, padding: 14,
}, },
topRow: { topRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "flex-start", alignItems: "flex-start",
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: "#e5e7eb", paddingBottom: 8,
paddingBottom: 10, marginBottom: 6,
marginBottom: 6
}, },
avatarContainer: { avatarContainer: {
marginRight: 10, marginRight: 8,
marginTop: 2, marginTop: 2,
}, },
avatarCircle: { avatarCircle: {
width: 36, width: 36,
height: 36, height: 36,
borderRadius: 18, borderRadius: 18,
backgroundColor: colors.pastelBlue,
justifyContent: "center", justifyContent: "center",
alignItems: "center" alignItems: "center"
}, },
avatarText: { avatarText: {
fontWeight: "700", fontFamily: FontsFamily.nunitoBold,
fontSize: responsiveFontSize(1.6), fontSize: responsiveFontSize(1.6),
color: colors.white,
}, },
userInfoSection: { userInfoSection: {
flex: 1, flex: 1,
paddingRight: 8, paddingRight: 6,
}, },
userName: { userName: {
fontWeight: "bold", fontFamily: FontsFamily.nunitoBold,
color: colors.textDark,
fontSize: responsiveFontSize(1.5), fontSize: responsiveFontSize(1.5),
}, },
userMeta: { userMeta: {
fontSize: responsiveFontSize(1.2), fontSize: responsiveFontSize(1.2),
color: colors.textLight, fontFamily: FontsFamily.nunitoRegular,
marginTop: 3, marginTop: 3,
marginBottom: -3, marginBottom: -3,
}, },
groupInfoSection: { groupInfoSection: {
paddingTop: 10, paddingTop: 8,
paddingBottom: 6, paddingBottom: 2,
}, },
requestText: { requestText: {
color: colors.textDark,
fontSize: responsiveFontSize(1.5), fontSize: responsiveFontSize(1.5),
marginBottom: 2, marginBottom: 2,
lineHeight: 22, lineHeight: 20,
fontFamily: FontsFamily.nunitoRegular,
}, },
groupName: { groupName: {
fontWeight: "600", fontFamily: FontsFamily.nunitoSemiBold,
color: colors.Settings,
fontSize: responsiveFontSize(1.8), fontSize: responsiveFontSize(1.8),
marginBottom: 2, marginBottom: 2,
}, },
reasonText: { reasonText: {
fontSize: responsiveFontSize(1.2), fontSize: responsiveFontSize(1.2),
color: colors.textLight,
marginTop: 6, marginTop: 6,
paddingLeft: 1, paddingLeft: 1,
lineHeight: 18, lineHeight: 16,
fontStyle: "italic", fontStyle: "italic",
}, },
statusBadge: { statusBadge: {
@@ -170,15 +164,15 @@ const styles = StyleSheet.create({
paddingHorizontal: 10, paddingHorizontal: 10,
paddingVertical: 5, paddingVertical: 5,
marginLeft: 4, marginLeft: 4,
minWidth: 90, minWidth: 84,
justifyContent: "center", justifyContent: "center",
alignSelf: "flex-start", alignSelf: "flex-start",
marginTop: 2 marginTop: 2
}, },
statusText: { statusText: {
fontSize: responsiveFontSize(1.4), fontSize: responsiveFontSize(1.4),
fontWeight: "700", fontFamily: FontsFamily.nunitoBold,
marginLeft: 6, marginLeft: 6,
letterSpacing: 0.5 letterSpacing: 0.3
}, },
}); });
+66 -35
View File
@@ -1,44 +1,62 @@
import React, { use, useRef } from "react"; import React, { useRef } from 'react';
import { View, Text, StyleSheet, Animated, TouchableWithoutFeedback } from "react-native"; import { View, Text, StyleSheet, Animated, TouchableWithoutFeedback } from 'react-native';
import LinearGradient from "react-native-linear-gradient"; import LucideIcon from './LucideIcon';
import { Ionicons } from "@react-native-vector-icons/ionicons"; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import colors from "../colors"; import { useTheme } from '../../hooks/ui/useTheme';
import { responsiveFontSize } from "react-native-responsive-dimensions"; import FontsFamily from '../../constents/FontsFamily';
import { useTheme } from "../../theme/ThemeProvider";
import FontsFamily from "../../constents/FontsFamily";
type CardProps = { type CardProps = {
title: string; title: string;
subtitle?: string; subtitle?: string;
iconName?: string; iconName?: string;
onPress?: () => void; onPress?: () => void;
backOne?: string;
backTwo?: string;
}; };
const AnimatedCard: React.FC<CardProps> = ({ const AnimatedCard: React.FC<CardProps> = ({
title, title,
subtitle, subtitle,
iconName = "ellipse-outline", iconName = 'ellipse-outline',
onPress, onPress,
backOne = colors.primary,
backTwo = colors.pastelPink,
}) => { }) => {
const theme = useTheme();
const scale = useRef(new Animated.Value(1)).current; const scale = useRef(new Animated.Value(1)).current;
const{ theme } = useTheme()
const handlePress = () => { const handlePressIn = () => {
Animated.sequence([ Animated.spring(scale, { toValue: 0.95, useNativeDriver: true }).start();
Animated.spring(scale, { toValue: 0.95, useNativeDriver: true }), };
]).start(() => onPress && onPress());
const handlePressOut = () => {
Animated.spring(scale, { toValue: 1, useNativeDriver: true }).start();
}; };
return ( return (
<TouchableWithoutFeedback onPress={handlePress}> <TouchableWithoutFeedback
<Animated.View style={[styles.card, { transform: [{ scale }] }]}> onPress={onPress}
<View style={[styles.gradient, { backgroundColor: theme.colors.reverseBackground }]}> onPressIn={handlePressIn}
<Ionicons name={iconName} size={38} color={theme.colors.reverseText} style={styles.icon} /> onPressOut={handlePressOut}
<Text style={[styles.title, { color: theme.colors.reverseText }]}>{title}</Text> >
{subtitle && <Text style={[styles.subtitle, { color: theme.colors.reverseText }]}>{subtitle}</Text>} <Animated.View
style={[
styles.card,
{
transform: [{ scale }],
backgroundColor: theme.colors.surface,
borderRadius: theme.borderRadius.lg,
borderColor: theme.colors.border,
},
]}
>
<View style={styles.content}>
<LucideIcon
name={iconName}
size={38}
color={theme.colors.textPrimary}
style={styles.icon}
/>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
{subtitle && (
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>{subtitle}</Text>
)}
</View> </View>
</Animated.View> </Animated.View>
</TouchableWithoutFeedback> </TouchableWithoutFeedback>
@@ -48,20 +66,33 @@ const AnimatedCard: React.FC<CardProps> = ({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
card: { card: {
flex: 1, flex: 1,
height: 160, height: 150,
margin: 8, margin: 6,
borderRadius: 20, borderWidth: 1,
overflow: "hidden", elevation: 2,
elevation: 5, shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 4,
}, },
gradient: { content: {
flex: 1, flex: 1,
justifyContent: "center", justifyContent: 'center',
alignItems: "center", alignItems: 'center',
padding: 16,
},
icon: { marginBottom: 12 },
title: {
fontSize: responsiveFontSize(2),
fontFamily: FontsFamily.nunitoExtraBold,
textAlign: 'center',
},
subtitle: {
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoRegular,
textAlign: 'center',
marginTop: 4,
}, },
icon: { marginBottom: 10 },
title: { fontSize: responsiveFontSize(2.2), fontFamily: FontsFamily.nunitoExtraBold, color: "#fff" },
subtitle: { fontSize: responsiveFontSize(1.5), fontFamily: FontsFamily.nunitoRegular, opacity: 0.9 },
}); });
export default AnimatedCard; export default AnimatedCard;
+157 -57
View File
@@ -1,46 +1,76 @@
import React from "react"; import React from 'react';
import { import { StyleSheet, View, Text, TextInput, TouchableOpacity, TextInputProps } from 'react-native';
StyleSheet, import LucideIcon, { LucideIconName } from './LucideIcon';
View, import { responsiveFontSize } from 'react-native-responsive-dimensions';
Text, import { useTheme } from '../../hooks/ui/useTheme';
TextInput,
TouchableOpacity,
TextInputProps,
} from "react-native";
import {Ionicons, IoniconsIconName} from "@react-native-vector-icons/ionicons";
import colors from "../colors";
import { responsiveFontSize } from "react-native-responsive-dimensions";
// --- Custom Input --- // --- Custom Input ---
type CustomInputProps = TextInputProps & { type CustomInputProps = TextInputProps & {
label?: string; label?: string;
icon?: IoniconsIconName; icon?: string;
error?:string error?: string;
value?: string | number; value?: string | number;
}; };
const CustomInput: React.FC<CustomInputProps> = ({ const withOpacity = (hex: string, alpha: number) => {
label, const normalized = hex.replace('#', '');
value, if (normalized.length !== 6) return hex;
icon,
style, const r = parseInt(normalized.slice(0, 2), 16);
error, const g = parseInt(normalized.slice(2, 4), 16);
...rest const b = parseInt(normalized.slice(4, 6), 16);
}) => { return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
const CustomInput: React.FC<CustomInputProps> = ({ label, value, icon, style, error, placeholder, ...rest }) => {
const theme = useTheme();
const computedPlaceholder = placeholder ?? (label ? `Enter ${label.toLowerCase()}` : undefined);
const hasError = Boolean(error && String(error).trim());
return ( return (
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
{label ? <Text style={styles.inputLabel}>{label}</Text> : null} {label ? <Text style={[styles.inputLabel, { color: theme.colors.textPrimary }]}>{label}</Text> : null}
<View style={styles.inputWrapper}> <View style={styles.inputWrapper}>
{icon && <Ionicons name={icon} size={20} color={colors.pastelBlue} style={styles.inputIcon} />} {icon && (
<LucideIcon
name={icon}
size={20}
color={theme.colors.textSecondary}
style={styles.inputIcon}
/>
)}
<TextInput <TextInput
style={[styles.input, icon && { paddingLeft: 36 }, style]} style={[
placeholderTextColor={colors.textLight} styles.input,
{
value={value ? String(value) : undefined} backgroundColor: theme.colors.background,
{...(error ? {borderColor:'red', borderWidth:1} : {})} color: theme.colors.textPrimary,
borderColor: hasError ? theme.colors.error : theme.colors.border,
borderWidth: 1,
paddingLeft: icon ? 40 : 12,
},
style,
]}
placeholder={computedPlaceholder}
placeholderTextColor={theme.colors.textMuted}
value={value !== undefined && value !== null ? String(value) : ''}
{...rest} {...rest}
/> />
</View> </View>
{hasError ? (
<View
style={[
styles.errorContainer,
{
backgroundColor: withOpacity(theme.colors.error, 0.1),
borderColor: withOpacity(theme.colors.error, 0.3),
},
]}
>
<LucideIcon name="alert-circle" size={13} color={theme.colors.error} />
<Text style={[styles.errorText, { color: theme.colors.error }]}>{error}</Text>
</View>
) : null}
</View> </View>
); );
}; };
@@ -49,32 +79,72 @@ const CustomInput: React.FC<CustomInputProps> = ({
type CustomButtonProps = { type CustomButtonProps = {
title: string; title: string;
onPress: () => void; onPress: () => void;
type?: "primary" | "secondary" | "danger"; type?: 'primary' | 'secondary' | 'outline';
icon?: IoniconsIconName; icon?: string;
style?: object; style?: object;
buttonBackground?: string; buttonBackground?: string;
textColor?: string;
loading?: boolean;
disabled?: boolean;
}; };
const CustomButton: React.FC<CustomButtonProps> = ({ const CustomButton: React.FC<CustomButtonProps> = ({
title, title,
onPress, onPress,
type = "primary", type = 'primary',
icon, icon,
style = {}, style = {},
buttonBackground, buttonBackground,
textColor: customTextColor,
loading = false,
disabled = false,
}) => { }) => {
const theme = useTheme();
const textColor = type === "secondary" ? colors.textDark : '#000'; const getBackgroundColor = () => {
if (disabled) return theme.palette.greyLight;
if (buttonBackground) return buttonBackground;
if (type === 'primary') return theme.colors.primary;
if (type === 'secondary') return theme.palette.greyMedium;
return 'transparent';
};
const getTextColor = () => {
if (disabled) return theme.colors.textMuted;
if (customTextColor) return customTextColor;
if (type === 'primary') return theme.colors.onPrimary;
if (type === 'secondary') return theme.colors.textPrimary;
if (type === 'outline') return theme.colors.primary;
return theme.colors.onPrimary;
};
const backgroundColor = getBackgroundColor();
const textColor = getTextColor();
return ( return (
<TouchableOpacity <TouchableOpacity
style={[styles.button, { backgroundColor: buttonBackground }, style]} style={[
styles.button,
type === 'outline' && styles.outlineButton,
{
backgroundColor,
borderColor: (type === 'outline' && !disabled) ? theme.colors.primary : 'transparent',
borderWidth: (type === 'outline' && !disabled) ? 1 : 0,
opacity: (loading || disabled) ? 0.7 : 1,
},
style,
]}
onPress={onPress} onPress={onPress}
disabled={loading || disabled}
activeOpacity={0.8} activeOpacity={0.8}
> >
<View style={styles.buttonContent}> <View style={styles.buttonContent}>
{icon && <Ionicons name={icon} size={20} color={textColor} style={{ marginRight: 8 }} />} {icon && !loading && (
<Text style={[styles.buttonText, { color: textColor }]}>{title}</Text> <LucideIcon name={icon} size={20} color={textColor} style={{ marginRight: 8 }} />
)}
<Text style={[styles.buttonText, { color: textColor }]}>
{loading ? 'Processing...' : title}
</Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
); );
@@ -82,42 +152,72 @@ const CustomButton: React.FC<CustomButtonProps> = ({
// --- Styles --- // --- Styles ---
const styles = StyleSheet.create({ const styles = StyleSheet.create({
inputContainer: { marginBottom: 12,elevation:5 }, inputContainer: {
inputLabel: { fontSize: responsiveFontSize(1.5), fontWeight: "500", color: colors.textDark, marginBottom: 6 }, marginBottom: 14,
},
inputLabel: {
fontSize: responsiveFontSize(1.6),
fontWeight: '600',
marginBottom: 8,
},
inputWrapper: { inputWrapper: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
position: "relative", position: 'relative',
}, },
inputIcon: { inputIcon: {
position: "absolute", position: 'absolute',
left: 10, left: 12,
zIndex: 1, zIndex: 1,
}, },
input: { input: {
flex: 1, flex: 1,
borderRadius: 15, borderRadius: 12,
padding: 12, paddingHorizontal: 12,
paddingLeft: 12, paddingVertical: 12,
backgroundColor: colors.white, fontSize: responsiveFontSize(1.6),
color: colors.textDark, },
fontSize: responsiveFontSize(1.5), errorContainer: {
marginTop: 6,
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 10,
paddingVertical: 6,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
errorText: {
fontSize: responsiveFontSize(1.25),
fontWeight: '600',
flex: 1,
}, },
button: { button: {
elevation:5, paddingVertical: 14,
paddingVertical: 12,
borderRadius: 12, borderRadius: 12,
alignItems: "center", alignItems: 'center',
marginVertical: 6, marginVertical: 6,
// Soft shadow for depth
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 4,
elevation: 2,
},
outlineButton: {
shadowOpacity: 0,
shadowRadius: 0,
shadowOffset: { width: 0, height: 0 },
elevation: 0,
}, },
buttonContent: { buttonContent: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
}, },
buttonText: { buttonText: {
fontSize: responsiveFontSize(1.6), fontSize: responsiveFontSize(1.8),
fontWeight: "600", fontWeight: '700',
}, },
}); });
+76 -55
View File
@@ -1,4 +1,4 @@
import React, { useRef, useEffect } from "react"; import React, { useRef, useEffect } from 'react';
import { import {
View, View,
Text, Text,
@@ -7,11 +7,16 @@ import {
TouchableOpacity, TouchableOpacity,
Animated, Animated,
Dimensions, Dimensions,
} from "react-native"; KeyboardAvoidingView,
import colors from "../colors"; Platform,
import { responsiveFontSize } from "react-native-responsive-dimensions"; TouchableWithoutFeedback,
Keyboard,
} from 'react-native';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useTheme } from '../../hooks/ui/useTheme';
import FontsFamily from '../../constents/FontsFamily';
const { height } = Dimensions.get("window"); const { height } = Dimensions.get('window');
type CustomModalProps = { type CustomModalProps = {
visible: boolean; visible: boolean;
@@ -21,9 +26,9 @@ type CustomModalProps = {
confirmText?: string; confirmText?: string;
cancelText?: string; cancelText?: string;
onConfirm?: () => void; onConfirm?: () => void;
confirmButton?:string confirmButton?: string;
confirmTextColor?:string confirmTextColor?: string;
elevation?:number elevation?: number;
}; };
const CustomModal: React.FC<CustomModalProps> = ({ const CustomModal: React.FC<CustomModalProps> = ({
@@ -31,14 +36,15 @@ const CustomModal: React.FC<CustomModalProps> = ({
onClose, onClose,
title, title,
children, children,
confirmText = "Confirm", confirmText = 'Confirm',
cancelText = "Cancel", cancelText = 'Cancel',
onConfirm, onConfirm,
confirmTextColor, confirmTextColor,
elevation, elevation = 5,
confirmButton = colors.primary confirmButton,
}) => { }) => {
const slideAnim = useRef(new Animated.Value(height)).current; // starts off screen const theme = useTheme();
const slideAnim = useRef(new Animated.Value(height)).current;
const fadeAnim = useRef(new Animated.Value(0)).current; const fadeAnim = useRef(new Animated.Value(0)).current;
useEffect(() => { useEffect(() => {
@@ -69,45 +75,65 @@ const CustomModal: React.FC<CustomModalProps> = ({
}), }),
]).start(); ]).start();
} }
}, [visible]); }, [fadeAnim, slideAnim, visible]);
return ( return (
<Modal transparent visible={visible} animationType="none"> <Modal transparent visible={visible} animationType="none">
{/* Overlay */}
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}> <Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
{/* Modal Box */} <KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardContainer}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
<View style={styles.keyboardContainer}>
<Animated.View <Animated.View
style={[ style={[
styles.modalBox, styles.modalBox,
{ transform: [{ translateY: slideAnim }] }, {
backgroundColor: theme.colors.surface,
borderTopLeftRadius: theme.borderRadius.xl,
borderTopRightRadius: theme.borderRadius.xl,
transform: [{ translateY: slideAnim }]
},
]} ]}
> >
{/* Header */} {title && (
{ <Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
title && )}
<Text style={styles.title}>{title}</Text>
}
{/* Body */}
<View style={styles.content}>{children}</View> <View style={styles.content}>{children}</View>
{/* Footer Actions */}
<View style={styles.actions}> <View style={styles.actions}>
<TouchableOpacity <TouchableOpacity
style={[styles.button, styles.cancelButton,{elevation:elevation}]} style={[styles.button, { backgroundColor: theme.colors.background, elevation: elevation }]}
onPress={onClose} onPress={onClose}
> >
<Text style={styles.cancelText}>{cancelText}</Text> <Text style={[styles.cancelText, { color: theme.colors.textSecondary }]}>{cancelText}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.button, {backgroundColor:confirmButton ,elevation:elevation}]} style={[
styles.button,
{
backgroundColor: confirmButton || theme.colors.primary,
elevation: elevation
}
]}
onPress={onConfirm} onPress={onConfirm}
> >
<Text style={[styles.confirmText,{color:confirmTextColor}]}>{confirmText}</Text> <Text
style={[
styles.confirmText,
{ color: confirmTextColor || theme.colors.onPrimary }
]}
>
{confirmText}
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</Animated.View> </Animated.View>
</View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</Animated.View> </Animated.View>
</Modal> </Modal>
); );
@@ -118,46 +144,41 @@ export default CustomModal;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
overlay: { overlay: {
flex: 1, flex: 1,
backgroundColor: "rgba(0,0,0,0.4)", backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: "flex-end", justifyContent: 'flex-end',
},
keyboardContainer: {
flex: 1,
justifyContent: 'flex-end',
}, },
modalBox: { modalBox: {
backgroundColor: "#fff", padding: 24,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: 20,
elevation: 10, elevation: 10,
}, },
title: { title: {
fontSize: responsiveFontSize(1.8), fontSize: responsiveFontSize(2),
fontWeight: "700", fontFamily: FontsFamily.nunitoBold,
color: "#1f2937", marginBottom: 16,
marginBottom: 12,
}, },
content: { content: {
marginBottom: 20, marginBottom: 24,
}, },
actions: { actions: {
flexDirection: "row", flexDirection: 'row',
justifyContent: "flex-end", justifyContent: 'flex-end',
marginTop: 10, gap: 12,
}, },
button: { button: {
paddingVertical: 10, paddingVertical: 12,
paddingHorizontal: 18, paddingHorizontal: 24,
borderRadius: 10, borderRadius: 12,
marginLeft: 10,
}, },
cancelButton: {
backgroundColor: "#e5e7eb",
},
cancelText: { cancelText: {
color: "#374151", fontFamily: FontsFamily.nunitoSemiBold,
fontWeight: "500", fontSize: responsiveFontSize(1.6),
}, },
confirmText: { confirmText: {
color: "#fff", fontFamily: FontsFamily.nunitoBold,
fontWeight: "600", fontSize: responsiveFontSize(1.6),
}, },
}); });
+30 -16
View File
@@ -1,9 +1,10 @@
import React from "react"; import React from "react";
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"; import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
import {Ionicons} from "@react-native-vector-icons/ionicons"; import LucideIcon from './LucideIcon';
import colors from "../colors";
import { responsiveFontSize } from "react-native-responsive-dimensions"; import { responsiveFontSize } from "react-native-responsive-dimensions";
import { useTheme } from "../../hooks/ui/useTheme";
import FontsFamily from "../../constents/FontsFamily";
type HeaderProps = { type HeaderProps = {
title: string; title: string;
@@ -19,10 +20,14 @@ const Header: React.FC<HeaderProps> = ({
showBack = true, showBack = true,
onBackPress, onBackPress,
rightAction, rightAction,
titleColor = '#000', titleColor,
arrowColor = '#000' arrowColor
}) => { }) => {
const navigation = useNavigation(); const navigation = useNavigation();
const theme = useTheme();
const resolvedTitleColor = titleColor || theme.colors.textPrimary;
const resolvedArrowColor = arrowColor || theme.colors.textPrimary;
const handleBack = () => { const handleBack = () => {
if (onBackPress) { if (onBackPress) {
@@ -35,12 +40,15 @@ const Header: React.FC<HeaderProps> = ({
return ( return (
<View style={styles.header}> <View style={styles.header}>
{showBack && ( {showBack && (
<TouchableOpacity onPress={handleBack} style={{padding:6}}> <TouchableOpacity onPress={handleBack} style={styles.sideAction}>
<Ionicons name="arrow-back" size={24} color={arrowColor} /> <LucideIcon name="arrow-back" size={22} color={resolvedArrowColor} />
</TouchableOpacity> </TouchableOpacity>
)} )}
<Text style={[styles.headerTitle, {color:titleColor}]}>{title}</Text> {!showBack && <View style={styles.sideAction} />}
<View>{rightAction}</View>
<Text numberOfLines={1} style={[styles.headerTitle, {color:resolvedTitleColor}]}>{title}</Text>
<View style={styles.sideAction}>{rightAction}</View>
</View> </View>
); );
}; };
@@ -50,15 +58,21 @@ export default Header;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
header: { header: {
flexDirection: "row", flexDirection: "row",
// alignItems: "center", alignItems: "center",
// justifyContent: "space-between", justifyContent: "space-between",
paddingVertical: 12, paddingVertical: 10,
paddingHorizontal:12 paddingHorizontal: 12,
},
sideAction: {
width: 36,
height: 36,
alignItems: "center",
justifyContent: "center",
}, },
headerTitle: { headerTitle: {
fontSize: responsiveFontSize(1.8), flex: 1,
fontWeight: "600", textAlign: "center",
// textTransform: "capitalize", fontSize: responsiveFontSize(2),
fontFamily: FontsFamily.nunitoBold,
}, },
}); });
+197
View File
@@ -0,0 +1,197 @@
import React from 'react';
import {
Home, User, Users, UsersRound, Settings, Bell, BellOff,
ArrowLeft, ArrowRight, ChevronRight, ChevronDown, X, XCircle,
Search, Wallet, Banknote, Trophy, Receipt, PlusCircle,
MailOpen, Mail, Pencil, Camera, CloudUpload, LogOut,
AlertCircle, Info, CheckCircle, CheckSquare, Square,
Phone, Trash2, MoreVertical, Power, Repeat, Ticket,
PieChart, BookOpen, Globe, FileText, UserPlus, Sparkles,
Cpu, Circle, Copy, Star, Calendar, Clock, ArrowUpDown,
Link, ShieldCheck, ChevronUp, Eye, EyeOff, Building2,
CreditCard, Hash, Check, HelpCircle, MessageCircle, CheckCircle2,
RefreshCw, ArrowDownCircle, CircleUserRound,
AtSign, MapPin, Barcode, Flag, Send, Tag, Code,
type LucideProps,
} from 'lucide-react-native';
// Map of Ionicons names to Lucide components
const iconMap: Record<string, React.FC<LucideProps>> = {
// Home
'home': Home,
'home-outline': Home,
// User / Person
'person': User,
'person-outline': User,
'people': Users,
'people-outline': Users,
'people-circle-outline': UsersRound,
'person-add-outline': UserPlus,
'person-circle-outline': CircleUserRound,
// Settings
'settings': Settings,
'settings-outline': Settings,
// Notifications
'notifications-outline': Bell,
'notifications-off-outline': BellOff,
// Navigation
'arrow-back': ArrowLeft,
'arrow-forward': ArrowRight,
'arrow-down-circle-outline': ArrowDownCircle,
'chevron-forward': ChevronRight,
'chevron-down': ChevronDown,
'chevron-up': ChevronUp,
'swap-vertical': ArrowUpDown,
// Close
'close': X,
'close-circle': XCircle,
// Search
'search': Search,
// Finance
'wallet-outline': Wallet,
'cash': Banknote,
'cash-outline': Banknote,
'card-outline': CreditCard,
// Trophy
'trophy': Trophy,
// Receipt
'receipt-outline': Receipt,
// Add
'add-circle-outline': PlusCircle,
'add': PlusCircle,
// Mail
'mail-open-outline': MailOpen,
'mail-unread-outline': Mail,
// Edit
'create-outline': Pencil,
// Camera
'camera': Camera,
// Upload
'cloud-upload': CloudUpload,
// Auth
'log-out-outline': LogOut,
// Alert / Info
'alert-circle': AlertCircle,
'alert-circle-outline': AlertCircle,
'information-circle-outline': Info,
// Check
'checkmark-circle': CheckCircle,
'checkbox': CheckSquare,
'square-outline': Square,
// Phone
'call-outline': Phone,
// Delete
'trash-outline': Trash2,
// More
'ellipsis-vertical': MoreVertical,
// Power
'power': Power,
// Repeat
'repeat-outline': Repeat,
// Ticket
'ticket-outline': Ticket,
// Charts
'pie-chart-outline': PieChart,
// Book
'book-outline': BookOpen,
// Globe
'planet-outline': Globe,
// Documents
'documents-outline': FileText,
// Sparkles
'sparkles-outline': Sparkles,
// Hardware
'hardware-chip-outline': Cpu,
// Shapes
'ellipse-outline': Circle,
// Copy
'copy-outline': Copy,
// Star
'star': Star,
'star-outline': Star,
// Calendar
'calendar-outline': Calendar,
// Time
'time-outline': Clock,
// Link
'link-outline': Link,
// Shield
'shield-checkmark-outline': ShieldCheck,
// Eye
'eye-outline': Eye,
'eye-off-outline': EyeOff,
// Building
'business-outline': Building2,
// Hash
'keypad-outline': Hash,
// Check
'checkmark': Check,
'checkmark-circle-outline': CheckCircle,
// Close circle outline
'close-circle-outline': XCircle,
// Help
'help-outline': HelpCircle,
// Chat
'chatbubbles-outline': MessageCircle,
// Information circle (without -outline)
'information-circle': Info,
// Sync / Refresh
'sync-outline': RefreshCw,
'sync': RefreshCw,
// Chat bubble
'chatbubble-ellipses-outline': MessageCircle,
'chatbubble-outline': MessageCircle,
// Card (without -outline)
'card': CreditCard,
// Mail (without -outline)
'mail-outline': Mail,
// At / Username
'at-outline': AtSign,
// Location
'location-outline': MapPin,
// Gender
'male-female-outline': Users,
'man-outline': User,
'woman-outline': User,
// Barcode
'barcode-outline': Barcode,
// Close (outline variant)
'close-outline': X,
// Flag
'flag-outline': Flag,
// Arrow forward outline
'arrow-forward-outline': ArrowRight,
// Code
'code-working-outline': Code,
// Document text
'document-text-outline': FileText,
// Tag / Pricetag
'pricetag-outline': Tag,
// Search outline
'search-outline': Search,
// Send
'send-outline': Send,
};
export type LucideIconName = keyof typeof iconMap;
type LucideIconProps = {
name: string;
size?: number;
color?: string;
style?: any;
strokeWidth?: number;
};
const LucideIcon: React.FC<LucideIconProps> = ({
name,
size = 24,
color = '#000',
style,
strokeWidth = 2,
}) => {
const IconComponent = iconMap[name] || Circle;
return <IconComponent size={size} color={color} style={style} strokeWidth={strokeWidth} />;
};
export default LucideIcon;
+65
View File
@@ -0,0 +1,65 @@
import React, { useEffect, useRef } from 'react';
import { Animated, StyleSheet, ViewStyle, DimensionValue } from 'react-native';
import { useTheme } from '../../hooks/ui/useTheme';
interface SkeletonProps {
width?: DimensionValue;
height?: DimensionValue;
borderRadius?: number;
style?: ViewStyle;
}
const SkeletonLoader: React.FC<SkeletonProps> = ({
width = '100%',
height = 20,
borderRadius = 4,
style,
}) => {
const theme = useTheme();
const opacity = useRef(new Animated.Value(0.3)).current;
useEffect(() => {
const animation = Animated.loop(
Animated.sequence([
Animated.timing(opacity, {
toValue: 0.7,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 0.3,
duration: 800,
useNativeDriver: true,
}),
])
);
animation.start();
return () => animation.stop();
}, [opacity]);
return (
<Animated.View
style={[
styles.skeleton,
{
width,
height,
borderRadius,
backgroundColor: theme.colors.surface,
opacity,
},
style,
]}
/>
);
};
const styles = StyleSheet.create({
skeleton: {
overflow: 'hidden',
},
});
export default SkeletonLoader;
+69
View File
@@ -0,0 +1,69 @@
import { AxiosError } from 'axios';
type ApiErrorPayload = {
message?: string;
error?: string;
status?: string;
statusCode?: number;
data?: unknown;
};
export const logGroupRequest = (method: string, url: string, payload?: unknown) => {
console.log(`[HOOK_API][${method}] URL:`, url);
console.log(`[HOOK_API][${method}] PAYLOAD:`, payload ?? null);
};
export const logGroupResponse = (method: string, url: string, response: unknown) => {
const meta = response as { statusCode?: number; status?: string; message?: string };
console.log(`[HOOK_API][${method}] SUCCESS URL:`, url);
console.log(`[HOOK_API][${method}] SUCCESS META:`, {
statusCode: meta?.statusCode ?? null,
status: meta?.status ?? null,
message: meta?.message ?? null,
});
console.log(`[HOOK_API][${method}] SUCCESS RESPONSE:`, response);
};
export const logGroupError = (method: string, url: string, error: unknown) => {
const axiosError = error as AxiosError<ApiErrorPayload>;
console.error(
`[HOOK_API][${method}] ERROR RESPONSE:`,
axiosError?.response?.data ?? axiosError?.message ?? error
);
};
export const getApiErrorMessage = (error: unknown) => {
const axiosError = error as AxiosError<ApiErrorPayload>;
const apiMessage =
axiosError?.response?.data?.message ||
axiosError?.response?.data?.error ||
axiosError?.message;
return apiMessage || 'An unexpected error occurred';
};
export const normalizeApiError = (error: unknown) => {
const normalized = new Error(getApiErrorMessage(error));
(normalized as Error & { originalError?: unknown }).originalError = error;
return normalized;
};
export const resolveMutationRequest = <T>(resource: string, payload: T) => {
const rawPayload = payload as unknown as { resource?: string; payload?: unknown };
const canUseDynamicResource =
(!resource || resource.trim() === '') &&
typeof rawPayload?.resource === 'string' &&
rawPayload.resource.trim() !== '';
if (canUseDynamicResource) {
return {
url: rawPayload.resource as string,
body: rawPayload.payload ?? payload,
};
}
return {
url: resource,
body: payload,
};
};
+38
View File
@@ -0,0 +1,38 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query';
import apiClient from '../../api/client';
import {
normalizeApiError,
resolveMutationRequest,
} from './apiDebug';
export const useCreate = <T, R>(
resource: string,
options?: UseMutationOptions<R, Error, T>
) => {
const queryClient = useQueryClient();
return useMutation<R, Error, T>({
...options,
mutationFn: async (payload: T) => {
const { url, body } = resolveMutationRequest(resource, payload);
try {
const { data } = await apiClient.post<R>(url, body);
return data;
} catch (error) {
throw normalizeApiError(error);
}
},
onSuccess: (data, variables, onMutateResult, context) => {
const { url } = resolveMutationRequest(resource, variables);
queryClient.invalidateQueries({ queryKey: [url] });
if (resource && resource !== url) {
queryClient.invalidateQueries({ queryKey: [resource] });
}
options?.onSuccess?.(data, variables, onMutateResult, context);
},
onError: (error, variables, onMutateResult, context) => {
options?.onError?.(error, variables, onMutateResult, context);
},
});
};
+31
View File
@@ -0,0 +1,31 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query';
import apiClient from '../../api/client';
import { normalizeApiError } from './apiDebug';
export const useDelete = <R>(
resource: string,
options?: UseMutationOptions<R, Error, string | number>
) => {
const queryClient = useQueryClient();
return useMutation<R, Error, string | number>({
...options,
mutationFn: async (id: string | number) => {
const url = `${resource}/${id}`;
try {
const { data } = await apiClient.delete<R>(url);
return data;
} catch (error) {
throw normalizeApiError(error);
}
},
onSuccess: (data, variables, onMutateResult, context) => {
queryClient.invalidateQueries({ queryKey: [resource] });
options?.onSuccess?.(data, variables, onMutateResult, context);
},
onError: (error, variables, onMutateResult, context) => {
options?.onError?.(error, variables, onMutateResult, context);
},
});
};
+22
View File
@@ -0,0 +1,22 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import apiClient from '../../api/client';
import { normalizeApiError } from './apiDebug';
export const useFetch = <T>(
resource: string,
params?: object,
options?: Omit<UseQueryOptions<T, Error>, 'queryKey' | 'queryFn'>
) => {
return useQuery<T, Error>({
queryKey: [resource, params],
queryFn: async () => {
try {
const { data } = await apiClient.get<T>(resource, { params });
return data;
} catch (error) {
throw normalizeApiError(error);
}
},
...options,
});
};
+21
View File
@@ -0,0 +1,21 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import apiClient from '../../api/client';
import { normalizeApiError } from './apiDebug';
/**
* Hook to generate a group invite link.
* Returns the mutation result which includes the API response data.
*/
export const useInvite = (options?: UseMutationOptions<any, Error, string | number>) => {
return useMutation<any, Error, string | number>({
...options,
mutationFn: async (groupId: string | number) => {
try {
const { data } = await apiClient.get(`invite/generate?groupId=${groupId}`);
return data;
} catch (error) {
throw normalizeApiError(error);
}
},
});
};
+38
View File
@@ -0,0 +1,38 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query';
import apiClient from '../../api/client';
import {
normalizeApiError,
resolveMutationRequest,
} from './apiDebug';
export const useUpdate = <T, R>(
resource: string,
options?: UseMutationOptions<R, Error, T>
) => {
const queryClient = useQueryClient();
return useMutation<R, Error, T>({
...options,
mutationFn: async (payload: T) => {
const { url, body } = resolveMutationRequest(resource, payload);
try {
const { data } = await apiClient.put<R>(url, body);
return data;
} catch (error) {
throw normalizeApiError(error);
}
},
onSuccess: (data, variables, onMutateResult, context) => {
const { url } = resolveMutationRequest(resource, variables);
queryClient.invalidateQueries({ queryKey: [url] });
if (resource && resource !== url) {
queryClient.invalidateQueries({ queryKey: [resource] });
}
options?.onSuccess?.(data, variables, onMutateResult, context);
},
onError: (error, variables, onMutateResult, context) => {
options?.onError?.(error, variables, onMutateResult, context);
},
});
};
+7
View File
@@ -0,0 +1,7 @@
import { useTheme as useThemeContext } from '../../theme/ThemeProvider';
import { Theme } from '../../theme/theme';
export const useTheme = (): Theme => {
const { theme } = useThemeContext();
return theme;
};
+4 -1
View File
@@ -24,6 +24,7 @@ import Notification from "../screens/notification/Notification";
import ManagementScreen from "../screens/management/ManagementScreen"; import ManagementScreen from "../screens/management/ManagementScreen";
import { navigationRef } from "../utils/navigationService"; import { navigationRef } from "../utils/navigationService";
import NotificationRedirection from "../screens/NotificationRedirection"; import NotificationRedirection from "../screens/NotificationRedirection";
import PaymentModeScreen from "../screens/payment/PaymentModeScreen";
// import screen from "../components/test"; // import screen from "../components/test";
export type RootStackParamList = { export type RootStackParamList = {
@@ -40,13 +41,14 @@ export type RootStackParamList = {
BecomeOrg: undefined; BecomeOrg: undefined;
YourContacts: undefined; YourContacts: undefined;
Transaction: undefined; Transaction: undefined;
EditBankDetails: { currentBankName: string | null; currentAccountNumber: string | null; currentBranchCode: string | null; onUpdate:()=>void }; EditBankDetails: { currentBankName: string | null; currentAccountNumber: string | null; currentBranchCode: string | null; onUpdate: () => void };
ManageJoinedGroup: { groupId: string | number }; ManageJoinedGroup: { groupId: string | number };
Invite: { groupId: string | number; code: string | number }; Invite: { groupId: string | number; code: string | number };
ManagementTabs: { groupId: string | number; } ManagementTabs: { groupId: string | number; }
TransactionList: undefined TransactionList: undefined
RandomSelectUserScreen: { groupId: string | number, cycleNumber: number }; RandomSelectUserScreen: { groupId: string | number, cycleNumber: number };
Notification: undefined; Notification: undefined;
PaymentMode: { groupId: string | number; amount: number; payableData?: any };
NotificationRedirection: { data: any }; NotificationRedirection: { data: any };
Management: Management:
| { actionTab: "My Groups" } | { actionTab: "My Groups" }
@@ -119,6 +121,7 @@ export default function AppNavigator() {
<Stack.Screen name="Notification" component={Notification} options={{ headerShown: false }} /> <Stack.Screen name="Notification" component={Notification} options={{ headerShown: false }} />
<Stack.Screen name="Management" component={ManagementScreen} options={{ headerShown: false }} /> <Stack.Screen name="Management" component={ManagementScreen} options={{ headerShown: false }} />
<Stack.Screen name="NotificationRedirection" component={NotificationRedirection} options={{ headerShown: false }} /> <Stack.Screen name="NotificationRedirection" component={NotificationRedirection} options={{ headerShown: false }} />
<Stack.Screen name="PaymentMode" component={PaymentModeScreen} options={{ headerShown: false }} />
</Stack.Navigator> </Stack.Navigator>
</NavigationContainer> </NavigationContainer>
); );
+60 -137
View File
@@ -1,50 +1,22 @@
import React, { useState, useEffect } from "react"; import React from "react";
import { import {
BottomTabNavigationProp, BottomTabNavigationProp,
createBottomTabNavigator, createBottomTabNavigator,
} from "@react-navigation/bottom-tabs"; } from "@react-navigation/bottom-tabs";
import { import {
View, View,
Text,
StyleSheet, StyleSheet,
Platform,
AccessibilityInfo,
} from "react-native"; } from "react-native";
import { Ionicons, IoniconsIconName } from "@react-native-vector-icons/ionicons"; import { Home, User, Settings, Users } from "lucide-react-native";
import GroupScreen from "../screens/GroupScreen";
import Profile from "../screens/profile/Profile";
import Home from "../screens/home/Home";
import colors from "../components/colors";
import Setting from "../screens/setting/Setting";
import ManagementScreen from "../screens/management/ManagementScreen";
import {
responsiveFontSize,
responsiveHeight,
} from "react-native-responsive-dimensions";
import { useTheme } from "../theme/ThemeProvider";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { responsiveFontSize } from "react-native-responsive-dimensions";
const TAB_CONFIG = { import HomeScreen from "../screens/home/Home";
activeColor: "#000", import Profile from "../screens/profile/Profile";
inactiveColor: "#8e8e93", import GroupScreen from "../screens/GroupScreen";
backgroundColor: "#fff", import ManagementScreen from "../screens/management/ManagementScreen";
height: 70, import { useTheme } from "../hooks/ui/useTheme";
iconSize: 28, import FontsFamily from "../constents/FontsFamily";
};
const activeTabColors: { [key: string]: string } = {
Home: colors.pastelPink,
Profile: colors.pastelBlue,
Group: colors.pastelGreen,
Management: colors.pastelOrange,
};
const activeIconColors: { [key: string]: string } = {
Home: "#333",
Profile: "#333",
Group: "#333",
Management: "#333",
};
export type RootTabParamList = { export type RootTabParamList = {
Home: undefined; Home: undefined;
@@ -59,87 +31,72 @@ export type TabNavigationProp = BottomTabNavigationProp<RootTabParamList>;
const Tab = createBottomTabNavigator<RootTabParamList>(); const Tab = createBottomTabNavigator<RootTabParamList>();
const BottomTabs: React.FC = () => { const BottomTabs: React.FC = () => {
const { theme } = useTheme(); const theme = useTheme();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [isReduceMotionEnabled, setIsReduceMotionEnabled] =
useState<boolean>(false);
const [isScreenReaderEnabled, setIsScreenReaderEnabled] =
useState<boolean>(false);
useEffect(() => {
AccessibilityInfo.isScreenReaderEnabled().then(setIsScreenReaderEnabled);
AccessibilityInfo.isReduceMotionEnabled().then(setIsReduceMotionEnabled);
const screenReaderSubscription = AccessibilityInfo.addEventListener(
"screenReaderChanged",
setIsScreenReaderEnabled
);
return () => screenReaderSubscription.remove();
}, []);
return ( return (
<Tab.Navigator <Tab.Navigator
screenOptions={({ route }) => ({ screenOptions={({ route }) => ({
headerShown: false, headerShown: false,
tabBarStyle: [ tabBarShowLabel: true,
[styles.tabBar,{ backgroundColor: theme.colors.reverseBackground }], tabBarStyle: {
{ position: "absolute",
bottom: insets.bottom + 10 bottom: Math.max(insets.bottom, 8),
left: 12,
right: 12,
height: 64,
borderRadius: 18,
backgroundColor: theme.colors.surface,
borderWidth: 1,
borderColor: theme.colors.border,
borderTopWidth: 0,
elevation: 8,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.12,
shadowRadius: 8,
paddingBottom: 0,
}, },
],
// Make each tab item occupy the full bar height and center its content
tabBarItemStyle: { tabBarItemStyle: {
height: "100%", height: 64,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
padding: 0, paddingVertical: 6,
margin: 0,
}, },
tabBarActiveTintColor: theme.colors.primary,
tabBarInactiveTintColor: theme.colors.textMuted,
tabBarLabelStyle: {
fontFamily: FontsFamily.nunitoBold,
fontSize: responsiveFontSize(1.2),
marginTop: -2,
marginBottom: 4,
},
tabBarIcon: ({ focused, color }) => {
const iconColor = focused ? theme.colors.primary : color;
const iconSize = 22;
let IconComponent: React.FC<any> = Home;
tabBarLabel: ({ focused, color }) => if (route.name === "Home") IconComponent = Home;
!focused ? ( else if (route.name === "Profile") IconComponent = User;
<Text style={[styles.label, { color }]}>{route.name}</Text> else if (route.name === "Management") IconComponent = Settings;
) : null, else if (route.name === "Group") IconComponent = Users;
tabBarIcon: ({ focused }) => {
let iconName: IoniconsIconName = "home-outline";
if (route.name === "Home")
iconName = focused ? "home" : "home-outline";
else if (route.name === "Profile")
iconName = focused ? "person" : "person-outline";
else if (route.name === "Management")
iconName = focused ? "settings" : "settings-outline";
else if (route.name === "Group")
iconName = focused ? "people" : "people-outline";
const activeBackgroundColor = theme.colors.background;
const activeIconColor = activeIconColors[route.name];
return ( return (
<View <View style={[
style={[ styles.iconWrapper,
styles.iconContainer, focused && { backgroundColor: theme.colors.primarySoft }
focused && [ ]}>
styles.activeIconContainer, <IconComponent
{ backgroundColor: activeBackgroundColor }, size={iconSize}
], color={iconColor}
]} strokeWidth={focused ? 2.5 : 2}
>
<Ionicons
name={iconName}
size={TAB_CONFIG.iconSize}
color={focused ? activeIconColor : TAB_CONFIG.inactiveColor}
/> />
</View> </View>
); );
}, },
})} })}
> >
<Tab.Screen name="Home" component={Home} /> <Tab.Screen name="Home" component={HomeScreen} />
<Tab.Screen name="Profile" component={Profile} /> <Tab.Screen name="Profile" component={Profile} />
<Tab.Screen name="Group" component={GroupScreen} /> <Tab.Screen name="Group" component={GroupScreen} />
<Tab.Screen name="Management" component={ManagementScreen} /> <Tab.Screen name="Management" component={ManagementScreen} />
@@ -148,47 +105,13 @@ const BottomTabs: React.FC = () => {
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
screen: { iconWrapper: {
flex: 1, width: 40,
height: 28,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
backgroundColor: "#f5f7fa", borderRadius: 10,
}, marginBottom: 2,
text: {
fontSize: responsiveFontSize(1.8),
fontWeight: "600",
color: "#111",
},
tabBar: {
position: "absolute",
left: 20,
right: 20,
height: TAB_CONFIG.height,
borderRadius: 20,
shadowColor: "#000",
shadowOpacity: 0.1,
shadowOffset: { width: 0, height: 5 },
shadowRadius: 8,
elevation: 10,
paddingTop: 0,
marginHorizontal: 10,
},
label: {
paddingVertical: 5,
fontSize: responsiveFontSize(1.2),
},
iconContainer: {
width: 60,
height: 50,
justifyContent: "center",
alignItems: "center",
borderRadius: 15,
},
activeIconContainer: {
elevation: 2,
shadowColor: "#000",
shadowOpacity: 0.1,
shadowRadius: 5,
}, },
}); });
View File
+90 -99
View File
@@ -1,15 +1,16 @@
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from 'react';
import { import {
View, View,
Text, Text,
StyleSheet, StyleSheet,
Animated, Animated,
Easing, Easing,
} from "react-native"; } from 'react-native';
import { Ionicons } from "@react-native-vector-icons/ionicons"; import LucideIcon from '../components/common/LucideIcon';
import colors from "../components/colors"; import CustomModal from '../components/common/CustomModal';
import CustomModal from "../components/common/CustomModal"; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { responsiveFontSize } from "react-native-responsive-dimensions"; import { useTheme } from '../hooks/ui/useTheme';
import FontsFamily from '../constents/FontsFamily';
interface ContributionProps { interface ContributionProps {
data: any; data: any;
@@ -24,137 +25,127 @@ const ContributionDetails: React.FC<ContributionProps> = ({
onClose, onClose,
onConfirm, onConfirm,
}) => { }) => {
const theme = useTheme();
// Animations // Animations
const fadeAnim = useRef(new Animated.Value(0)).current; const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(50)).current; const slideAnim = useRef(new Animated.Value(20)).current;
const scaleAnim = useRef(new Animated.Value(0.9)).current;
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
Animated.parallel([ Animated.parallel([
Animated.timing(fadeAnim, { Animated.timing(fadeAnim, {
toValue: 1, toValue: 1,
duration: 600, duration: 400,
useNativeDriver: true, useNativeDriver: true,
}), }),
Animated.timing(slideAnim, { Animated.timing(slideAnim, {
toValue: 0, toValue: 0,
duration: 600, duration: 400,
easing: Easing.out(Easing.exp), easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.spring(scaleAnim, {
toValue: 1,
friction: 6,
useNativeDriver: true, useNativeDriver: true,
}), }),
]).start(); ]).start();
} else { } else {
fadeAnim.setValue(0); fadeAnim.setValue(0);
slideAnim.setValue(50); slideAnim.setValue(20);
scaleAnim.setValue(0.9);
} }
}, [visible]); }, [visible]);
return ( return (
<CustomModal visible={visible} onClose={onClose} onConfirm={onConfirm} confirmButton={colors.pastelOrange} confirmTextColor={colors.black} elevation={5}> <CustomModal
{/* Header */} visible={visible}
<Animated.View onClose={onClose}
style={[ onConfirm={onConfirm}
styles.header, confirmText="Contribute Now"
{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] }, title="Contribution Summary"
]}
> >
<Ionicons name="wallet-outline" size={32} color={colors.black} /> <Animated.View style={{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] }}>
<Text style={styles.headerTitle}>Contribution Summary</Text> {/* Info Card */}
</Animated.View> <View style={[styles.card, { backgroundColor: theme.colors.background, borderColor: theme.colors.border }]}>
<View style={styles.headerRow}>
<LucideIcon name="wallet-outline" size={24} color={theme.colors.primary} />
<Text style={[styles.amount, { color: theme.colors.textPrimary }]}>₹{data?.totalPayable || data?.baseAmount}</Text>
</View>
{/* Contribution Info Card */} <View style={styles.infoGrid}>
<Animated.View <InfoItem label="Base Amount" value={`₹${data?.baseAmount}`} />
style={[ <InfoItem label="Due Date" value={data?.dueDate} />
styles.card, <InfoItem label="Cycle End" value={data?.cycleEnd} />
{ transform: [{ scale: scaleAnim }], opacity: fadeAnim }, <InfoItem label="Late Fee" value={`₹${data?.effectiveLateFee || 0}`} />
]} </View>
> </View>
<Text style={styles.label}>Base Amount</Text>
<Text style={styles.value}>₹{data?.baseAmount}</Text>
{
data?.alreadyPaid ? <Text style={styles?.valueHighlight}>Already Paid Amount</Text> :
<View>
<Text style={styles.label}>Total Payable</Text>
<Text style={styles.valueHighlight}>₹{data?.totalPayable}</Text>
</View>}
{/* Status Card */}
<Text style={styles.label}>Due Date</Text> <View style={[styles.miniCard, { backgroundColor: theme.colors.background, borderColor: theme.colors.border }]}>
<Text style={styles.value}>{data?.dueDate}</Text> <Text style={[styles.label, { color: theme.colors.textMuted }]}>Cycle {data?.cycleNumber}</Text>
{data?.alreadyPaid && (
<Text style={styles.label}>Cycle End</Text> <Text style={[styles.statusText, { color: theme.colors.primary }]}>Payment Already Completed</Text>
<Text style={styles.value}>{data?.cycleEnd}</Text> )}
</Animated.View> </View>
{/* Extra Details */}
<Animated.View
style={[
styles.card,
{ transform: [{ translateY: slideAnim }], opacity: fadeAnim },
]}
>
<Text style={styles.label}>Cycle Number</Text>
<Text style={styles.value}>{data?.cycleNumber}</Text>
<Text style={styles.label}>Late Fee Mode</Text>
<Text style={styles.value}>{data?.lateFeeMode}</Text>
<Text style={styles.label}>Late Fee Value</Text>
<Text style={styles.value}>₹{data?.lateFeeValue}</Text>
<Text style={styles.label}>Effective Late Fee</Text>
<Text style={styles.value}>₹{data?.effectiveLateFee}</Text>
</Animated.View> </Animated.View>
</CustomModal> </CustomModal>
); );
}; };
const InfoItem = ({ label, value }: { label: string; value: string | number }) => {
const theme = useTheme();
return (
<View style={styles.infoItem}>
<Text style={[styles.label, { color: theme.colors.textMuted }]}>{label}</Text>
<Text style={[styles.value, { color: theme.colors.textSecondary }]}>{value}</Text>
</View>
);
};
export default ContributionDetails; export default ContributionDetails;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
header: {
flexDirection: "row",
alignItems: "center",
marginBottom: 24,
},
headerTitle: {
fontSize: responsiveFontSize(2.2),
fontWeight: "700",
marginLeft: 10,
},
card: { card: {
backgroundColor: "#fff", padding: 20,
padding: 16,
borderRadius: 16, borderRadius: 16,
marginBottom: 16, borderWidth: 1,
shadowColor: "#000", marginBottom: 12,
shadowOpacity: 0.08, },
shadowOffset: { width: 0, height: 3 }, headerRow: {
shadowRadius: 6, flexDirection: 'row',
elevation: 3, alignItems: 'center',
gap: 12,
marginBottom: 20,
},
amount: {
fontSize: responsiveFontSize(2.8),
fontFamily: FontsFamily.nunitoBold,
},
infoGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 16,
},
infoItem: {
width: '45%',
},
miniCard: {
padding: 16,
borderRadius: 12,
borderWidth: 1,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
}, },
label: { label: {
fontSize: responsiveFontSize(1.3), fontSize: responsiveFontSize(1.2),
color: "#6b7280", fontFamily: FontsFamily.nunitoMedium,
marginTop: 6, textTransform: 'uppercase',
letterSpacing: 0.5,
}, },
value: { value: {
fontSize: responsiveFontSize(1.5), fontSize: responsiveFontSize(1.6),
fontWeight: "500", fontFamily: FontsFamily.nunitoBold,
color: "#374151", marginTop: 2,
marginBottom: 6,
}, },
valueHighlight: { statusText: {
fontSize: responsiveFontSize(1.8), fontSize: responsiveFontSize(1.4),
fontWeight: "700", fontFamily: FontsFamily.nunitoBold,
color: colors.black,
marginBottom: 10,
}, },
}); });
+250 -149
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useState } from 'react';
import { import {
View, View,
Text, Text,
@@ -7,37 +7,64 @@ import {
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
TouchableOpacity, TouchableOpacity,
Alert,
Image,
ToastAndroid, ToastAndroid,
} from "react-native"; } from 'react-native';
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from '@react-navigation/native';
import { CustomInput } from "../components/common/Custom"; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import CustomButton from "../components/CustomButton"; import { CustomInput, CustomButton } from '../components/common/Custom';
import colors from "../components/colors"; import { useTheme } from '../hooks/ui/useTheme';
import { launchImageLibrary } from "react-native-image-picker"; import FontsFamily from '../constents/FontsFamily';
import Ionicons from "@react-native-vector-icons/ionicons"; import AppView from '../components/AppView';
import { UPLOAD_GROUP_IMAGE } from "../utils/ApiUrls";
import { responsiveFontSize } from "react-native-responsive-dimensions"; type GroupCreationPayload = {
groupName: string;
description: string;
minParticipants: number;
maxParticipants: number;
contributionAmount: number;
cycleType: string;
customCycleDays: number;
visibility: 'PUBLIC' | 'PRIVATE';
withdrawalMethod: 'SEQUENTIAL' | 'RANDOM';
lateFeeMode: 'DAILY' | 'MONTHLY';
lateFeeValue: number;
entryFee: number;
};
type GroupCreationFormState = {
groupName: string;
description: string;
minParticipants: string;
maxParticipants: string;
contributionAmount: string;
cycleType: string;
customCycleDays: string;
visibility: 'PUBLIC' | 'PRIVATE';
withdrawalMethod: 'SEQUENTIAL' | 'RANDOM';
lateFeeMode: 'DAILY' | 'MONTHLY';
lateFeeValue: string;
entryFee: string;
};
export default function GroupCreationForm() { export default function GroupCreationForm() {
const navigation = useNavigation<any>(); const navigation = useNavigation<any>();
const [form, setForm] = useState({ const theme = useTheme();
groupName: "", const [form, setForm] = useState<GroupCreationFormState>({
description: "", groupName: '',
visibility: "PRIVATE", description: '',
withdrawalMethod: "RANDOM", visibility: 'PUBLIC',
minParticipants: "", withdrawalMethod: 'SEQUENTIAL',
maxParticipants: "", minParticipants: '',
contributionAmount: "", maxParticipants: '',
cycleType: "MONTHLY", contributionAmount: '',
customCycleDays: "", cycleType: 'MONTHLY',
lateFeeMode: "DAILY", customCycleDays: '',
lateFeeValue: "", lateFeeMode: 'DAILY',
lateFeeValue: '',
entryFee: '0',
}); });
const [errors, setErrors] = useState<any>({}); const [errors, setErrors] = useState<any>({});
const [selectedImage, setSelectedImage] = useState<any>();
const handleChange = (key: string, value: string) => { const handleChange = (key: string, value: string) => {
setForm({ ...form, [key]: value }); setForm({ ...form, [key]: value });
@@ -46,277 +73,351 @@ export default function GroupCreationForm() {
} }
}; };
// ---- Custom logic for Payment Cycle ---- const distributionTypes = ['SEQUENTIAL', 'RANDOM'];
const cycleTypes = [ const cycleTypes = ['CUSTOM', 'DAILY', 'WEEKLY', 'MONTHLY', 'QUARTERLY', 'HALF_YEARLY', 'YEARLY'];
"CUSTOM", const lateFeeModes = ['DAILY', 'MONTHLY'];
"DAILY",
"WEEKLY",
"MONTHLY",
"QUARTERLY",
"HALF_YEARLY",
"YEARLY",
];
const distributionTypes = ["SEQUENTIAL", "RANDOM"];
const lateFeeModes = ["DAILY", "MONTHLY"];
// The core: handle payment cycle options
const handleCycleChange = (val: string) => { const handleCycleChange = (val: string) => {
let newForm = { ...form, cycleType: val }; let newForm = { ...form, cycleType: val };
if (val === "DAILY") { if (val === 'DAILY') {
newForm = { ...newForm, cycleType: "CUSTOM", customCycleDays: "1" }; newForm = { ...newForm, cycleType: 'CUSTOM', customCycleDays: '1' };
} else if (val === "WEEKLY") { } else if (val === 'WEEKLY') {
newForm = { ...newForm, cycleType: "CUSTOM", customCycleDays: "7" }; newForm = { ...newForm, cycleType: 'CUSTOM', customCycleDays: '7' };
} else if ( } else if (val !== 'CUSTOM') {
val !== "CUSTOM" newForm = { ...newForm, customCycleDays: '' };
) {
newForm = { ...newForm, customCycleDays: "" }; // Clear custom days
} }
setForm(newForm); setForm(newForm);
if (errors["customCycleDays"]) { if (errors.customCycleDays) {
setErrors({ ...errors, customCycleDays: null }); setErrors({ ...errors, customCycleDays: null });
} }
}; };
const validateForm = () => { const validateForm = () => {
const newErrors: any = {}; const newErrors: any = {};
if (!form.groupName.trim()) newErrors.groupName = "Group name is required."; const entryFeeValue = form.entryFee.trim() === '' ? 0 : Number(form.entryFee);
if (!form.description.trim()) newErrors.description = "Description is required."; if (!form.groupName.trim()) newErrors.groupName = 'Group name is required.';
if (!form.minParticipants || Number(form.minParticipants) <= 0) newErrors.minParticipants = "Min participants must be at least 1."; if (!form.description.trim()) newErrors.description = 'Description is required.';
if (!form.maxParticipants || Number(form.maxParticipants) < Number(form.minParticipants)) newErrors.maxParticipants = "Max must be greater than or equal to min."; if (!form.minParticipants || Number(form.minParticipants) <= 0)
if (!form.contributionAmount || Number(form.contributionAmount) <= 0) newErrors.contributionAmount = "Amount must be greater than 0."; newErrors.minParticipants = 'Min participants must be at least 1.';
if (form.cycleType === "CUSTOM" && (!form.customCycleDays || Number(form.customCycleDays) <= 0)) newErrors.customCycleDays = "Custom days must be greater than 0."; if (!form.maxParticipants || Number(form.maxParticipants) < Number(form.minParticipants))
if (!form.lateFeeMode && (!form.lateFeeValue || Number(form.lateFeeValue) <= 0)) newErrors.lateFeeValue = "Late fee value is required."; newErrors.maxParticipants = 'Max must be >= min.';
if (!form.contributionAmount || Number(form.contributionAmount) <= 0)
newErrors.contributionAmount = 'Amount must be > 0.';
if (Number.isNaN(entryFeeValue) || entryFeeValue < 0)
newErrors.entryFee = 'Entry fee cannot be negative.';
if (form.cycleType === 'CUSTOM' && (!form.customCycleDays || Number(form.customCycleDays) <= 0))
newErrors.customCycleDays = 'Custom days must be > 0.';
if (!form.lateFeeValue || Number(form.lateFeeValue) <= 0)
newErrors.lateFeeValue = 'Late fee is required.';
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0;
}; };
const handleReview = () => { const handleReview = () => {
if (validateForm()) { if (validateForm()) {
const payload = { const entryFeeValue = form.entryFee.trim() === '' ? 0 : Number(form.entryFee);
...form, const payload: GroupCreationPayload = {
contributionAmount: Number(form.contributionAmount), groupName: form.groupName.trim(),
customCycleDays: Number(form.customCycleDays) || 0, description: form.description.trim(),
lateFeeValue: Number(form.lateFeeValue) || 0,
maxParticipants: Number(form.maxParticipants),
minParticipants: Number(form.minParticipants), minParticipants: Number(form.minParticipants),
maxParticipants: Number(form.maxParticipants),
contributionAmount: Number(form.contributionAmount),
cycleType: form.cycleType,
customCycleDays: form.cycleType === 'CUSTOM' ? Number(form.customCycleDays) || 0 : 0,
visibility: form.visibility,
withdrawalMethod: form.withdrawalMethod,
lateFeeMode: form.lateFeeMode,
lateFeeValue: Number(form.lateFeeValue) || 0,
entryFee: Number.isNaN(entryFeeValue) ? 0 : entryFeeValue,
}; };
console.log('review payload',payload) navigation.navigate('GroupReview', { form: payload });
navigation.navigate("GroupReview", { form: payload });
} else { } else {
ToastAndroid.show('Please fill out all required fields correctly.', ToastAndroid.SHORT); ToastAndroid.show('Please fill required fields correctly.', ToastAndroid.SHORT);
} }
}; };
return ( return (
<AppView keyboardSafe={false}>
<KeyboardAvoidingView <KeyboardAvoidingView
style={{ flex: 1, backgroundColor: "#f5f6fa", paddingTop: Platform.OS === "android" ? 40 : 0 }} style={{ flex: 1 }}
behavior={Platform.OS === "ios" ? "padding" : "height"} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
> >
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<View style={styles.titleContainer}> <View style={styles.header}>
<Text style={styles.title}>Create New Group</Text> <Text style={[styles.title, { color: theme.colors.textPrimary }]}>Create Circle</Text>
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
Define your saving group and its rules.
</Text>
</View> </View>
<View style={[styles.section, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<CustomInput <CustomInput
label="Group Name" label="Circle Name"
placeholder="e.g., 'Family Savings'" placeholder="e.g., 'Team Growth'"
value={form.groupName} value={form.groupName}
onChangeText={(val) => handleChange("groupName", val)} onChangeText={(val) => handleChange('groupName', val)}
icon="people" icon="people-outline"
error={errors.groupName} error={errors.groupName}
/> />
<CustomInput <CustomInput
label="Description" label="Description"
placeholder="e.g., 'Monthly fund for family needs'" placeholder="What is this circle for?"
value={form.description} value={form.description}
onChangeText={(val) => handleChange("description", val)} onChangeText={(val) => handleChange('description', val)}
multiline multiline
icon="document-text" style={styles.textArea}
icon="document-text-outline"
error={errors.description} error={errors.description}
/> />
<Section title="Group Settings"> </View>
<Section title="Settings">
<SettingRow label="Visibility"> <SettingRow label="Visibility">
<ToggleButtons <ToggleButtons
options={["PRIVATE", "PUBLIC"]} options={['PRIVATE', 'PUBLIC']}
selectedValue={form.visibility} selectedValue={form.visibility}
onSelect={(val) => handleChange("visibility", val)} onSelect={(val) => handleChange('visibility', val)}
/> />
</SettingRow> </SettingRow>
<SettingRow label="Fund Distribution"> <SettingRow label="Fund Distribution">
<ToggleButtons <ToggleButtons
options={distributionTypes} options={distributionTypes}
selectedValue={form.withdrawalMethod} selectedValue={form.withdrawalMethod}
onSelect={(val) => handleChange("withdrawalMethod", val)} onSelect={(val) => handleChange('withdrawalMethod', val)}
/> />
</SettingRow> </SettingRow>
</Section> </Section>
<Section title="Participant Rules"> <Section title="Participant Rules">
<View style={styles.participantsRow}> <View style={styles.row}>
<View style={styles.participantInput}> <View style={styles.flex1}>
<CustomInput <CustomInput
label="Min" label="Min"
placeholder="e.g., 5" placeholder="e.g., 5"
value={form.minParticipants} value={form.minParticipants}
onChangeText={(val) => handleChange("minParticipants", val)} onChangeText={(val) => handleChange('minParticipants', val)}
keyboardType="numeric" keyboardType="numeric"
icon="person-add" icon="person-outline"
error={errors.minParticipants} error={errors.minParticipants}
/> />
</View> </View>
<View style={styles.participantInput}> <View style={styles.flex1}>
<CustomInput <CustomInput
label="Max" label="Max"
placeholder="e.g., 10" placeholder="e.g., 20"
value={form.maxParticipants} value={form.maxParticipants}
onChangeText={(val) => handleChange("maxParticipants", val)} onChangeText={(val) => handleChange('maxParticipants', val)}
keyboardType="numeric" keyboardType="numeric"
icon="people-circle" icon="people-outline"
error={errors.maxParticipants} error={errors.maxParticipants}
/> />
</View> </View>
</View> </View>
</Section> </Section>
<Section title="Financial Details"> <Section title="Financial Details">
<CustomInput <CustomInput
label="Contribution Amount (INR)" label="Contribution Amount (₹)"
placeholder="e.g., 5000" placeholder="e.g., 5000.00"
value={form.contributionAmount} value={form.contributionAmount}
onChangeText={(val) => handleChange("contributionAmount", val)} onChangeText={(val) => handleChange('contributionAmount', val)}
keyboardType="numeric" keyboardType="numeric"
icon="cash" icon="cash-outline"
error={errors.contributionAmount} error={errors.contributionAmount}
/> />
<CustomInput
label="Entry Fee (₹)"
placeholder="e.g., 0 for free"
value={form.entryFee}
onChangeText={(val) => handleChange('entryFee', val)}
keyboardType="numeric"
icon="pricetag-outline"
error={errors.entryFee}
/>
<SettingRow label="Payment Cycle"> <SettingRow label="Payment Cycle">
<ToggleButtons <ToggleButtons
options={cycleTypes} options={cycleTypes}
selectedValue={ selectedValue={
form.cycleType === "CUSTOM" && form.customCycleDays === "1" form.cycleType === 'CUSTOM' && form.customCycleDays === '1'
? "DAILY" ? 'DAILY'
: form.cycleType === "CUSTOM" && form.customCycleDays === "7" : form.cycleType === 'CUSTOM' && form.customCycleDays === '7'
? "WEEKLY" ? 'WEEKLY'
: form.cycleType : form.cycleType
} }
onSelect={handleCycleChange} onSelect={handleCycleChange}
isWrap={true} isWrap={true}
/> />
</SettingRow> </SettingRow>
{form.cycleType === "CUSTOM" && {form.cycleType === 'CUSTOM' &&
form.customCycleDays !== "1" && form.customCycleDays !== '1' &&
form.customCycleDays !== "7" && ( form.customCycleDays !== '7' && (
<CustomInput <CustomInput
label="Custom Cycle (Days)" label="Custom Days"
placeholder="Enter number of days" placeholder="Days between payments"
value={form.customCycleDays} value={form.customCycleDays}
onChangeText={(val) => handleChange("customCycleDays", val)} onChangeText={(val) => handleChange('customCycleDays', val)}
keyboardType="numeric" keyboardType="numeric"
icon="calendar" icon="calendar-outline"
error={errors.customCycleDays} error={errors.customCycleDays}
/> />
)} )}
</Section> </Section>
<Section title="Late Fee Policy"> <Section title="Late Fee Policy">
<SettingRow label="Fee Mode"> <SettingRow label="Fee Mode">
<ToggleButtons <ToggleButtons
options={lateFeeModes} options={lateFeeModes}
selectedValue={form.lateFeeMode} selectedValue={form.lateFeeMode}
onSelect={(val) => handleChange("lateFeeMode", val)} onSelect={(val) => handleChange('lateFeeMode', val)}
/> />
</SettingRow> </SettingRow>
<CustomInput <CustomInput
label="Late Fee Value (INR)" label="Fee Amount (₹)"
placeholder="e.g., 50" placeholder="e.g., 50"
value={form.lateFeeValue} value={form.lateFeeValue}
onChangeText={(val) => handleChange("lateFeeValue", val)} onChangeText={(val) => handleChange('lateFeeValue', val)}
keyboardType="numeric" keyboardType="numeric"
icon="alert-circle" icon="alert-circle-outline"
error={errors.lateFeeValue} error={errors.lateFeeValue}
/> />
</Section> </Section>
<CustomButton title="Review Group" onPress={handleReview} style={{ marginTop: 20 }} />
<CustomButton
title="Review Details"
onPress={handleReview}
style={styles.submitBtn}
/>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</AppView>
); );
} }
// --- Helper Components for a cleaner layout --- const Section = ({ title, children }: { title: string; children: React.ReactNode }) => {
const Section: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => ( const theme = useTheme();
<View style={styles.section}> return (
<Text style={styles.sectionTitle}>{title}</Text> <View style={[styles.section, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>{title}</Text>
{children} {children}
</View> </View>
); );
};
const SettingRow: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( const SettingRow = ({ label, children }: { label: string; children: React.ReactNode }) => {
const theme = useTheme();
return (
<View style={styles.settingRow}> <View style={styles.settingRow}>
<Text style={styles.label}>{label}</Text> <Text style={[styles.label, { color: theme.colors.textSecondary }]}>{label}</Text>
{children} {children}
</View> </View>
); );
};
const ToggleButtons: React.FC<{ const ToggleButtons = ({
options,
selectedValue,
onSelect,
isWrap = false,
}: {
options: string[]; options: string[];
selectedValue: string; selectedValue: string;
onSelect: (value: string) => void; onSelect: (value: string) => void;
isWrap?: boolean; isWrap?: boolean;
}> = ({ options, selectedValue, onSelect, isWrap = false }) => ( }) => {
<View style={[styles.toggleContainer, isWrap && { flexWrap: "wrap" }]}> const theme = useTheme();
return (
<View style={[styles.toggleContainer, isWrap && { flexWrap: 'wrap' }]}>
{options.map((option) => ( {options.map((option) => (
<TouchableOpacity <TouchableOpacity
key={option} key={option}
onPress={() => onSelect(option)} onPress={() => onSelect(option)}
activeOpacity={0.7}
style={[ style={[
styles.toggleButton, styles.toggleButton,
selectedValue === option && styles.selectedButton, { borderColor: theme.colors.border },
isWrap && { flex: 0, paddingHorizontal: 15 }, selectedValue === option && { backgroundColor: theme.colors.primary, borderColor: theme.colors.primary },
isWrap && { flex: 0, paddingHorizontal: 16 },
]} ]}
> >
<Text style={[styles.toggleButtonText, selectedValue === option && styles.selectedButtonText]}> <Text
{option.charAt(0) + option.slice(1).toLowerCase()} style={[
styles.toggleText,
{ color: theme.colors.textPrimary },
selectedValue === option && { color: theme.colors.onPrimary, fontFamily: FontsFamily.nunitoBold },
]}
>
{option}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>
); );
};
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flexGrow: 1, padding: 16, backgroundColor: "#f5f6fa" }, scrollContent: {
titleContainer: { marginBottom: 20, paddingHorizontal: 4 }, paddingBottom: 40,
title: { fontSize: responsiveFontSize(2.8), fontWeight: "bold", color: "#1F2937" }, },
header: {
marginBottom: 24,
},
title: {
fontSize: responsiveFontSize(2.8),
fontFamily: FontsFamily.nunitoBold,
},
subtitle: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 4,
},
section: { section: {
backgroundColor: "#fff", borderRadius: 16,
borderRadius: 12, borderWidth: 1,
padding: 16, padding: 16,
marginBottom: 16, marginBottom: 16,
elevation: 1,
shadowColor: "#4A5568",
shadowOpacity: 0.06,
shadowRadius: 10,
}, },
sectionTitle: { sectionTitle: {
fontSize: responsiveFontSize(1.8), fontSize: responsiveFontSize(1.8),
fontWeight: "600", fontFamily: FontsFamily.nunitoBold,
color: "#374151",
marginBottom: 16, marginBottom: 16,
borderBottomWidth: 1,
borderBottomColor: "#F3F4F6",
paddingBottom: 10,
}, },
settingRow: { settingRow: {
marginBottom: 16, marginBottom: 16,
}, },
label: { fontSize: responsiveFontSize(1.4), fontWeight: "600", marginBottom: 8, color: "#4B5563" }, label: {
toggleContainer: { flexDirection: "row", gap: 10 }, fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoSemiBold,
marginBottom: 8,
},
toggleContainer: {
flexDirection: 'row',
gap: 8,
},
toggleButton: { toggleButton: {
flex: 1, flex: 1,
paddingVertical: 12, paddingVertical: 10,
borderWidth: 1, borderWidth: 1,
borderColor: "#D1D5DB", borderRadius: 10,
borderRadius: 8, alignItems: 'center',
alignItems: "center", justifyContent: 'center',
backgroundColor: "#F9FAFB", },
toggleText: {
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoMedium,
textTransform: 'capitalize',
},
row: {
flexDirection: 'row',
gap: 16,
},
flex1: {
flex: 1,
},
textArea: {
height: 100,
textAlignVertical: 'top',
},
submitBtn: {
marginTop: 8,
}, },
selectedButton: { backgroundColor: colors.pastelGreen, borderColor: "#62b59a" },
toggleButtonText: { color: "#374151", fontWeight: "600", textTransform: "capitalize" },
selectedButtonText: { color: "#1F2937" },
participantsRow: { flexDirection: "row", gap: 15 },
participantInput: { flex: 1 },
}); });
+235 -133
View File
@@ -1,20 +1,37 @@
import React, { useState } from "react"; import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Modal, ActivityIndicator } from "react-native"; import { View, Text, StyleSheet, ScrollView, Modal } from 'react-native';
import { Ionicons } from "@react-native-vector-icons/ionicons"; import LucideIcon from '../components/common/LucideIcon';
import AppView from "../components/AppView"; import AppView from '../components/AppView';
import { apiCall } from "../utils/ApiHelper"; import { GROUP_CREATION_URL } from '../utils/ApiUrls';
import { GROUP_CREATION_URL } from "../utils/ApiUrls"; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import colors from "../components/colors"; import { useTheme } from '../hooks/ui/useTheme';
import { responsiveFontSize } from "react-native-responsive-dimensions"; import { useCreate } from '../hooks/core/useCreate';
import { CustomButton } from '../components/common/Custom';
import FontsFamily from '../constents/FontsFamily';
type GroupCreationPayload = {
groupName: string;
description: string;
minParticipants: number;
maxParticipants: number;
contributionAmount: number;
cycleType: string;
customCycleDays: number;
visibility: 'PUBLIC' | 'PRIVATE';
withdrawalMethod: 'SEQUENTIAL' | 'RANDOM';
lateFeeMode: 'DAILY' | 'MONTHLY';
lateFeeValue: number;
entryFee: number;
};
// --- Helper: Get Cycle Label --- // --- Helper: Get Cycle Label ---
const getCycleLabel = (cycleType: string, customCycleDays: string | number) => { const getCycleLabel = (cycleType: string, customCycleDays: string | number) => {
if (cycleType === "CUSTOM") { if (cycleType === 'CUSTOM') {
if (String(customCycleDays) === "1") return "Daily"; if (String(customCycleDays) === '1') return 'Daily';
if (String(customCycleDays) === "7") return "Weekly"; if (String(customCycleDays) === '7') return 'Weekly';
return `Custom (${customCycleDays} days)`; return `Custom (${customCycleDays} days)`;
} }
return cycleType.charAt(0).toUpperCase() + cycleType.slice(1).toLowerCase().replace("_", " "); return cycleType.charAt(0).toUpperCase() + cycleType.slice(1).toLowerCase().replace('_', ' ');
}; };
// --- Themed Dialog Component --- // --- Themed Dialog Component ---
@@ -31,34 +48,37 @@ const ThemedDialog = ({
onClose: () => void; onClose: () => void;
onNavigate: () => void; onNavigate: () => void;
}) => { }) => {
const theme = useTheme();
if (!visible) return null; if (!visible) return null;
const config = isSuccess const config = isSuccess
? { ? {
icon: "checkmark-circle" as const, icon: 'checkmark-circle' as const,
color: "#2ecc71", color: theme.colors.primary,
title: "Success!", title: 'Request Submitted',
buttonText: "Go Home", buttonText: 'Back to Home',
action: onNavigate, action: onNavigate,
} }
: { : {
icon: "close-circle" as const, icon: 'close-circle' as const,
color: "#e74c3c", color: theme.colors.error,
title: "Oops!", title: 'Submission Failed',
buttonText: "Try Again", buttonText: 'Review and Retry',
action: onClose, action: onClose,
}; };
return ( return (
<Modal animationType="fade" transparent={true} visible={visible}> <Modal animationType="fade" transparent={true} visible={visible}>
<View style={styles.modalBackdrop}> <View style={styles.modalBackdrop}>
<View style={styles.dialogBox}> <View style={[styles.dialogBox, { backgroundColor: theme.colors.surface, borderRadius: theme.borderRadius.xl }]}>
<Ionicons name={config.icon} size={60} color={config.color} /> <LucideIcon name={config.icon} size={64} color={config.color} />
<Text style={styles.dialogTitle}>{config.title}</Text> <Text style={[styles.dialogTitle, { color: theme.colors.textPrimary }]}>{config.title}</Text>
<Text style={styles.dialogMessage}>{message}</Text> <Text style={[styles.dialogMessage, { color: theme.colors.textSecondary }]}>{message}</Text>
<TouchableOpacity style={styles.dialogButton} onPress={config.action}> <CustomButton
<Text style={styles.dialogButtonText}>{config.buttonText}</Text> title={config.buttonText}
</TouchableOpacity> onPress={config.action}
style={styles.dialogButton}
/>
</View> </View>
</View> </View>
</Modal> </Modal>
@@ -66,116 +86,127 @@ const ThemedDialog = ({
}; };
export default function GroupReview({ route, navigation }: any) { export default function GroupReview({ route, navigation }: any) {
const { form } = route.params; const { form } = route.params as { form: GroupCreationPayload };
const [isLoading, setIsLoading] = useState(false); const theme = useTheme();
const [isSuccess, setIsSuccess] = useState(false);
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [feedbackMessage, setFeedbackMessage] = useState(""); const [feedbackMessage, setFeedbackMessage] = useState('');
const [isSuccess, setIsSuccess] = useState(false);
const { mutate: createGroup, isPending: isLoading } = useCreate<any, any>(GROUP_CREATION_URL);
const handleNavigateHome = () => { const handleNavigateHome = () => {
setModalVisible(false); setModalVisible(false);
navigation.reset({ index: 0, routes: [{ name: "Home" }] }); navigation.reset({ index: 0, routes: [{ name: 'Home' }] });
}; };
const handleSubmit = async () => { const handleSubmit = () => {
setIsLoading(true); const payload: GroupCreationPayload = {
try { groupName: String(form.groupName || '').trim(),
const res = (await apiCall(GROUP_CREATION_URL, "POST", form)) as { description: String(form.description || '').trim(),
statusCode?: number; minParticipants: Number(form.minParticipants) || 0,
message?: string; maxParticipants: Number(form.maxParticipants) || 0,
contributionAmount: Number(form.contributionAmount) || 0,
cycleType: String(form.cycleType || 'MONTHLY'),
customCycleDays: String(form.cycleType || '').toUpperCase() === 'CUSTOM'
? Number(form.customCycleDays) || 0
: 0,
visibility: (String(form.visibility || 'PUBLIC').toUpperCase() === 'PRIVATE' ? 'PRIVATE' : 'PUBLIC'),
withdrawalMethod: (String(form.withdrawalMethod || 'SEQUENTIAL').toUpperCase() === 'RANDOM' ? 'RANDOM' : 'SEQUENTIAL'),
lateFeeMode: (String(form.lateFeeMode || 'DAILY').toUpperCase() === 'MONTHLY' ? 'MONTHLY' : 'DAILY'),
lateFeeValue: Number(form.lateFeeValue) || 0,
entryFee: Number(form.entryFee) || 0,
}; };
if (res?.statusCode === 201) {
createGroup(payload, {
onSuccess: (res) => {
setIsSuccess(true); setIsSuccess(true);
setFeedbackMessage(res.message || "Your group has been created successfully."); setFeedbackMessage(res.message || 'Your circle request was submitted for admin approval.');
} else {
throw new Error(res?.message || "Failed to create the group.");
}
} catch (error: any) {
setIsSuccess(false);
setFeedbackMessage(error.message || "An unexpected error occurred.");
} finally {
setIsLoading(false);
setModalVisible(true); setModalVisible(true);
} },
onError: (error) => {
setIsSuccess(false);
setFeedbackMessage(error.message || 'Failed to submit your request. Please try again.');
setModalVisible(true);
},
});
}; };
const TimelineSection: React.FC<{ icon: any; title: string; children: React.ReactNode }> = ({ const TimelineSection = ({ icon, title, children }: { icon: any; title: string; children: React.ReactNode }) => (
icon,
title,
children,
}) => (
<View style={styles.section}> <View style={styles.section}>
<View style={styles.timeline}> <View style={styles.timeline}>
<View style={styles.timelineIcon}> <View style={[styles.timelineIcon, { backgroundColor: theme.colors.primary }]}>
<Ionicons name={icon} size={22} color="#fff" /> <LucideIcon name={icon} size={20} color={theme.colors.onPrimary} />
</View> </View>
<View style={styles.timelineLine} /> <View style={[styles.timelineLine, { backgroundColor: theme.colors.border }]} />
</View> </View>
<View style={styles.content}> <View style={styles.content}>
<Text style={styles.sectionTitle}>{title}</Text> <Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>{title}</Text>
<View style={styles.card}>{children}</View> <View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>{children}</View>
</View> </View>
</View> </View>
); );
const renderRow = (label: string, value: string | number | null) => { const renderRow = (label: string, value: string | number | null) => {
if (value === null || value === undefined || value === "") return null; if (value === null || value === undefined || value === '') return null;
return ( return (
<View style={styles.row}> <View style={[styles.row, { borderBottomColor: theme.colors.background }]}>
<Text style={styles.label}>{label}</Text> <Text style={[styles.label, { color: theme.colors.textSecondary }]}>{label}</Text>
<Text style={styles.value}>{value}</Text> <Text style={[styles.value, { color: theme.colors.textPrimary }]}>{value}</Text>
</View> </View>
); );
}; };
return ( return (
<AppView> <AppView dismissKeyboardOnTap={false} paddingHorizontal={0}>
<ScrollView contentContainerStyle={styles.container}> <ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}>{form.groupName || "Group Review"}</Text> <Text style={[styles.title, { color: theme.colors.textPrimary }]}>{form.groupName || 'Circle Review'}</Text>
<Text style={styles.subtitle}>Confirm the details to launch your group.</Text> <Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
Review all details before submitting for admin approval.
</Text>
</View> </View>
<TimelineSection icon="information-circle-outline" title="Basic Information"> <TimelineSection icon="information-circle-outline" title="Basic Information">
{renderRow("Name", form.groupName)} {renderRow('Name', form.groupName)}
{form.description ? ( {form.description ? (
<View style={styles.descriptionContainer}> <View style={styles.descriptionContainer}>
<Text style={styles.label}>Description</Text> <Text style={[styles.label, { color: theme.colors.textSecondary }]}>Description</Text>
<Text style={styles.descriptionText}>{form.description}</Text> <Text style={[styles.descriptionText, { color: theme.colors.textPrimary }]}>{form.description}</Text>
</View> </View>
) : null} ) : null}
</TimelineSection> </TimelineSection>
<TimelineSection icon="shield-checkmark-outline" title="Rules & Privacy"> <TimelineSection icon="shield-checkmark-outline" title="Governance">
{renderRow("Visibility", form.visibility)} {renderRow('Visibility', form.visibility)}
{renderRow("Distribution", form.withdrawalMethod)} {renderRow('Distribution', form.withdrawalMethod)}
{renderRow("Min Members", form.minParticipants)} {renderRow('Min Members', form.minParticipants)}
{renderRow("Max Members", form.maxParticipants)} {renderRow('Max Members', form.maxParticipants)}
</TimelineSection> </TimelineSection>
<TimelineSection icon="wallet-outline" title="Financial Details"> <TimelineSection icon="wallet-outline" title="Financials">
{renderRow("Contribution", `₹${form.contributionAmount}`)} {renderRow('Contribution', `₹${form.contributionAmount}`)}
{renderRow("Cycle", getCycleLabel(form.cycleType, form.customCycleDays))} {renderRow('Entry Fee', `₹${form.entryFee}`)}
{/* Show days only if neither Daily nor Weekly */} {renderRow('Cycle', getCycleLabel(form.cycleType, form.customCycleDays))}
{form.cycleType === "CUSTOM" && !["1", "7"].includes(String(form.customCycleDays)) {form.cycleType === 'CUSTOM' && !['1', '7'].includes(String(form.customCycleDays)) &&
&& renderRow("Cycle Days", `${form.customCycleDays} days`)} renderRow('Cycle Days', `${form.customCycleDays} days`)}
</TimelineSection> </TimelineSection>
<TimelineSection icon="alert-circle-outline" title="Late Fee Policy"> <TimelineSection icon="alert-circle-outline" title="Policies">
{renderRow("Fee Mode", form.lateFeeMode)} {renderRow('Fee Mode', form.lateFeeMode)}
{renderRow("Fee Value", `₹${form.lateFeeValue}`)} {renderRow('Fee Value', `₹${form.lateFeeValue}`)}
</TimelineSection> </TimelineSection>
<TouchableOpacity onPress={handleSubmit} style={styles.submitBtn} disabled={isLoading}> <CustomButton
{isLoading ? ( title="Submit"
<ActivityIndicator color="#333" /> onPress={handleSubmit}
) : ( loading={isLoading}
<> style={styles.submitBtn}
<Ionicons name="rocket-outline" size={22} color="#333" /> />
<Text style={styles.btnText}>Create Group</Text>
</>
)}
</TouchableOpacity>
</ScrollView> </ScrollView>
<ThemedDialog <ThemedDialog
@@ -190,50 +221,121 @@ export default function GroupReview({ route, navigation }: any) {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { padding: 20, backgroundColor: "#f5f6fa" }, scrollView: {
header: { marginBottom: 30 }, flex: 1,
title: { fontSize: responsiveFontSize(2.8), fontWeight: "bold", color: "#333" }, },
subtitle: { fontSize: responsiveFontSize(1.6), color: "#666", marginTop: 4 }, scrollContent: {
section: { flexDirection: "row", marginBottom: 10 }, flexGrow: 1,
timeline: { alignItems: "center", width: 40 }, paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 40,
},
header: {
marginBottom: 32,
},
title: {
fontSize: responsiveFontSize(2.8),
fontFamily: FontsFamily.nunitoBold,
},
subtitle: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 4,
},
section: {
flexDirection: 'row',
},
timeline: {
alignItems: 'center',
width: 32,
},
timelineIcon: { timelineIcon: {
width: 40, width: 32,
height: 40, height: 32,
borderRadius: 20, borderRadius: 16,
backgroundColor: colors.pastelGreen, justifyContent: 'center',
justifyContent: "center", alignItems: 'center',
alignItems: "center", zIndex: 1,
elevation: 3, },
timelineLine: {
flex: 1,
width: 2,
marginTop: -2,
},
content: {
flex: 1,
paddingLeft: 16,
paddingBottom: 24,
},
sectionTitle: {
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoBold,
marginBottom: 12,
},
card: {
borderRadius: 16,
padding: 16,
borderWidth: 1,
}, },
timelineLine: { flex: 1, width: 2, backgroundColor: "#e0e0e0" },
content: { flex: 1, paddingLeft: 10, paddingBottom: 20 },
sectionTitle: { fontSize: responsiveFontSize(1.8), fontWeight: "bold", color: "#444", marginBottom: 10 },
card: { backgroundColor: "#fff", borderRadius: 12, padding: 15, elevation: 2 },
row: { row: {
flexDirection: "row", flexDirection: 'row',
justifyContent: "space-between", justifyContent: 'space-between',
paddingVertical: 12, paddingVertical: 10,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: "#f5f5f5",
}, },
label: { fontSize: responsiveFontSize(1.5), color: "#666", fontWeight: "500" }, label: {
value: { fontSize: responsiveFontSize(1.5), color: "#333", fontWeight: "600", textTransform: "capitalize" }, fontSize: responsiveFontSize(1.5),
descriptionContainer: { paddingVertical: 10 }, fontFamily: FontsFamily.nunitoMedium,
descriptionText: { fontSize: responsiveFontSize(1.5), color: "#333", marginTop: 4, lineHeight: 22 }, },
value: {
fontSize: responsiveFontSize(1.5),
fontFamily: FontsFamily.nunitoBold,
textTransform: 'capitalize',
},
descriptionContainer: {
paddingVertical: 10,
},
descriptionText: {
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 6,
lineHeight: 20,
},
submitBtn: { submitBtn: {
backgroundColor: colors.pastelGreen, marginTop: 16,
flexDirection: "row", },
justifyContent: "center", modalBackdrop: {
alignItems: "center", flex: 1,
paddingVertical: 16, backgroundColor: 'rgba(0, 0, 0, 0.4)',
borderRadius: 12, justifyContent: 'center',
marginTop: 20, alignItems: 'center',
padding: 20,
},
dialogBox: {
width: '100%',
maxWidth: 340,
padding: 32,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 10,
elevation: 8,
},
dialogTitle: {
fontSize: responsiveFontSize(2.2),
fontFamily: FontsFamily.nunitoBold,
marginTop: 16,
marginBottom: 8,
},
dialogMessage: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
textAlign: 'center',
marginBottom: 24,
lineHeight: 22,
},
dialogButton: {
width: '100%',
}, },
btnText: { color: "#333", fontWeight: "bold", fontSize: responsiveFontSize(1.8), marginLeft: 10 },
modalBackdrop: { flex: 1, backgroundColor: "rgba(0, 0, 0, 0.6)", justifyContent: "center", alignItems: "center" },
dialogBox: { width: "85%", backgroundColor: "white", borderRadius: 16, padding: 25, alignItems: "center", elevation: 10 },
dialogTitle: { fontSize: responsiveFontSize(2.2), fontWeight: "bold", color: "#333", marginTop: 15, marginBottom: 10 },
dialogMessage: { fontSize: responsiveFontSize(1.6), color: "#666", textAlign: "center", marginBottom: 25 },
dialogButton: { backgroundColor: "#f0f0f0", paddingVertical: 12, paddingHorizontal: 40, borderRadius: 10 },
dialogButtonText: { color: "#333", fontWeight: "bold", fontSize: 16 },
}); });
+368 -326
View File
@@ -1,396 +1,438 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useMemo, useCallback } from 'react';
import { import {
View, View,
Text, Text,
StyleSheet, StyleSheet,
FlatList, FlatList,
TouchableOpacity, TouchableOpacity,
TextInput,
Dimensions, Dimensions,
ActivityIndicator,
Image, Image,
ScrollView,
Alert,
RefreshControl, RefreshControl,
ToastAndroid, ToastAndroid,
} from "react-native"; } from 'react-native';
import { FAB } from "react-native-paper"; import { FAB } from 'react-native-paper';
import { Ionicons } from "@react-native-vector-icons/ionicons"; import LucideIcon from '../components/common/LucideIcon';
import { MaterialIcons } from "@react-native-vector-icons/material-icons"; import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { useNavigation, useFocusEffect } from "@react-navigation/native"; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { StackNavigationProp } from "@react-navigation/stack"; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { RootStackParamList } from "../navigation/AppNavigator"; import { useSelector } from 'react-redux';
import AppView from "../components/AppView"; import AppView from '../components/AppView';
import { apiCall } from "../utils/ApiHelper"; import { useTheme } from '../hooks/ui/useTheme';
import { BASE_URL, GET_JOINED_GROUP_URL, JOIN_GROUP_URL, PUBLIC_GROUPS_URL, USER_STATUS } from "../utils/ApiUrls"; import { useFetch } from '../hooks/core/useFetch';
import colors from "../components/colors"; import { useCreate } from '../hooks/core/useCreate';
import { ApiResponse } from "./home/UserProfileStatus"; import { PUBLIC_GROUPS_URL, GET_JOINED_GROUP_URL, JOIN_GROUP_URL, USER_STATUS } from '../utils/ApiUrls';
import * as Keychain from "react-native-keychain"; import { CustomInput, CustomButton } from '../components/common/Custom';
import CustomAlert from "../components/CostomAlart"; import SkeletonLoader from '../components/common/SkeletonLoader';
import { responsiveFontSize } from "react-native-responsive-dimensions"; import FontsFamily from '../constents/FontsFamily';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
// --- Types --- // --- Types ---
interface Group { interface Group {
id: string; id: string | number;
groupName: string; groupName: string;
description: string; description: string;
visibility?: string;
status?: string;
maxParticipants: number; maxParticipants: number;
currentParticipants: number; currentParticipants: number;
contributionAmount: number; contributionAmount: number;
cycleType: string; cycleType: string;
createdByName: string; createdByName: string;
imageBlob: string imageBlob: string;
joined?: boolean;
joinRequestPending?: boolean;
entryFee?: number;
entryFeeAppliedToOrganizer?: boolean;
customCycleDays?: number;
imageUrl?: string;
} }
// --- UI Components --- const PUBLIC_GROUPS_PARAMS = {
const GroupCard = ({ item, onJoin, onView, isGrid }: { item: Group; onJoin:()=>void; onView: (group: Group) => void; isGrid?: boolean; }) => { page: 0,
const isFull = item.currentParticipants >= item.maxParticipants; size: 10,
const cardStyle = isGrid ? styles.gridCard : styles.listCard; };
const [loading, setLoading] = useState<boolean>(false)
const navigation = useNavigation()
const getGroupsFromResponse = (response: any): Group[] => {
if (!response) return [];
if (Array.isArray(response?.data?.content)) return response.data.content;
if (Array.isArray(response?.data)) return response.data;
return [];
};
// --- UI Components ---
const GroupCard = ({
item,
onJoin,
isJoined,
isJoining
}: {
item: Group;
onJoin: () => void;
isJoined: boolean;
isJoining: boolean;
}) => {
const theme = useTheme();
const isFull = item.currentParticipants >= item.maxParticipants;
const isPending = Boolean(item.joinRequestPending);
const canJoin = !isJoined && !isPending && !isFull;
console.log("item>>>>>>>>>>>>>>", item);
return ( return (
<View style={cardStyle}> <View style={[
{/* <TouchableOpacity onPress={()=> navigation.navigate("GroupDetails" as never)}> */} styles.card,
<View style={{ flex: 1, flexDirection: 'row', justifyContent: "space-between" }}> { backgroundColor: theme.colors.surface, borderColor: theme.colors.border },
<View> styles.listCard
<Text style={[styles.groupName,{width:isGrid?"50%":"100%"}]} numberOfLines={isGrid ? 2 : 1}>{item.groupName}</Text> ]}>
<Text style={styles.groupDescription} numberOfLines={isGrid ? 2 : 1}>{item.description}</Text> <View style={styles.cardHeader}>
<View style={styles.groupInfo}>
<Text style={[styles.groupName, { color: theme.colors.textPrimary }]} numberOfLines={1}>
{item.groupName}
</Text>
<Text style={[styles.groupDescription, { color: theme.colors.textSecondary }]} numberOfLines={1}>
{item.description}
</Text>
</View> </View>
<Image <Image
source={{ uri: item?.imageBlob !== null ? `data:image/jpeg;base64,${item?.imageBlob}` : `https://i.pravatar.cc/150?u=${item.id}` }} source={{ uri: item.imageUrl ? item.imageUrl : `https://ui-avatars.com/api/?name=${item.groupName}&background=random` }}
style={{ width: 50, borderWidth: 1, height: 50, borderColor: colors.pastelGreen, borderRadius: 25, }} style={[styles.groupAvatar, { borderColor: theme.colors.border }]}
/> />
</View> </View>
<View style={styles.groupMeta}> <View style={[styles.cardDivider, { backgroundColor: theme.colors.border }]} />
<Text style={styles.groupMetaText}>{item.currentParticipants}/{item.maxParticipants} Members</Text>
<Text style={styles.groupMetaText}>{item.cycleType}</Text> <View style={styles.cardMeta}>
<View style={styles.metaItem}>
<LucideIcon name="people-outline" size={14} color={theme.colors.textMuted} />
<Text style={[styles.metaText, { color: theme.colors.textSecondary }]}>
{item.currentParticipants}/{item.maxParticipants}
</Text>
</View> </View>
{/* </TouchableOpacity> */} <View style={styles.metaItem}>
<View style={styles.buttonContainer}> <LucideIcon name="repeat-outline" size={14} color={theme.colors.textMuted} />
{/* <TouchableOpacity style={styles.viewButton} onPress={() => onView(item)}> <Text style={[styles.metaText, { color: theme.colors.textSecondary }]}>
<Text style={styles.viewButtonText}>View</Text> {item.cycleType === 'CUSTOM' && item.customCycleDays
</TouchableOpacity> */} ? (item.customCycleDays === 1 ? 'Daily' : item.customCycleDays === 7 ? 'Weekly' : `${item.customCycleDays} Days`)
<TouchableOpacity : item.cycleType}
style={[styles.joinButton, isFull && styles.joinButtonDisabled]} </Text>
onPress={async () => {
setLoading(true);
await onJoin(item.id);
setLoading(false);
}}
disabled={isFull || loading} // Also disable button while loading
>
{loading ? <ActivityIndicator color={colors.black} /> : <Text style={styles.joinButtonText}>{isFull ? "Full" : "Join"}</Text>}
</TouchableOpacity>
</View> </View>
{item.entryFee && item.entryFee > 0 ? (
<View style={styles.metaItem}>
<LucideIcon name="ticket-outline" size={14} color={theme.colors.textMuted} />
<Text style={[styles.metaText, { color: theme.colors.textSecondary }]}>
₹{item.entryFee}
</Text>
</View>
) : null}
</View>
{!isJoined && (
<CustomButton
title={isPending ? 'Request Pending' : isFull ? 'Full' : (item?.entryFee && item?.entryFee > 0 ? 'Pay & Join' : 'Join Circle')}
onPress={onJoin}
disabled={!canJoin || isJoining}
loading={isJoining}
style={styles.joinBtn}
type={!canJoin ? 'secondary' : 'primary'}
/>
)}
</View> </View>
); );
}; };
const FeaturedGroupCard = ({ item }: { item: Group }) => {
return (
<View style={styles.featuredCard}>
<Image source={{ uri: item?.imageBlob !== null ? `data:image/jpeg;base64${item?.imageBlob}` : `https://i.pravatar.cc/150?u=${item.id}` }} style={styles.featuredImage} />
<View style={styles.featuredContent}>
<Text style={styles.featuredName} numberOfLines={1}>{item.groupName}</Text>
<Text style={styles.featuredCreator}>by {item.createdByName}</Text>
</View>
</View>
)
};
export default function GroupScreen() { export default function GroupScreen() {
const [activeTab, setActiveTab] = useState("Public"); const theme = useTheme();
const [searchQuery, setSearchQuery] = useState(""); const navigation = useNavigation<any>();
const [isGridView, setIsGridView] = useState(false); const insets = useSafeAreaInsets();
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>(); const userRole = useSelector((state: any) => state.user?.role);
const [activeTab, setActiveTab] = useState<'Public' | 'Joined'>('Public');
const [myGroups, setMyGroups] = useState<Group[]>([]); const [searchQuery, setSearchQuery] = useState('');
const [joinedGroups, setJoinedGroups] = useState<Group[]>([]); const [joiningGroupId, setJoiningGroupId] = useState<string | number | null>(null);
const [publicGroups, setPublicGroups] = useState<Group[]>([]);
const [featuredGroups, setFeaturedGroups] = useState<Group[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [role, setRole] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState<boolean>(false)
const [alertInfo, setAlertInfo] = useState({
visible: false,
title: '',
message: '',
buttons: [] as { text: string; onPress: () => void; style?: 'cancel' | 'destructive' | 'default' }[],
alertType: 'info' as 'info' | 'success' | 'error',
});
const fetchGroups = async () => { // Data Fetching
if (loading) return; const { data: publicGroupsRes, isLoading: publicLoading, refetch: refetchPublic } = useFetch<any>(
setLoading(true); PUBLIC_GROUPS_URL,
setError(null); PUBLIC_GROUPS_PARAMS
try {
const res = await apiCall(PUBLIC_GROUPS_URL, "GET") as { data?: Group[] };
if (res && res.data) {
setPublicGroups(res.data);
setFeaturedGroups(res.data.slice(0, 5));
} else {
throw new Error("No data received");
}
} catch (e) {
setError("Failed to fetch groups. Please try again.");
} finally {
setLoading(false);
}
};
const fetchJoinedGroups = async () => {
if (loading) return;
setLoading(true);
setError(null);
try {
console.log("first-????-")
const res = await apiCall(GET_JOINED_GROUP_URL, "GET") as { data?: Group[] };
console.log("first---", res)
if (res && res.data) {
setJoinedGroups(res.data);
} else {
throw new Error("No data received");
}
} catch (e) {
setError("Failed to fetch groups. Please try again.");
} finally {
setLoading(false);
}
};
useFocusEffect(React.useCallback(() => {
fetchGroups()
fetchJoinedGroups()
}, []));
const filterData = (data: Group[]) => {
if (!searchQuery.trim()) return data;
return data.filter(g =>
g.groupName.toLowerCase().includes(searchQuery.toLowerCase())
); );
}; const { data: joinedGroupsRes, isLoading: joinedLoading, refetch: refetchJoined } = useFetch<any>(GET_JOINED_GROUP_URL);
const { data: userStatusRes } = useFetch<any>(USER_STATUS);
const { mutate: joinGroup, isPending: isJoining } = useCreate<any, any>('');
const handleJoinGroup = async (groupId: string, userId: string) => { const role = String(userStatusRes?.data?.role || userRole || '').trim().toUpperCase();
console.log("Joining group:", groupId, "User:", userId); const isOrganizer = role === 'ORGANIZER';
try {
const url = `${JOIN_GROUP_URL}groups/${groupId}/join/10`;
console.log("Joining group URL:", url);
const res = await apiCall(url, "POST") as any;
console.log("JOIN_GROUP_URL response:", res); const publicGroups = useMemo(() => {
const groups = getGroupsFromResponse(publicGroupsRes);
if (res.statusCode === 200) { return groups.filter((g) => {
setAlertInfo({ const visibility = String(g.visibility || "").toUpperCase();
visible: true, const status = String(g.status || "").toUpperCase();
title: 'Success', return visibility === "PUBLIC" && status === "APPROVED";
message: 'Your request to join the group has been sent.',
buttons: [
{ text: 'OK', onPress: () => setAlertInfo({ ...alertInfo, visible: false }), buttonColor: "#28a745", textColor: "#fff" }
],
alertType: 'success',
}); });
} else { }, [publicGroupsRes]);
ToastAndroid.show(res.message || "Failed to join group.", ToastAndroid.SHORT);
}
} catch (error) {
console.error("Join Group Error:", error);
}
};
const userStatus = async () => { const joinedGroups = useMemo(() => getGroupsFromResponse(joinedGroupsRes), [joinedGroupsRes]);
try {
const credentials = await Keychain.getGenericPassword();
if (!credentials) {
ToastAndroid.show('You are not logged in', ToastAndroid.SHORT);
return; const filteredData = useMemo(() => {
} const data = activeTab === 'Public' ? publicGroups : joinedGroups;
const token = credentials.password; if (!searchQuery.trim()) return data;
return data.filter((g: Group) => String(g.groupName || "").toLowerCase().includes(searchQuery.toLowerCase()));
}, [activeTab, joinedGroups, publicGroups, searchQuery]);
setLoading(true); const [refreshing, setRefreshing] = useState(false);
setError(null); const onRefresh = useCallback(() => {
const response = await fetch(USER_STATUS, { setRefreshing(true);
method: "GET", const promises = [refetchPublic(), refetchJoined()];
headers: { Promise.all(promises).finally(() => setRefreshing(false));
Accept: "*/*", }, [refetchPublic, refetchJoined]);
Authorization: `Bearer ${token}`,
},
});
const json: ApiResponse = await response.json();
console.log("👤 Profile Status Response:", json);
if (json.status === "success") {
setRole(json.data.role);
if (json.data.profileStatus === false) {
}
} else {
setError(json.message || "Failed to fetch status");
}
} catch (err) {
setError("Something went wrong. Please try again.");
} finally {
setLoading(false);
}
};
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
userStatus(); onRefresh();
}, []) }, [])
); );
const handleJoin = (groupId: string | number) => {
console.log("Hitting here");
const handleViewGroup = (group: Group) => { setJoiningGroupId(groupId);
// navigation.navigate('GroupDetails', { group }); joinGroup(
ToastAndroid.show(`Viewing details for ${group.groupName}`, ToastAndroid.SHORT); { resource: `${JOIN_GROUP_URL}groups/${String(groupId)}/join/10`, payload: {} } as any,
}; {
onSuccess: () => {
const onRefresh = () => { setJoiningGroupId(null);
setRefreshing(true) ToastAndroid.show('Join request sent successfully!', ToastAndroid.SHORT);
fetchGroups() refetchPublic();
setTimeout(() => { },
setRefreshing(false) onError: (err) => {
}, 20000); setJoiningGroupId(null);
ToastAndroid.show(err.message || 'Failed to send request', ToastAndroid.SHORT);
},
} }
const renderContent = () => {
let data: Group[];
switch (activeTab) {
case "My Groups": data = myGroups; break;
case "Joined": data = joinedGroups; break;
case "Public": data = publicGroups; break;
default: data = [];
}
if (loading && !publicGroups.length) return <ActivityIndicator size="large" color="#62b59a" style={{ marginTop: 40 }} />;
if (error) return <Text style={styles.emptyText}>{error}</Text>;
const filteredData = filterData(data);
if (filteredData.length === 0 && !loading) return <Text style={styles.emptyText}>No groups found.</Text>;
return (
<FlatList
data={filteredData}
key={isGridView ? 'grid' : 'list'}
numColumns={isGridView ? 2 : 1}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<GroupCard
item={item}
onJoin={() => {
console.log('mmmm')
if (activeTab === 'Public') {
handleJoinGroup(item.id, 'currentUserId');
}
}}
onView={handleViewGroup}
isGrid={isGridView}
/>
)}
columnWrapperStyle={isGridView ? { justifyContent: 'space-between' } : null}
contentContainerStyle={{ paddingBottom: 150 }}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
// ListFooterComponent={<ActivityIndicator size={'large'} color={colors.Group} />}
/>
); );
}; };
const renderSkeletons = () => (
<View style={styles.skeletonList}>
{[1, 2, 3, 4].map((i) => (
<SkeletonLoader
key={i}
width={'100%'}
height={160}
style={{ marginBottom: 16, borderRadius: 16 }}
/>
))}
</View>
);
return ( return (
<AppView paddingHorizontal={0}> <AppView paddingHorizontal={0}>
{/* Fixed Header */} <View style={styles.topContainer}>
<View style={styles.headerContainer}> <View style={styles.header}>
<Text style={styles.title}>Explore Groups</Text> <Text style={[styles.title, { color: theme.colors.textPrimary }]}>Explore Circles</Text>
<View style={styles.searchContainer}>
<Ionicons name="search" size={20} color="#888" style={styles.searchIcon} />
<TextInput style={styles.searchInput} placeholder="Search for groups..." placeholderTextColor="#888" value={searchQuery} onChangeText={setSearchQuery} />
</View>
</View> </View>
{/* Scrollable Content */} <CustomInput
<ScrollView style={styles.container}> placeholder="Search circles by name..."
value={searchQuery}
<View style={styles.tabHeader}> onChangeText={setSearchQuery}
<View style={styles.segmentedControl}> icon="search-outline"
{role === "ORGANIZER" ? (["Public"].map((tab) => (
<TouchableOpacity key={tab} style={[styles.segmentButton, activeTab === tab && styles.segmentButtonActive]} onPress={() => setActiveTab(tab)}>
<Text style={[styles.segmentText, activeTab === tab && styles.segmentTextActive]}>{tab}</Text>
</TouchableOpacity>
))) : (["Joined", "Public"].map((tab) => (
<TouchableOpacity key={tab} style={[styles.segmentButton, activeTab === tab && styles.segmentButtonActive]} onPress={() => setActiveTab(tab)}>
<Text style={[styles.segmentText, activeTab === tab && styles.segmentTextActive]}>{tab}</Text>
</TouchableOpacity>
)))}
</View>
<TouchableOpacity onPress={() => setIsGridView(!isGridView)} style={styles.layoutToggleButton}>
<Ionicons name={isGridView ? "list" : "grid"} size={22} color="#555" />
</TouchableOpacity>
</View>
{/* The list now renders inside a simple View, as FlatList handles its own scroll */}
<View style={{}}>{renderContent()}</View>
</ScrollView>
{role === "ORGANIZER" && (
<FAB style={styles.fab} icon={() => <MaterialIcons name="group-add" size={24} color="#333" />} onPress={() => navigation.navigate('GroupCreation')} />
)}
<CustomAlert
visible={alertInfo.visible}
title={alertInfo.title}
message={alertInfo.message}
buttons={alertInfo.buttons}
alertType={alertInfo.alertType}
/> />
{!isOrganizer && (
<View style={[styles.tabs, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
{(['Public', 'Joined'] as const).map((tab) => (
<TouchableOpacity
key={tab}
onPress={() => setActiveTab(tab)}
style={[
styles.tab,
activeTab === tab && { backgroundColor: theme.colors.card, borderRadius: 10, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 2 }
]}
>
<Text style={[
styles.tabText,
{ color: activeTab === tab ? theme.colors.textPrimary : theme.colors.textMuted },
activeTab === tab && { fontFamily: FontsFamily.nunitoBold }
]}>
{tab}
</Text>
</TouchableOpacity>
))}
</View>
)}
</View>
<FlatList
data={filteredData}
keyExtractor={(item) => String(item.id)}
renderItem={({ item }) => (
<GroupCard
item={item}
isJoined={activeTab === 'Joined' || Boolean(item.joined)}
onJoin={() => item?.entryFee && item?.entryFee > 0 ? navigation.navigate('PaymentMode' as any, {
groupId: item.id,
routeFrom: 'GroupScreen',
routeData: item
}) : handleJoin(item.id)}
isJoining={isJoining && joiningGroupId === item.id}
/>
)}
contentContainerStyle={styles.listContent}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[theme.colors.primary]}
/>
}
ListEmptyComponent={
!publicLoading && !joinedLoading ? (
<View style={styles.emptyState}>
<LucideIcon name="planet-outline" size={64} color={theme.colors.textMuted} />
<Text style={[styles.emptyText, { color: theme.colors.textSecondary }]}>
{searchQuery ? 'No circles match your search' : 'No circles found here'}
</Text>
</View>
) : renderSkeletons()
}
/>
{isOrganizer && (
<FAB
style={[styles.fab, { backgroundColor: theme.colors.primary, bottom: insets.bottom + 88 }]}
icon={() => <LucideIcon name="add" size={24} color={theme.colors.onPrimary} />}
onPress={() => navigation.navigate('GroupCreation')}
/>
)}
</AppView> </AppView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
topContainer: {
paddingHorizontal: 14,
paddingTop: 10,
paddingBottom: 8,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 14,
},
title: {
fontSize: responsiveFontSize(2.8),
fontFamily: FontsFamily.nunitoBold,
},
iconButton: {
width: 38,
height: 38,
borderRadius: 12,
borderWidth: 1,
justifyContent: 'center',
alignItems: 'center',
},
tabs: {
flexDirection: 'row',
padding: 4,
borderRadius: 14,
borderWidth: 1,
marginTop: 12,
marginBottom: 4,
},
tab: {
flex: 1,
paddingVertical: 10,
alignItems: 'center',
justifyContent: 'center',
},
tabText: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoSemiBold,
},
listContent: {
paddingHorizontal: 14,
paddingBottom: 86,
},
card: {
borderRadius: 16,
borderWidth: 1,
padding: 14,
marginBottom: 12,
},
listCard: {
width: '100%',
},
headerContainer: { backgroundColor: '#f5f6fa', paddingHorizontal: 16 }, cardHeader: {
title: { fontSize: responsiveFontSize(2.2), fontWeight: "600", color: colors.Group }, flexDirection: 'row',
searchContainer: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#fff', borderRadius: 20, paddingHorizontal: 10, elevation: 5, marginVertical: 20 , borderWidth:2,borderColor:colors.pastelGreen }, justifyContent: 'space-between',
searchIcon: { marginRight: 8 }, alignItems: 'flex-start',
searchInput: { flex: 1, height: 45, fontSize: responsiveFontSize(1.6), color: '#333' }, },
sectionTitle: { fontSize: responsiveFontSize(2.0), fontWeight: 'bold', color: colors.Group, marginBottom: 12, paddingHorizontal: 16 }, groupInfo: {
featuredList: { paddingLeft: 16, marginBottom: 20 }, flex: 1,
featuredCard: { width: width * 0.45, marginRight: 15, backgroundColor: '#fff', borderRadius: 12, elevation: 3, overflow: 'hidden' }, marginRight: 10,
featuredImage: { width: '100%', height: 100 }, },
featuredContent: { padding: 10 }, groupName: {
featuredName: { fontSize: responsiveFontSize(1.4), fontWeight: 'bold', color: '#333' }, fontSize: responsiveFontSize(1.8),
featuredCreator: { fontSize: responsiveFontSize(1.2), color: '#777', marginTop: 2 }, fontFamily: FontsFamily.nunitoBold,
tabHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, marginBottom: 16 }, marginBottom: 2,
segmentedControl: { flex: 1, flexDirection: 'row', borderRadius: 10, padding: 4, marginRight: 10 }, },
segmentButton: { flex: 1, paddingVertical: 10, borderRadius: 8, alignItems: 'center' }, groupDescription: {
segmentButtonActive: { backgroundColor: colors.pastelGreen, elevation: 5, shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 5 , borderRadius:15 }, fontSize: responsiveFontSize(1.4),
segmentText: { fontSize: responsiveFontSize(1.4), fontWeight: '600', color: '#555' }, fontFamily: FontsFamily.nunitoRegular,
segmentTextActive: { color: '#333' }, },
layoutToggleButton: { padding: 8 }, groupAvatar: {
fab: { position: "absolute", right: 20, bottom: 90, width: 60, height: 60, justifyContent: "center", alignItems: "center", backgroundColor: colors.pastelGreen, borderRadius: 30, elevation: 4 }, width: 48,
listCard: { backgroundColor: colors.pastelGreen, padding: 16, borderRadius: 20, marginBottom: 12,marginHorizontal:15, elevation: 5 }, height: 48,
gridCard: { backgroundColor: colors.pastelGreen, padding: 12, borderRadius: 20, marginBottom: 12,marginHorizontal:15, elevation: 5, width: (width - 48) / 2 }, borderRadius: 12,
groupName: { fontSize: responsiveFontSize(1.6), fontWeight: 'bold', color: '#333', width: '50%' }, borderWidth: 1,
groupDescription: { fontSize: responsiveFontSize(1.4), color: '#666', marginVertical: 4, width: '40%' }, },
groupMeta: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 8, borderTopWidth: 1, borderTopColor: '#f0f0f0', paddingTop: 8, marginBottom: 10 }, cardDivider: {
groupMetaText: { fontSize: responsiveFontSize(1.2), color: '#888', fontWeight: '500', textTransform: 'capitalize' }, height: 1,
emptyText: { textAlign: 'center', marginTop: 40, fontSize: responsiveFontSize(1.6) , color: '#888' }, marginVertical: 10,
emptyFeaturedText: { marginLeft: 20, color: '#888' }, },
buttonContainer: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 8 }, cardMeta: {
viewButton: { flex: 1, backgroundColor: '#e9ecef', paddingVertical: 10, borderRadius: 8, alignItems: 'center', marginRight: 5 }, flexDirection: 'row',
viewButtonText: { color: '#333', fontWeight: 'bold', fontSize: responsiveFontSize(1.4) }, justifyContent: 'space-between',
joinButton: { flex: 1, backgroundColor: colors.white, paddingVertical: 10, borderRadius: 8, alignItems: 'center', marginLeft: 5 }, marginBottom: 12,
joinButtonDisabled: { backgroundColor: '#bdc3c7' }, },
joinButtonText: { color: '#333', fontWeight: 'bold', fontSize: responsiveFontSize(1.4) }, metaItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
metaText: {
fontSize: responsiveFontSize(1.3),
fontFamily: FontsFamily.nunitoMedium,
},
joinBtn: {
paddingVertical: 10,
marginVertical: 0,
borderRadius: 10,
},
skeletonList: {
flexDirection: 'column',
gap: 16,
},
emptyState: {
alignItems: 'center',
marginTop: 64,
},
emptyText: {
marginTop: 16,
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoMedium,
},
fab: {
position: 'absolute',
right: 16,
borderRadius: 30,
width: 56,
height: 56,
justifyContent: 'center',
alignItems: 'center',
},
}); });
+263 -138
View File
@@ -1,21 +1,24 @@
import { NativeStackScreenProps } from '@react-navigation/native-stack'; import React, { useEffect } from 'react';
import React, { useState, useEffect, useCallback, FC } from 'react';
import { import {
View, View,
Text, Text,
StyleSheet, StyleSheet,
ScrollView, ScrollView,
TouchableOpacity,
Alert,
ActivityIndicator,
ToastAndroid, ToastAndroid,
} from 'react-native'; } from 'react-native';
import { RootStackParamList } from '../navigation/AppNavigator'; // Adjust path import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { apiCall } from '../utils/ApiHelper'; // Adjust path import { RootStackParamList } from '../navigation/AppNavigator';
import AppView from '../components/AppView'; // Adjust path import { useFetch } from '../hooks/core/useFetch';
import colors from '../components/colors'; // Adjust path import { useCreate } from '../hooks/core/useCreate';
import { BASE_URL } from '../utils/ApiUrls'; import { BASE_URL } from '../utils/ApiUrls';
import AppView from '../components/AppView';
import { useTheme } from '../hooks/ui/useTheme';
import { CustomButton } from '../components/common/Custom';
import SkeletonLoader from '../components/common/SkeletonLoader';
import { responsiveFontSize } from 'react-native-responsive-dimensions'; 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';
// --- Type Definitions --- // --- Type Definitions ---
interface Participant { interface Participant {
@@ -31,179 +34,301 @@ interface GroupDetails {
groupName: string; groupName: string;
description: string; description: string;
contributionAmount: number; contributionAmount: number;
minParticipants: number;
maxParticipants: number; maxParticipants: number;
cycleType: 'QUARTERLY' | 'MONTHLY' | 'WEEKLY' | 'CUSTOM'; entryFee: number;
latePaymentCharge: number;
lateFeeMode: string;
withdrawalMethod: string;
cycleType: 'QUARTERLY' | 'MONTHLY' | 'WEEKLY' | 'CUSTOM' | 'YEARLY' | 'HALF_YEARLY';
customCycleDays: number | null;
status: 'PENDING' | 'ACTIVE' | 'COMPLETED' | 'APPROVED'; status: 'PENDING' | 'ACTIVE' | 'COMPLETED' | 'APPROVED';
organizerFirstName: string; organizerFirstName: string;
organizerLastName: string | null; organizerLastName: string | null;
organizerMobile: string;
organizerEmail: string;
totalParticipants: number; totalParticipants: number;
participants: Participant[]; participants: Participant[];
} }
type InviteScreenProps = NativeStackScreenProps<RootStackParamList, 'Invite'>; // Assuming 'Invite' is in your stack type InviteScreenProps = NativeStackScreenProps<RootStackParamList, 'Invite'>;
// --- Main Screen Component --- const InviteScreen: React.FC<InviteScreenProps> = ({ route, navigation }) => {
const InviteScreen: FC<InviteScreenProps> = ({ route }) => { const { groupId, code } = route.params;
const { groupId,code} = route.params; const theme = useTheme();
const [groupDetails, setGroupDetails] = useState<GroupDetails | null>(null);
const [loading, setLoading] = useState(true);
const [joining, setJoining] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchGroupDetails = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await apiCall(`${BASE_URL}fund/group/details?groupId=${groupId}`, 'GET') as { status: string; data?: GroupDetails; message?: string };
if (response.status === 'success' && response.data) {
setGroupDetails(response.data);
} else {
throw new Error(response.message || 'Failed to fetch group invitation details.');
}
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [groupId]);
useEffect(() => { useEffect(() => {
fetchGroupDetails(); const checkAuth = async () => {
}, [fetchGroupDetails]);
const handleJoinGroup = async () => {
if (!groupDetails) return;
setJoining(true);
try { try {
// Replace with your actual API call to join the group const credentials = await Keychain.getGenericPassword();
// For now, it just shows an alert. if (!credentials) {
await apiCall(`${BASE_URL}fund/groups/join/invite/${code}`, 'POST'); navigation.replace('Login');
ToastAndroid.show(`You have requested to join "${groupDetails.groupName}".`, ToastAndroid.SHORT); }
fetchGroupDetails(); // Refresh details after joining } catch (error) {
} catch (e: any) { console.error('Auth check error:', error);
ToastAndroid.show(e.message || 'An unexpected error occurred.', ToastAndroid.SHORT); navigation.replace('Login');
} finally {
setJoining(false);
} }
}; };
checkAuth();
}, [navigation]);
if (loading) return <View style={styles.centered}><ActivityIndicator size="large" color={colors.primary} /></View>; const { data: detailsRes, isLoading, error } = useFetch<any>(
if (error) return <View style={styles.centered}><Text style={styles.errorText}>{error}</Text></View>; `${BASE_URL}fund/group/details?groupId=${groupId}`
if (!groupDetails) return null; );
console.log(detailsRes, "detailsRes")
const { mutate: joinGroup, isPending: isJoining } = useCreate<any, any>(
`${BASE_URL}fund/groups/join/invite/${code}`
);
const groupDetails: GroupDetails | null = detailsRes?.data || null;
const handleJoin = () => {
if (!groupDetails) return;
joinGroup({}, {
onSuccess: () => {
ToastAndroid.show(`Join request sent for "${groupDetails.groupName}".`, ToastAndroid.SHORT);
navigation.navigate('Home');
},
onError: (err: any) => {
ToastAndroid.show(err.message || 'Failed to join circle', ToastAndroid.SHORT);
},
});
};
if (isLoading) {
return (
<AppView>
<SkeletonLoader width="100%" height={240} style={{ borderRadius: 16, marginBottom: 20 }} />
<SkeletonLoader width="100%" height={160} style={{ borderRadius: 16, marginBottom: 20 }} />
<SkeletonLoader width="100%" height={300} style={{ borderRadius: 16 }} />
</AppView>
);
}
if (error || !groupDetails) {
return (
<AppView>
<View style={styles.errorContainer}>
<LucideIcon name="alert-circle-outline" size={64} color={theme.colors.error} />
<Text style={[styles.errorTitle, { color: theme.colors.textPrimary }]}>Invalid Invitation</Text>
<Text style={[styles.errorSubtitle, { color: theme.colors.textSecondary }]}>
{error instanceof Error ? error.message : 'This invitation link might be expired or invalid.'}
</Text>
<CustomButton title="Back Home" onPress={() => navigation.navigate('Home')} type="outline" />
</View>
</AppView>
);
}
const organizerName = `${groupDetails.organizerFirstName} ${groupDetails.organizerLastName || ''}`.trim(); const organizerName = `${groupDetails.organizerFirstName} ${groupDetails.organizerLastName || ''}`.trim();
const isJoinable = groupDetails.status === 'PENDING' || groupDetails.status === 'APPROVED';
return ( return (
<AppView> <AppView>
<ScrollView contentContainerStyle={styles.contentContainer}> <ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<Text style={styles.headerTitle}>You're Invited!</Text> <View style={styles.header}>
<Text style={styles.headerSubtitle}>Join the group <Text style={{ fontWeight: 'bold' }}>{groupDetails.groupName}</Text></Text> <View style={[styles.inviteBadge, { backgroundColor: theme.colors.primary }]}>
<LucideIcon name="mail-open-outline" size={24} color={theme.colors.onPrimary} />
{/* Group Info Card */} </View>
<View style={styles.card}> <Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}>You're Invited!</Text>
<Text style={styles.cardTitle}>Group Details </Text> <Text style={[styles.headerSubtitle, { color: theme.colors.textSecondary }]}>
<InfoRow label="Organizer" value={organizerName} /> Join <Text style={{ fontFamily: FontsFamily.nunitoBold }}>{groupDetails.groupName}</Text> and start saving together.
<InfoRow label="Contribution" value={`₹${groupDetails.contributionAmount.toLocaleString('en-IN')}`} /> </Text>
<InfoRow label="Payment Cycle" value={groupDetails.cycleType} />
<InfoRow label="Status" value={groupDetails.status} />
<InfoRow label="Code" value={code} />
</View> </View>
{/* Participants Card */} <View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<View style={styles.card}> <Text style={[styles.cardTitle, { color: theme.colors.textPrimary }]}>Circle Overview</Text>
<Text style={styles.cardTitle}>Participants ({groupDetails.totalParticipants} / {groupDetails.maxParticipants})</Text> <InfoRow label="Organizer" value={organizerName} />
<InfoRow label="Contribution" value={`₹${groupDetails.contributionAmount.toLocaleString('en-IN')}`} />
<InfoRow
label="Frequency"
value={groupDetails.cycleType === 'CUSTOM' ? `${groupDetails.customCycleDays} Day(s)` : groupDetails.cycleType}
/>
<InfoRow label="Min/Max Participants" value={`${groupDetails.minParticipants} / ${groupDetails.maxParticipants}`} />
<InfoRow label="Entry Fee" value={`₹${groupDetails.entryFee}`} />
<InfoRow
label="Late Payment Charge"
value={`₹${groupDetails.latePaymentCharge} (${groupDetails.lateFeeMode || 'DAILY'})`}
/>
<InfoRow label="Withdrawal Method" value={groupDetails.withdrawalMethod || 'RANDOM'} />
<InfoRow label="Status" value={groupDetails.status} />
<InfoRow label="Invite Code" value={code} />
</View>
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<Text style={[styles.cardTitle, { color: theme.colors.textPrimary }]}>
Members ({groupDetails.totalParticipants}/{groupDetails.maxParticipants})
</Text>
{groupDetails.participants.length > 0 ? ( {groupDetails.participants.length > 0 ? (
groupDetails.participants.map(p => ( groupDetails.participants.map((p) => (
<View key={p.id} style={styles.participantRow}> <View key={p.id} style={[styles.participantRow, { borderBottomColor: theme.colors.background }]}>
<Text style={styles.participantName}>{`${p.firstName} ${p.lastName || ''}`.trim()}</Text> <View style={styles.participantInfo}>
{p.role === 'ORGANIZER' && <View style={styles.organizerBadge}><Text style={styles.organizerBadgeText}>Organizer</Text></View>} <View style={[styles.avatar, { backgroundColor: theme.colors.background }]}>
<Text style={[styles.avatarText, { color: theme.colors.textSecondary }]}>
{p.firstName.charAt(0)}
</Text>
</View>
<Text style={[styles.participantName, { color: theme.colors.textPrimary }]}>
{`${p.firstName} ${p.lastName || ''}`.trim()}
</Text>
</View>
{p.role === 'ORGANIZER' && (
<View style={[styles.organizerBadge, { backgroundColor: theme.colors.background }]}>
<Text style={[styles.organizerBadgeText, { color: theme.colors.textMuted }]}>Organizer</Text>
</View>
)}
</View> </View>
)) ))
) : ( ) : (
<Text style={styles.noParticipantsText}>Be the first to join!</Text> <Text style={[styles.emptyText, { color: theme.colors.textSecondary }]}>Be the first to join!</Text>
)} )}
</View> </View>
</ScrollView>
{/* Floating Join Button */} <CustomButton
<View style={styles.floatingButtonContainer}> title={isJoinable ? 'Accept Invitation' : `Circle is ${groupDetails.status}`}
<TouchableOpacity onPress={handleJoin}
style={[styles.joinButton, groupDetails.status !== 'PENDING' && groupDetails.status !== 'APPROVED' ? styles.disabledButton : {}]} loading={isJoining}
onPress={handleJoinGroup} disabled={!isJoinable || isJoining}
disabled={joining || (groupDetails.status !== 'PENDING' && groupDetails.status !== 'APPROVED')} style={styles.joinButton}
> />
{joining ? ( </ScrollView>
<ActivityIndicator color="#FFFFFF" />
) : (
<Text style={styles.joinButtonText}>
{groupDetails.status === 'PENDING' || groupDetails.status === 'APPROVED' ? 'Join Group' : `Group is ${groupDetails.status}`}
</Text>
)}
</TouchableOpacity>
</View>
</AppView> </AppView>
); );
}; };
// Helper component for displaying rows const InfoRow = ({ label, value }: { label: string; value: string | number }) => {
const InfoRow = ({ label, value }: { label: string; value: string | number }) => ( const theme = useTheme();
return (
<View style={styles.infoRow}> <View style={styles.infoRow}>
<Text style={styles.infoLabel}>{label}</Text> <Text style={[styles.infoLabel, { color: theme.colors.textSecondary }]}>{label}</Text>
<Text style={styles.infoValue}>{value}</Text> <Text style={[styles.infoValue, { color: theme.colors.textPrimary }]}>{value}</Text>
</View> </View>
); );
};
const styles = StyleSheet.create({ const styles = StyleSheet.create({
centered: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F3F4F6' }, scrollContent: {
errorText: { color: '#EF4444', fontSize: responsiveFontSize(1.2), textAlign: 'center', padding: 20 }, paddingBottom: 40,
contentContainer: { padding: 16, paddingBottom: 100 }, // Add padding to avoid overlap with button
headerTitle: { fontSize: responsiveFontSize(3.2), fontWeight: 'bold', color: '#111827', textAlign: 'center' },
headerSubtitle: { fontSize: responsiveFontSize(1.8), color: '#4B5563', textAlign: 'center', marginBottom: 24, marginTop: 4 },
card: { backgroundColor: '#FFFFFF', borderRadius: 16, padding: 20, marginBottom: 16, shadowColor: '#374151', shadowOpacity: 0.05, shadowRadius: 10 },
cardTitle: { fontSize: responsiveFontSize(1.8), fontWeight: 'bold', color: '#1F2937', marginBottom: 12, borderBottomWidth: 1, borderBottomColor: '#E5E7EB', paddingBottom: 10 },
infoRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 8 },
infoLabel: { fontSize: responsiveFontSize(1.6), color: '#6B7280' },
infoValue: { fontSize: responsiveFontSize(1.6), fontWeight: '600', color: '#111827', textTransform: 'capitalize' },
participantRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 10 },
participantName: { fontSize: responsiveFontSize(1.6), color: '#1F2937' },
organizerBadge: { backgroundColor: '#FEF3C7', paddingHorizontal: 8, paddingVertical: 3, borderRadius: 12 },
organizerBadgeText: { color: '#92400E', fontSize: responsiveFontSize(1.2), fontWeight: '500' },
noParticipantsText: { fontSize: responsiveFontSize(1.6), color: '#6B7280', textAlign: 'center', paddingVertical: 10 },
floatingButtonContainer: {
position: 'absolute',
bottom: 20,
left: 20,
right: 20,
alignItems: 'center',
}, },
joinButton: { header: {
backgroundColor: colors.primary, alignItems: 'center',
borderRadius: 14, marginBottom: 32,
paddingVertical: 16, marginTop: 16,
paddingHorizontal: 32, },
elevation: 5, inviteBadge: {
width: 60,
height: 60,
borderRadius: 30,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3, shadowOpacity: 0.1,
shadowRadius: 4, shadowRadius: 8,
width: '80%', elevation: 4,
},
headerTitle: {
fontSize: responsiveFontSize(3.2),
fontFamily: FontsFamily.nunitoBold,
},
headerSubtitle: {
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoRegular,
textAlign: 'center',
marginTop: 8,
paddingHorizontal: 20,
lineHeight: 24,
},
card: {
borderRadius: 16,
borderWidth: 1,
padding: 20,
marginBottom: 16,
},
cardTitle: {
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoBold,
marginBottom: 16,
},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 10,
},
infoLabel: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoMedium,
},
infoValue: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoBold,
textTransform: 'capitalize',
},
participantRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
borderBottomWidth: 1,
},
participantInfo: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
avatar: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
joinButtonText: { avatarText: {
color: '#FFFFFF', fontSize: 14,
fontSize: responsiveFontSize(1.8), fontFamily: FontsFamily.nunitoBold,
fontWeight: 'bold',
}, },
disabledButton: { participantName: {
backgroundColor: '#9CA3AF', // Gray color when disabled fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoMedium,
},
organizerBadge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 8,
},
organizerBadgeText: {
fontSize: 10,
fontFamily: FontsFamily.nunitoBold,
textTransform: 'uppercase',
},
emptyText: {
textAlign: 'center',
paddingVertical: 20,
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoMedium,
},
joinButton: {
marginTop: 16,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 32,
},
errorTitle: {
fontSize: 24,
fontFamily: FontsFamily.nunitoBold,
marginTop: 16,
},
errorSubtitle: {
fontSize: 16,
fontFamily: FontsFamily.nunitoRegular,
textAlign: 'center',
marginTop: 8,
marginBottom: 32,
}, },
}); });
export default InviteScreen; export default InviteScreen;
+102 -251
View File
@@ -1,328 +1,179 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { import {
View, View,
Text, Text,
TextInput,
StyleSheet, StyleSheet,
TouchableOpacity, Animated,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
Animated,
StatusBar,
ActivityIndicator,
Dimensions,
} from 'react-native'; } from 'react-native';
import { LOGIN_URL } from '../utils/ApiUrls';
import axios from 'axios';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import type { StackNavigationProp } from '@react-navigation/stack'; import { StackNavigationProp } from '@react-navigation/stack';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { RootStackParamList } from '../navigation/AppNavigator'; import { RootStackParamList } from '../navigation/AppNavigator';
import LinearGradient from 'react-native-linear-gradient'; import { useTheme } from '../hooks/ui/useTheme';
import Ionicons from '@react-native-vector-icons/ionicons'; import { useFetch } from '../hooks/core/useFetch';
import MaterialRipple from 'react-native-material-ripple'; import { LOGIN_URL } from '../utils/ApiUrls';
import CustomAlert from '../components/CostomAlart'; import { CustomInput, CustomButton } from '../components/common/Custom';
import colors from '../components/colors'; import AppView from '../components/AppView';
import {
responsiveHeight,
responsiveWidth,
responsiveFontSize
} from "react-native-responsive-dimensions";
import { useTheme } from '../theme/ThemeProvider';
import FontsFamily from '../constents/FontsFamily'; import FontsFamily from '../constents/FontsFamily';
const { width } = Dimensions.get('window');
const BLOCK_SIZE = width / 3;
// Note: You will need to install react-native-material-ripple
// npm install react-native-material-ripple
export default function LoginScreen() { export default function LoginScreen() {
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>(); const navigation = useNavigation<StackNavigationProp<RootStackParamList>>();
const theme = useTheme();
const [mobile, setMobile] = useState(''); const [mobile, setMobile] = useState('');
const [loading, setLoading] = useState(false); const [error, setError] = useState('');
const [alertInfo, setAlertInfo] = useState({
visible: false,
title: '',
message: '',
buttons: [] as { text: string; onPress: () => void; style?: 'cancel' | 'destructive' | 'default' }[],
alertType: 'info' as 'info' | 'success' | 'error',
});
const fade = useRef(new Animated.Value(0)).current;
const slide = useRef(new Animated.Value(30)).current;
const ctaScale = useRef(new Animated.Value(1)).current;
const { theme } = useTheme();
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(20)).current;
useEffect(() => { useEffect(() => {
Animated.parallel([ Animated.parallel([
Animated.timing(fade, { toValue: 1, duration: 800, useNativeDriver: true }), Animated.timing(fadeAnim, {
Animated.spring(slide, { toValue: 0, friction: 5, tension: 40, useNativeDriver: true }), toValue: 1,
duration: 800,
useNativeDriver: true,
}),
Animated.spring(slideAnim, {
toValue: 0,
tension: 20,
useNativeDriver: true,
}),
]).start(); ]).start();
}, []); }, [fadeAnim, slideAnim]);
const showAlert = (
title: string,
message: string,
buttons: { text: string; onPress: () => void; style?: 'cancel' | 'destructive' | 'default' }[],
alertType: 'info' | 'success' | 'error' = 'info',
) => {
setAlertInfo({ visible: true, title, message, buttons, alertType });
};
const hideAlert = () => {
setAlertInfo({ ...alertInfo, visible: false });
};
const { refetch: sendOtp, isFetching: loading } = useFetch<any>(
LOGIN_URL,
{ mobile },
{
enabled: false,
retry: false,
}
);
const handleContinue = async () => { const handleContinue = async () => {
if (mobile.length === 10) { if (!/^\d{10}$/.test(mobile)) {
setLoading(true); setError('Please enter a 10-digit mobile number');
return;
}
setError('');
try { try {
const res = await axios.get(LOGIN_URL, { params: { mobile } }); const { data } = await sendOtp();
console.log("login res -",res) if (data?.statusCode === 200) {
if (res.data.statusCode === 200) {
navigation.navigate('OtpVarify', { mobile }); navigation.navigate('OtpVarify', { mobile });
} else { } else {
showAlert('Login Failed', res.data.message || 'Could not send OTP.', [{ text: 'Try Again', onPress: hideAlert }], 'error'); setError(data?.message || 'Failed to send OTP. Please try again.');
} }
} catch (error: any) { } catch (err) {
showAlert('An Error Occurred', 'Failed to send OTP. Please check your connection and try again.', [{ text: 'OK', onPress: hideAlert }], 'error'); setError('Something went wrong. Please check your connection.');
} finally {
setLoading(false);
}
} else {
showAlert('Invalid Number', 'Please enter a valid 10-digit mobile number.', [{ text: 'Got it', onPress: hideAlert }], 'info');
} }
}; };
const colorsBlocks = [
theme.colors.logincard,
theme.colors.logincard1,
theme.colors.logincard1,
theme.colors.logincard,
theme.colors.logincard,
theme.colors.logincard1,
theme.colors.logincard1,
theme.colors.logincard,
theme.colors.logincard,
];
return ( return (
<View style={[ styles.container, {backgroundColor: theme.colors.background }]}> <AppView keyboardSafe={false} style={styles.container}>
<StatusBar barStyle={theme.mode === "dark" ? "light-content" : "dark-content"} backgroundColor={colors.pastelOrange} />
{/* Funky grid blocks background (Unchanged) */}
<View style={styles.gridContainer}>
{colorsBlocks.map((bgColor, index) => (
<View
key={index}
style={[
styles.gridBlock,
{ backgroundColor: bgColor ,borderRadius:16 },
index % 2 === 0 ? styles.elevatedBlock : {},
]}
/>
))}
</View>
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.keyboardAvoidingContainer}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
> >
<View style={styles.contentContainer}> <Animated.View
<View style={[ styles.topSection, {marginTop:60}]}> style={[
<Animated.View style={{ opacity: fade, transform: [{ translateY: slide }] }}> styles.content,
<Text style={[styles.brand, { color: theme.colors.textPrimary }]}>CircleCollect</Text> {
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Welcome Back!</Text> opacity: fadeAnim,
transform: [{ translateY: slideAnim }],
},
]}
>
<View style={styles.headerSection}>
<Text style={[styles.brand, { color: theme.colors.primary }]}>CircleCollect</Text>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Welcome back</Text>
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}> <Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
Let's get you signed in to your community. Enter your mobile number to sign in to your community.
</Text> </Text>
</Animated.View>
</View> </View>
<View style={styles.formSection}>
<Animated.View style={{ opacity: fade, transform: [{ translateY: slide }] ,marginTop: 150}}> <CustomInput
<View style={[styles.inputWrapper, { backgroundColor: theme.colors.reverseBackground }]}> label="Mobile Number"
<Ionicons name="call-outline" size={24} color={theme.colors.reverseText} style={styles.inputIcon} /> placeholder="e.g. 9876543210"
<TextInput
style={[ styles.input, { color: theme.colors.reverseText, fontFamily: FontsFamily.nunitoRegular }]}
placeholder="Enter your mobile number"
placeholderTextColor={theme.colors.reverseText}
value={mobile} value={mobile}
onChangeText={setMobile} onChangeText={(text) => {
setMobile(text.replace(/\D/g, ''));
if (error) setError('');
}}
keyboardType="phone-pad" keyboardType="phone-pad"
maxLength={10} maxLength={10}
selectionColor={colors.textLight} icon="call-outline"
error={error}
/> />
</View>
</Animated.View>
<CustomButton
<Animated.View style={[styles.bottomSection, { transform: [{ scale: ctaScale }] }]}> title="Continue"
<MaterialRipple
onPress={handleContinue} onPress={handleContinue}
disabled={loading} loading={loading}
rippleColor={colors.white} icon="arrow-forward-outline"
style={styles.button} style={styles.button}
rippleCentered
rippleContainerBorderRadius={30}
>
<View style={[styles.buttonGradient, { backgroundColor: theme.colors.reverseBackground }]} >
{loading ? (
<ActivityIndicator color={theme.colors.reverseText} />
) : (
<View style={styles.buttonContent}>
<Text style={[styles.buttonText, { color: theme.colors.reverseText }]}>Continue</Text>
<Ionicons name="arrow-forward-circle-outline" size={22} color={theme.colors.reverseText} style={{marginLeft: 8}} />
</View>
)}
</View>
</MaterialRipple>
<Text style={styles.footerText}>
Your privacy is our priority. Your information is always secure.
</Text>
</Animated.View>
</View>
</KeyboardAvoidingView>
<CustomAlert
visible={alertInfo.visible}
title={alertInfo.title}
message={alertInfo.message}
buttons={alertInfo.buttons}
alertType={alertInfo.alertType}
/> />
</View> </View>
<View style={styles.footerSection}>
<Text style={[styles.footerText, { color: theme.colors.textMuted }]}>
Your information is secure and private.
</Text>
</View>
</Animated.View>
</KeyboardAvoidingView>
</AppView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
gridContainer: { keyboardView: {
...StyleSheet.absoluteFillObject,
flexDirection: 'row',
flexWrap: 'wrap',
},
gridBlock: {
width: BLOCK_SIZE,
height: BLOCK_SIZE,
margin: 2,
borderRadius: 16,
},
elevatedBlock: {
shadowColor: '#ffffffff',
shadowOffset: { width: 1, height: 5 },
shadowOpacity: 1,
shadowRadius: 15,
elevation: 10,
},
keyboardAvoidingContainer: {
flex: 1, flex: 1,
},
contentContainer: {
flex: 1,
justifyContent: 'space-between',
padding: 24,
},
topSection: {
flex: 0.8,
justifyContent: 'center', justifyContent: 'center',
},
content: {
paddingHorizontal: 12,
},
headerSection: {
alignItems: 'center', alignItems: 'center',
marginBottom: 48,
}, },
brand: { brand: {
fontSize: responsiveFontSize(3), fontSize: responsiveFontSize(2.2),
color: colors.primary,
textAlign: 'center',
letterSpacing: 2,
marginBottom: 16,
textShadowColor: 'rgba(0, 0, 0, 0.1)',
textShadowOffset: { width: 1, height: 1 },
textShadowRadius: 2,
fontFamily: FontsFamily.nunitoExtraBold, fontFamily: FontsFamily.nunitoExtraBold,
letterSpacing: 1.5,
textTransform: 'uppercase',
marginBottom: 16,
}, },
title: { title: {
fontSize: responsiveFontSize(5), fontSize: responsiveFontSize(3.5),
fontFamily: FontsFamily.nunitoBold, fontFamily: FontsFamily.nunitoBold,
marginBottom: 8,
textAlign: 'center',
marginBottom: 14,
}, },
subtitle: { subtitle: {
fontSize: responsiveFontSize(1.6), fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular, fontFamily: FontsFamily.nunitoRegular,
textAlign: 'center', textAlign: 'center',
paddingHorizontal: 24,
lineHeight: 26, lineHeight: 24,
maxWidth: 320,
}, },
inputWrapper: { formSection: {
flexDirection: 'row', marginBottom: 32,
alignItems: 'center',
borderRadius: 36,
borderWidth: 2,
paddingHorizontal: responsiveHeight(2),
elevation: 8,
},
inputIcon: {
marginRight: 16,
},
input: {
flex: 1,
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoSemiBold,
color: colors.textLight,
height: responsiveHeight(5),
},
bottomSection: {
alignItems: 'center',
paddingBottom: Platform.OS === 'ios' ? 20 : 10,
}, },
button: { button: {
width: '100%', marginTop: 16,
borderRadius: 30,
elevation: 14,
marginTop: 20,
}, },
buttonGradient: { footerSection: {
borderRadius:42,
paddingVertical: 14,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
buttonContent: {
flexDirection: 'row',
alignItems: 'center',
},
buttonText: {
color: colors.white,
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoBold,
},
footerText: { footerText: {
fontSize: responsiveFontSize(1.4), fontSize: responsiveFontSize(1.3),
color: 'rgba(0,0,0,0.5)',
marginTop: 28,
textAlign: 'center',
maxWidth: 300,
fontFamily: FontsFamily.nunitoRegular, fontFamily: FontsFamily.nunitoRegular,
textAlign: 'center',
}, },
}); });
+140 -113
View File
@@ -1,154 +1,181 @@
import React, { useState } from "react"; import React, { useState } from 'react';
import { import {
View,
Text,
TextInput,
Pressable,
StyleSheet, StyleSheet,
Alert,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
ScrollView, ScrollView,
ToastAndroid, ToastAndroid,
} from "react-native"; View,
import axios from "axios"; Text,
import { ORGRAGISTRATION } from "../utils/ApiUrls"; } from 'react-native';
import * as Keychain from "react-native-keychain"; import { useNavigation } from '@react-navigation/native';
import { responsiveFontSize } from "react-native-responsive-dimensions"; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useTheme } from '../hooks/ui/useTheme';
import { useCreate } from '../hooks/core/useCreate';
import { ORGRAGISTRATION } from '../utils/ApiUrls';
import { CustomInput, CustomButton } from '../components/common/Custom';
import FontsFamily from '../constents/FontsFamily';
import AppView from '../components/AppView';
const theme = { type OrganizerForm = {
primary: "#111", bankAccountNumber: string;
accent: "#FF7A00", bankName: string;
background: "#F9F9F9", branchCode: string;
text: "#1A1A1A",
subText: "#6B6B6B",
outline: "#E5E5E5",
}; };
type OrganizerFormErrors = Partial<Record<keyof OrganizerForm, string>>;
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());
export default function OrganizerRegisterScreen() { export default function OrganizerRegisterScreen() {
const [bankAccountNumber, setBankAccountNumber] = useState(""); const theme = useTheme();
const [bankName, setBankName] = useState(""); const navigation = useNavigation<any>();
const [branchCode, setBranchCode] = useState("");
const [loading, setLoading] = useState(false); const [form, setForm] = useState<OrganizerForm>({
const handleSubmit = async () => { bankAccountNumber: '',
if (!bankAccountNumber || !bankName || !branchCode) { bankName: '',
ToastAndroid.show('All fields are required.', ToastAndroid.SHORT); branchCode: '',
return; });
const [errors, setErrors] = useState<OrganizerFormErrors>({});
const { mutate: register, isPending: isLoading } = useCreate<any, any>(ORGRAGISTRATION);
const handleChange = (field: keyof OrganizerForm, value: string) => {
let nextValue = value;
if (field === 'bankAccountNumber') nextValue = value.replace(/\D/g, '');
if (field === 'branchCode') nextValue = value.toUpperCase();
setForm(prev => ({ ...prev, [field]: nextValue }));
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
} }
try {
setLoading(true);
// 🔑 get token from Keychain
const creds = await Keychain.getGenericPassword();
const token = creds ? creds.password : null;
if (!token) {
ToastAndroid.show('No token found. Please log in again.', ToastAndroid.SHORT);
setLoading(false);
return;
}
const payload = {
bankAccountNumber,
bankName,
branchCode,
}; };
const res = await axios.post( const validateForm = () => {
ORGRAGISTRATION, // your API endpoint const nextErrors: OrganizerFormErrors = {};
payload,
{ if (!isValidBankName(form.bankName)) {
headers: { nextErrors.bankName = 'Enter a valid bank name (min 2 letters).';
"Content-Type": "application/json", }
Authorization: `Bearer ${token}`, // 👈 token added here if (!isValidAccountNumber(form.bankAccountNumber)) {
nextErrors.bankAccountNumber = 'Account number must be 9-18 digits.';
}
if (!isValidIfsc(form.branchCode)) {
nextErrors.branchCode = 'Enter a valid IFSC (e.g. HDFC0001234).';
}
setErrors(nextErrors);
return Object.keys(nextErrors).length === 0;
};
const handleSubmit = () => {
if (!validateForm()) {
ToastAndroid.show('Please fix the highlighted fields.', ToastAndroid.SHORT);
return;
}
register(form, {
onSuccess: () => {
ToastAndroid.show('You are now registered as an Organizer!', ToastAndroid.SHORT);
navigation.navigate('Home');
}, },
} onError: (err) => {
); ToastAndroid.show(err.message || 'Registration failed', ToastAndroid.SHORT);
},
console.log("✅ Organizer Registered:", res.data); });
ToastAndroid.show('Organizer registered successfully!', ToastAndroid.SHORT); };
// Optionally, reset form fields
setBankAccountNumber("");
setBankName("");
setBranchCode("");
} catch (err: any) {
console.error("❌ Register Error:", err?.response?.data || err.message);
} finally {
setLoading(false);
}
};
return ( return (
<AppView keyboardSafe={false}>
<KeyboardAvoidingView <KeyboardAvoidingView
style={{ flex: 1, backgroundColor: theme.background }} style={{ flex: 1 }}
behavior={Platform.OS === "ios" ? "padding" : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
> >
<ScrollView contentContainerStyle={styles.container}> <ScrollView
<Text style={styles.title}>Organizer Registration 📝</Text> contentContainerStyle={styles.container}
showsVerticalScrollIndicator={false}
>
<View style={styles.header}>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Organizer Registration</Text>
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
Provide your banking details to start managing your own circles.
</Text>
</View>
<TextInput <View style={[styles.form, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
placeholder="Bank Account Number" <CustomInput
style={styles.input} label="Bank Name"
value={bankAccountNumber} placeholder="Enter your bank name"
onChangeText={setBankAccountNumber} value={form.bankName}
onChangeText={(val) => handleChange('bankName', val)}
icon="business-outline"
error={errors.bankName}
/>
<CustomInput
label="Account Number"
placeholder="Enter bank account number"
value={form.bankAccountNumber}
onChangeText={(val) => handleChange('bankAccountNumber', val)}
keyboardType="numeric" keyboardType="numeric"
maxLength={18}
icon="card-outline"
error={errors.bankAccountNumber}
/> />
<TextInput <CustomInput
placeholder="Bank Name" label="Branch Code / IFSC"
style={styles.input} placeholder="Enter branch code"
value={bankName} value={form.branchCode}
onChangeText={setBankName} onChangeText={(val) => handleChange('branchCode', val)}
autoCapitalize="characters"
maxLength={11}
icon="code-working-outline"
error={errors.branchCode}
/> />
</View>
<TextInput <CustomButton
placeholder="Branch Code" title="Complete Registration"
style={styles.input} onPress={handleSubmit}
value={branchCode} loading={isLoading}
onChangeText={setBranchCode} disabled={isLoading}
style={styles.button}
/> />
<Pressable style={styles.button} onPress={handleSubmit} disabled={loading}>
<Text style={styles.buttonText}>{loading ? "Submitting..." : "Submit"}</Text>
</Pressable>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</AppView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flexGrow: 1, flexGrow: 1,
justifyContent: "center", paddingBottom: 40,
padding: 20, },
header: {
marginBottom: 32,
marginTop: 16,
}, },
title: { title: {
fontSize: responsiveFontSize(2.8), fontSize: responsiveFontSize(3.2),
fontWeight: "700", fontFamily: FontsFamily.nunitoBold,
color: theme.text,
marginBottom: 20,
textAlign: "center",
}, },
input: { subtitle: {
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 8,
lineHeight: 24,
},
form: {
borderRadius: 20,
borderWidth: 1, borderWidth: 1,
borderColor: theme.outline, padding: 24,
borderRadius: 12, marginBottom: 24,
padding: 14,
marginBottom: 15,
fontSize: responsiveFontSize(1.6),
backgroundColor: "#fff",
}, },
button: { button: {
backgroundColor: theme.primary, marginTop: 'auto',
paddingVertical: 16,
borderRadius: 12,
alignItems: "center",
},
buttonText: {
color: "#fff",
fontWeight: "600",
fontSize: responsiveFontSize(1.6),
}, },
}); });
+198 -340
View File
@@ -1,396 +1,254 @@
import { CommonActions, RouteProp, useNavigation, useRoute } from "@react-navigation/native"; import React, { useState, useEffect, useRef } from 'react';
import axios from "axios";
import React, { useEffect, useRef, useState } from "react";
import { import {
View, View,
Text, Text,
StyleSheet, StyleSheet,
TextInput, TextInput,
Pressable,
Animated, Animated,
Platform, Platform,
NativeSyntheticEvent,
TextInputKeyPressEventData,
KeyboardAvoidingView, KeyboardAvoidingView,
StatusBar, ToastAndroid,
Dimensions, TouchableOpacity,
} from "react-native"; } from 'react-native';
import { RootStackParamList } from "../navigation/AppNavigator"; import { useNavigation, useRoute, RouteProp, CommonActions } from '@react-navigation/native';
import * as Keychain from "react-native-keychain"; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { LOGIN_URL, VARIFY_OTP_URL } from "../utils/ApiUrls"; import * as Keychain from 'react-native-keychain';
import colors from "../components/colors"; import { RootStackParamList } from '../navigation/AppNavigator';
import CustomAlert from "../components/CostomAlart"; import { useTheme } from '../hooks/ui/useTheme';
import { getFcmToken } from "../utils/firebase/firebaseServices"; import { useCreate } from '../hooks/core/useCreate';
import { useTheme } from "../theme/ThemeProvider"; import { LOGIN_URL, VARIFY_OTP_URL } from '../utils/ApiUrls';
import FontsFamily from "../constents/FontsFamily"; import { getFcmToken } from '../utils/firebase/firebaseServices';
import { CustomButton } from '../components/common/Custom';
import AppView from '../components/AppView';
import FontsFamily from '../constents/FontsFamily';
const CELL_COUNT = 6; const CELL_COUNT = 6;
const { width } = Dimensions.get('window'); type OtpVerifyRouteProp = RouteProp<RootStackParamList, 'OtpVarify'>;
const BLOCK_SIZE = width / 3;
type OtpVerifyRouteProp = RouteProp<RootStackParamList, "OtpVarify">;
interface AlertButton {
text: string;
onPress: () => void;
style?: "cancel" | "destructive" | "default";
}
export default function OtpVerificationScreen() { export default function OtpVerificationScreen() {
const [code, setCode] = useState<string[]>(Array(CELL_COUNT).fill("")); const theme = useTheme();
const [timer, setTimer] = useState(30); const navigation = useNavigation<any>();
const [isResending, setIsResending] = useState(false);
const [alertConfig, setAlertConfig] = useState<{
visible: boolean;
title: string;
message: string;
buttons: AlertButton[];
alertType: 'error' | 'success' | 'info';
}>({
visible: false,
title: "",
message: "",
buttons: [],
alertType: "info",
});
const { theme } = useTheme();
const inputs = useRef<Array<TextInput | null>>([]);
const route = useRoute<OtpVerifyRouteProp>(); const route = useRoute<OtpVerifyRouteProp>();
const { mobile } = route.params; const { mobile } = route.params;
const navigation = useNavigation<any>();
// Refs for auto-closing logic const [code, setCode] = useState<string[]>(Array(CELL_COUNT).fill(''));
const successActionRef = useRef<(() => void) | null>(null); const [timer, setTimer] = useState(30);
const successTimerRef = useRef<number | null>(null); const inputs = useRef<Array<TextInput | null>>([]);
const fade = useRef(new Animated.Value(0)).current; const fadeAnim = useRef(new Animated.Value(0)).current;
const slide = useRef(new Animated.Value(30)).current; const slideAnim = useRef(new Animated.Value(20)).current;
const ctaScale = useRef(new Animated.Value(1)).current;
useEffect(() => { useEffect(() => {
Animated.parallel([ Animated.parallel([
Animated.timing(fade, { toValue: 1, duration: 800, useNativeDriver: true }), Animated.timing(fadeAnim, {
Animated.spring(slide, { toValue: 0, friction: 5, tension: 40, useNativeDriver: true }), toValue: 1,
duration: 800,
useNativeDriver: true,
}),
Animated.spring(slideAnim, {
toValue: 0,
tension: 20,
useNativeDriver: true,
}),
]).start(); ]).start();
const interval = setInterval(() => {
setTimer((prev) => (prev > 0 ? prev - 1 : 0));
}, 1000);
return () => clearInterval(interval);
}, []); }, []);
useEffect(() => { const { mutate: verifyOtp, isPending: verifying } = useCreate<any, any>(VARIFY_OTP_URL, {
if (timer <= 0) return; onSuccess: async (res) => {
const interval = setInterval(() => setTimer((prev) => prev - 1), 1000); if (res?.statusCode === 200) {
return () => clearInterval(interval); const { jwt, userId } = res?.data || {};
}, [timer]); await Keychain.setGenericPassword('auth', jwt, {
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED,
const hideAlert = () => {
if (successTimerRef.current) {
clearTimeout(successTimerRef.current);
successTimerRef.current = null;
}
setAlertConfig(prev => ({ ...prev, visible: false }));
};
const triggerAlert = (
title: string,
message: string,
buttons: AlertButton[],
alertType: 'error' | 'success' | 'info' = 'info',
autoCloseAction?: () => void
) => {
setAlertConfig({ visible: true, title, message, buttons, alertType });
if (alertType === 'success' && autoCloseAction) {
successActionRef.current = autoCloseAction;
successTimerRef.current = setTimeout(() => {
successActionRef.current?.();
}, 2000);
}
};
const saveToken = async (token: string) => {
try {
await Keychain.setGenericPassword("auth", token, { accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED });
console.log("✅ Token saved securely");
} catch (error) {
console.error("❌ Error saving token:", error);
}
};
const onSubmit = async (otp: string, mobile: string) => {
if (otp.length !== CELL_COUNT) {
triggerAlert(
'Incomplete Code',
'Please enter all 6 digits of the OTP.',
[{ text: 'OK', onPress: hideAlert }],
'error'
);
return;
}
try {
const res = await axios.post(VARIFY_OTP_URL, { mobile, otp }, {
headers: { "Content-Type": "application/json", Accept: "application/json" },
}); });
await getFcmToken(userId);
if (res.data?.statusCode === 200) { ToastAndroid.show('Verified successfully', ToastAndroid.SHORT);
const { jwt } = res.data?.data || {};
console.log("✅ OTP Verified:", res.data);
await saveToken(jwt);
await getFcmToken(res.data?.data?.userId);
let hasActionRun = false;
const successAction = () => {
if (hasActionRun) return;
hasActionRun = true;
hideAlert();
navigation.dispatch( navigation.dispatch(
CommonActions.reset({ CommonActions.reset({
index: 0, index: 0,
routes: [{ name: 'Home' }], routes: [{ name: 'Home' }],
}) })
); );
};
triggerAlert(
'Success!',
'OTP verified successfully. Redirecting...',
[{ text: 'OK', onPress: successAction }],
'success',
successAction
);
}
} catch (err: any) {
console.error("❌ OTP Verification Error:", err?.response?.data || err.message);
triggerAlert(
'Verification Failed',
err?.response?.data?.message || "Invalid OTP. Please try again.",
[{ text: 'Try Again', onPress: hideAlert, style: 'cancel' }],
'error'
);
}
};
const resendOtp = async () => {
try {
setIsResending(true);
setTimer(30);
await axios.get(LOGIN_URL, { params: { mobile } })
setCode(Array(CELL_COUNT).fill(""));
inputs.current[0]?.focus();
let hasActionRun = false;
const successAction = () => {
if (hasActionRun) return;
hasActionRun = true;
hideAlert();
};
triggerAlert(
'OTP Sent',
'A new code has been sent to your mobile number.',
[{ text: 'OK', onPress: successAction }],
'success',
successAction
);
} catch (err: any) {
console.error("❌ Resend OTP Error:", err?.response?.data || err.message);
triggerAlert(
'Error',
'Failed to resend OTP. Try again later.',
[{ text: 'OK', onPress: hideAlert, style: 'cancel' }],
'error'
);
} finally {
setIsResending(false);
}
};
const submitIfComplete = (arr: string[]) => {
if (arr.join("").length === CELL_COUNT) {
onSubmit(arr.join(""), mobile);
}
};
const updateIndex = (idx: number, val: string) => {
const v = val.replace(/[^0-9]/g, "");
const next = [...code];
next[idx] = v.slice(-1);
setCode(next);
if (v && idx < CELL_COUNT - 1) inputs.current[idx + 1]?.focus();
submitIfComplete(next);
};
const handleFirstCellChange = (val: string) => {
const digits = val.replace(/\D/g, "");
if (digits.length <= 1) {
updateIndex(0, digits);
return;
}
const next = Array(CELL_COUNT).fill("").map((_, i) => digits[i] ?? "");
setCode(next);
const firstEmpty = next.findIndex((c) => !c);
if (firstEmpty >= 0) {
inputs.current[firstEmpty]?.focus();
} else { } else {
inputs.current[CELL_COUNT - 1]?.blur(); ToastAndroid.show(res?.message || 'Invalid OTP', ToastAndroid.SHORT);
} }
submitIfComplete(next); },
}; onError: () => {
ToastAndroid.show('Verification failed. Please try again.', ToastAndroid.SHORT);
},
});
const handleKeyPress = (idx: number, e: NativeSyntheticEvent<TextInputKeyPressEventData>) => { const { mutate: resendOtp, isPending: resending } = useCreate<any, any>(LOGIN_URL, {
if (e.nativeEvent.key === "Backspace" && !code[idx] && idx > 0) { onSuccess: () => {
inputs.current[idx - 1]?.focus(); 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 handleTextChange = (text: string, index: number) => {
const newCode = [...code];
newCode[index] = text.slice(-1);
setCode(newCode);
if (text && index < CELL_COUNT - 1) {
inputs.current[index + 1]?.focus();
}
if (newCode.every((cell) => cell !== '')) {
verifyOtp({ mobile, otp: newCode.join('') });
} }
}; };
const colorsBlocks = [ const handleKeyPress = (e: any, index: number) => {
theme.colors.logincard, if (e.nativeEvent.key === 'Backspace' && !code[index] && index > 0) {
theme.colors.logincard1, inputs.current[index - 1]?.focus();
theme.colors.logincard1, }
theme.colors.logincard, };
theme.colors.logincard,
theme.colors.logincard1,
theme.colors.logincard1,
theme.colors.logincard,
theme.colors.logincard,
];
return ( return (
<View style={[styles.container, {backgroundColor: theme.colors.background }]}> <AppView keyboardSafe={false} style={styles.container}>
<StatusBar barStyle={theme.mode === "dark" ? "light-content" : "dark-content"} backgroundColor={colors.pastelOrange} /> <KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
<View style={styles.gridContainer}> style={styles.keyboardView}
{colorsBlocks.map((bgColor, index) => (
<View
key={index}
style={[
styles.gridBlock,
{ backgroundColor: bgColor, borderRadius: 16 },
index % 2 === 0 ? styles.elevatedBlock : {},
]}
/>
))}
</View>
<KeyboardAvoidingView style={styles.keyboardAvoidingContainer} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<CustomAlert {...alertConfig} />
<View style={[styles.contentPanel, { backgroundColor: theme.colors.reverseBackground }]}>
<View style={styles.topSection}>
<Text style={[styles.title, { color: theme.colors.reverseText }]}>Verify Your Code</Text>
<Text style={[styles.subtitle, { color: theme.colors.reverseText }]}>We’ve sent a 6-digit verification code to +91 {mobile}.</Text>
<Animated.View style={[styles.otpRow, { transform: [{ translateY: slide }] }]}>
{Array.from({ length: CELL_COUNT }).map((_, i) => (
<TextInput
key={`otp-${i}`}
ref={(r) => { inputs.current[i] = r; }}
// UPDATED CELL STYLE
style={[
styles.cell,
code[i] ? styles.cellFilled : null,
{
backgroundColor: theme.colors.optCard,
color: theme.colors.reverseText,
}
]}
keyboardType="number-pad"
maxLength={i === 0 ? CELL_COUNT : 1}
value={code[i]}
onChangeText={(t) => (i === 0 ? handleFirstCellChange(t) : updateIndex(i, t))}
onKeyPress={(e) => handleKeyPress(i, e)}
placeholder="*"
placeholderTextColor="#BDBDBD"
textContentType="oneTimeCode"
/>
))}
</Animated.View>
</View>
<Animated.View style={[styles.bottomSection, { transform: [{ scale: ctaScale }] }]}>
<Pressable
onPressIn={() => Animated.spring(ctaScale, { toValue: 0.97, useNativeDriver: true }).start()}
onPressOut={() => Animated.spring(ctaScale, { toValue: 1, useNativeDriver: true }).start()}
onPress={() => onSubmit(code.join(""), mobile)}
style={[styles.button, { backgroundColor: theme.colors.background }]}
> >
<Text style={[styles.buttonText, { color: theme.colors.textPrimary }]}>Verify & Continue</Text> <Animated.View
</Pressable> style={[
styles.content,
{timer > 0 ? ( {
<Text style={[styles.footerText, { color: theme.colors.reverseText }]}>Resend code in 00:{timer.toString().padStart(2, "0")}</Text> opacity: fadeAnim,
) : ( transform: [{ translateY: slideAnim }],
<Pressable onPress={resendOtp} disabled={isResending}> },
<Text style={[styles.footerText, { color: theme.colors.reverseText, fontWeight: '600' }]}> ]}
{isResending ? "Resending..." : "Resend Code"} >
<View style={styles.headerSection}>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Verification</Text>
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
Enter the 6-digit code sent to {'\n'}
<Text style={{ color: theme.colors.textPrimary, fontWeight: '700' }}>+91 {mobile}</Text>
</Text> </Text>
</Pressable> </View>
<View style={styles.otpSection}>
{code.map((digit, index) => (
<TextInput
key={index}
ref={(ref) => { inputs.current[index] = ref }}
style={[
styles.otpInput,
{
backgroundColor: theme.colors.surface,
color: theme.colors.textPrimary,
borderColor: digit ? theme.colors.primary : theme.colors.border,
},
]}
keyboardType="number-pad"
maxLength={1}
value={digit}
placeholder="0"
placeholderTextColor={theme.colors.textMuted}
onChangeText={(text) => handleTextChange(text, index)}
onKeyPress={(e) => handleKeyPress(e, index)}
selectionColor={theme.colors.primary}
/>
))}
</View>
<View style={styles.actionSection}>
<CustomButton
title="Verify Code"
onPress={() => verifyOtp({ mobile, otp: code.join('') })}
loading={verifying}
style={styles.button}
/>
<View style={styles.resendContainer}>
{timer > 0 ? (
<Text style={[styles.timerText, { color: theme.colors.textMuted }]}>
Resend code in <Text style={{ fontWeight: '700' }}>00:{timer.toString().padStart(2, '0')}</Text>
</Text>
) : (
<TouchableOpacity onPress={() => resendOtp({ params: { mobile } })} disabled={resending}>
<Text style={[styles.resendText, { color: theme.colors.primary }]}>
{resending ? 'Sending...' : 'Resend Code'}
</Text>
</TouchableOpacity>
)} )}
</View>
</View>
</Animated.View> </Animated.View>
</View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</View> </AppView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1 }, container: {
gridContainer: { flex: 1,
...StyleSheet.absoluteFillObject, },
keyboardView: {
flex: 1,
justifyContent: 'center',
},
content: {
paddingHorizontal: 12,
},
headerSection: {
alignItems: 'center',
marginBottom: 48,
},
title: {
fontSize: responsiveFontSize(3.5),
fontFamily: FontsFamily.nunitoBold,
marginBottom: 12,
},
subtitle: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
textAlign: 'center',
lineHeight: 24,
},
otpSection: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', justifyContent: 'space-between',
marginBottom: 40,
}, },
gridBlock: { otpInput: {
width: BLOCK_SIZE, width: 48,
height: BLOCK_SIZE, height: 56,
margin: 2, borderRadius: 12,
borderRadius: 16, borderWidth: 1.5,
textAlign: 'center',
fontSize: responsiveFontSize(2.2),
fontFamily: FontsFamily.nunitoBold,
}, },
elevatedBlock: { actionSection: {
shadowColor: '#ffffffff', alignItems: 'center',
shadowOffset: { width: 1, height: 5 },
shadowOpacity: 1,
shadowRadius: 15,
elevation: 10,
}, },
keyboardAvoidingContainer: { flex: 1, justifyContent: 'center' }, button: {
contentPanel: {
marginHorizontal: 24,
borderRadius: 32,
padding: 28,
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.14,
shadowRadius: 24,
elevation: 12,
},
topSection: { alignItems: 'center', marginBottom: 24 },
title: { fontSize: 28, color: colors.textDark, textAlign: 'center', marginBottom: 12 ,fontFamily: FontsFamily.nunitoExtraBold},
subtitle: { fontSize: 16, color: colors.textLight, textAlign: 'center', marginBottom: 32, lineHeight: 24, maxWidth: 300 ,fontFamily: FontsFamily.nunitoMedium},
otpRow: {
flexDirection: "row",
justifyContent: "space-between",
width: '100%', width: '100%',
paddingHorizontal: 0, // Removed padding to use full width marginBottom: 24,
}, },
resendContainer: {
// FIX: Updated Cell Styling for clean smooth radius alignItems: 'center',
cell: {
width: 45,
height: 52,
borderRadius: 14, // Smoother radius
textAlign: "center",
fontSize: 20,
fontWeight: "600",
elevation: 0, // Removed elevation for flat look (or keep if you want shadow)
overflow: 'hidden',
}, },
cellFilled: { timerText: {
// Optional: Add specific style when filled if needed, handled inline above fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoRegular,
},
resendText: {
fontSize: responsiveFontSize(1.5),
fontFamily: FontsFamily.nunitoBold,
textDecorationLine: 'underline',
}, },
bottomSection: { paddingBottom: Platform.OS === "ios" ? 20 : 10, paddingTop: 10 },
button: { backgroundColor: colors.pastelPink, paddingVertical: 18, borderRadius: 30, alignItems: "center", justifyContent: "center",},
buttonText: { color: colors.white, fontSize: 16, fontFamily: FontsFamily.nunitoBold },
footerText: { textAlign: "center", fontFamily: FontsFamily.nunitoBold, fontSize: 14, marginTop: 20 },
}); });
+58 -104
View File
@@ -1,133 +1,87 @@
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, Animated, Alert, ToastAndroid } from "react-native"; import { View, Text, StyleSheet, ActivityIndicator, Animated } from 'react-native';
import * as Keychain from "react-native-keychain"; import { useNavigation } from '@react-navigation/native';
import { useNavigation } from "@react-navigation/native"; import { StackNavigationProp } from '@react-navigation/stack';
import { RootStackParamList } from "../navigation/AppNavigator"; import { useDispatch } from 'react-redux';
import { PROFILE_URL } from "../utils/ApiUrls"; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useDispatch } from "react-redux"; import { RootStackParamList } from '../navigation/AppNavigator';
import { clearUser, setUserData } from "../redux/userSlice"; import { PROFILE_URL } from '../utils/ApiUrls';
import colors from "../components/colors"; import { setUserData, clearUser } from '../redux/userSlice';
import { StackNavigationProp } from "@react-navigation/stack"; import { useTheme } from '../hooks/ui/useTheme';
import { useFetch } from '../hooks/core/useFetch';
import FontsFamily from '../constents/FontsFamily';
const SplashScreen = () => { const SplashScreen = () => {
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>(); const navigation = useNavigation<StackNavigationProp<RootStackParamList>>();
const dispatch = useDispatch(); const dispatch = useDispatch();
const theme = useTheme();
const fadeAnim = useRef(new Animated.Value(0)).current;
const fadeAnim = useRef(new Animated.Value(1)).current; const { data, isError, isLoading, error } = useFetch<any>(PROFILE_URL, {}, {
retry: false,
staleTime: 0,
const fetchUserProfile = async (dispatch: any, navigation: any) => {
try {
const credentials = await Keychain.getGenericPassword();
if (!credentials) {
return null;
}
const token = credentials.password;
const response = await fetch(PROFILE_URL, {
method: "GET",
headers: {
Accept: "*/*",
Authorization: `Bearer ${token}`,
},
}); });
const result = await response.json(); useEffect(() => {
Animated.timing(fadeAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}).start();
}, []);
if (response.status === 200 && result.status === "success") { useEffect(() => {
// Save user data in Redux if (!isLoading) {
const timer = setTimeout(() => {
if (data && data.status === 'success') {
const userData = data.data;
dispatch( dispatch(
setUserData({ setUserData({
id: result.data.id, ...userData,
userId: result.data.userId, profilePicture: userData.filePath,
username: result.data.username,
firstName: result.data.firstName,
lastName: result.data.lastName,
gender: result.data.gender,
mobile: result.data.mobile,
email: result.data.email,
address: result.data.address,
bankAccountNumber: result.data.bankAccountNumber,
bankName: result.data.bankName,
branchCode: result.data.branchCode,
profilePicture: result.data.filePath,
filePath: result.data.filePath,
imageBlob: result.data.imageBlob,
role: result.data.role,
organizerId: result.data.organizerId,
organizerStatus: result.data.organizerStatus,
profileStatus: true, profileStatus: true,
}) })
); );
return result.data.role; navigation.replace('Home');
} else if (response.status === 401 || result.status === 401) { } else {
// Unauthorized: reset credentials and navigate to login // If error is 401 or no data (not logged in)
await Keychain.resetGenericPassword();
dispatch(clearUser()); dispatch(clearUser());
ToastAndroid.show(result.message || "Session expired. Please login again.", ToastAndroid.SHORT); navigation.replace('Login');
navigation.replace("Login");
return null;
} else {
console.warn("Unexpected response:", result);
return null;
} }
} catch (error) { }, 1500); // Minimum splash time
console.error("❌ Error fetching profile:", error);
ToastAndroid.show("Failed to fetch profile. Please try again.", ToastAndroid.SHORT); return () => clearTimeout(timer);
return null;
} }
}; }, [isLoading, data, isError]);
useEffect(() => {
const startSplash = async () => {
const role = await fetchUserProfile(dispatch, navigation);
Animated.timing(fadeAnim, {
toValue: 0,
duration: 1500,
useNativeDriver: true,
delay: 1500,
}).start(() => {
// Navigate based on role
if (!role) {
navigation.replace("Login");
} else if (role === "USER") {
navigation.replace("Home");
} else if (role === "ORGANIZER") {
navigation.replace("Home"); // Replace with Organizer screen if you have one
} else {
navigation.replace("Login");
}
});
};
startSplash();
}, [navigation, fadeAnim, dispatch, ]);
return ( return (
<View style={styles.container}> <View style={[styles.container, { backgroundColor: theme.colors.background }]}>
<Animated.Text style={[styles.title, { opacity: fadeAnim }]}> <Animated.View style={{ opacity: fadeAnim, alignItems: 'center' }}>
Circle Collect <Text style={[styles.brand, { color: theme.colors.primary }]}>CircleCollect</Text>
</Animated.Text> <Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>Your Community Finance Hub</Text>
<ActivityIndicator size="large" color={colors.pastelPink} style={{ marginTop: 20 }} /> <ActivityIndicator size="small" color={theme.colors.primary} style={{ marginTop: 24 }} />
</Animated.View>
</View> </View>
); );
}; };
export default SplashScreen;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
justifyContent: "center", justifyContent: 'center',
alignItems: "center", alignItems: 'center',
backgroundColor: "#FFFFFF",
}, },
title: { brand: {
fontSize: 28, fontSize: responsiveFontSize(4),
fontWeight: "800", fontFamily: FontsFamily.nunitoExtraBold,
color: colors.Home, letterSpacing: 2,
letterSpacing: 1, },
subtitle: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 8,
}, },
}); });
export default SplashScreen;
+77 -40
View File
@@ -1,74 +1,111 @@
import React, { useState } from "react"; import React, { useState } from 'react';
import { View, Text, TextInput, Button, Modal, StyleSheet } from "react-native"; import { View, Text, StyleSheet, Modal, TouchableWithoutFeedback, Keyboard } from 'react-native';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useTheme } from '../../hooks/ui/useTheme';
import { CustomInput, CustomButton } from '../../components/common/Custom';
import FontsFamily from '../../constents/FontsFamily';
const DeclineModal = ({ visible, onClose, onSubmit }: { const DeclineModal = ({
visible,
onClose,
onSubmit,
}: {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
onSubmit: (reason: string) => void; onSubmit: (reason: string) => void;
}) => { }) => {
const [reason, setReason] = useState(""); const [reason, setReason] = useState('');
const theme = useTheme();
const handleSubmit = () => {
if (reason.trim()) {
onSubmit(reason);
setReason('');
}
};
return ( return (
<Modal <Modal transparent animationType="fade" visible={visible} onRequestClose={onClose}>
transparent <TouchableWithoutFeedback onPress={Keyboard.dismiss}>
animationType="fade"
visible={visible}
onRequestClose={onClose}
>
<View style={styles.overlay}> <View style={styles.overlay}>
<View style={styles.container}> <TouchableWithoutFeedback>
<Text style={styles.title}>Decline Request</Text> <View style={[styles.container, { backgroundColor: theme.colors.surface, borderRadius: theme.borderRadius.xl }]}>
<TextInput <Text style={[styles.title, { color: theme.colors.textPrimary }]}>Decline Request</Text>
style={styles.input} <Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
placeholder="Enter reason" Please provide a reason for declining this join request.
</Text>
<CustomInput
placeholder="Reason for declining..."
value={reason} value={reason}
onChangeText={setReason} onChangeText={setReason}
multiline
style={styles.input}
/> />
<View style={styles.actions}> <View style={styles.actions}>
<Button title="Cancel" onPress={onClose} /> <CustomButton
<Button title="Cancel"
onPress={onClose}
type="outline"
style={styles.button}
/>
<CustomButton
title="Decline" title="Decline"
onPress={() => { onPress={handleSubmit}
onSubmit(reason); style={[styles.button, { backgroundColor: theme.colors.error }]}
setReason(""); // reset after submit disabled={!reason.trim()}
}}
color="red"
/> />
</View> </View>
</View> </View>
</TouchableWithoutFeedback>
</View> </View>
</TouchableWithoutFeedback>
</Modal> </Modal>
); );
}; };
export default DeclineModal;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
overlay: { overlay: {
flex: 1, flex: 1,
justifyContent: "center", justifyContent: 'center',
alignItems: "center", alignItems: 'center',
backgroundColor: "rgba(0,0,0,0.5)", backgroundColor: 'rgba(0,0,0,0.4)',
padding: 20,
}, },
container: { container: {
width: "80%", width: '100%',
backgroundColor: "white", maxWidth: 400,
padding: 20, padding: 24,
borderRadius: 10, shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 5,
}, },
title: { title: {
fontSize: 18, fontSize: responsiveFontSize(2.2),
marginBottom: 10, fontFamily: FontsFamily.nunitoBold,
marginBottom: 8,
},
subtitle: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
marginBottom: 20,
lineHeight: 20,
}, },
input: { input: {
borderWidth: 1, height: 100,
borderColor: "#ccc", textAlignVertical: 'top',
borderRadius: 5, marginBottom: 24,
padding: 8,
marginBottom: 15,
}, },
actions: { actions: {
flexDirection: "row", flexDirection: 'row',
justifyContent: "space-between", gap: 12,
},
button: {
flex: 1,
}, },
}); });
export default DeclineModal;
+175 -127
View File
@@ -1,193 +1,241 @@
import React, { useState, useEffect } from "react"; import React, { useState, useMemo } from 'react';
import { import {
Keyboard,
KeyboardAvoidingView,
View, View,
Text, Text,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
TouchableWithoutFeedback,
TextInput, TextInput,
FlatList, FlatList,
ActivityIndicator,
LayoutAnimation, LayoutAnimation,
UIManager,
Platform, Platform,
Alert, UIManager,
ToastAndroid, } from 'react-native';
} from "react-native"; import LucideIcon from '../../components/common/LucideIcon';
import { Ionicons } from "@react-native-vector-icons/ionicons"; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import colors from "../../components/colors"; import { useTheme } from '../../hooks/ui/useTheme';
import * as keychain from "react-native-keychain"; import { useFetch } from '../../hooks/core/useFetch';
import { BASE_URL } from "../../utils/ApiUrls"; import { MY_GROUPS_URL } from '../../utils/ApiUrls';
import FontsFamily from '../../constents/FontsFamily';
import SkeletonLoader from '../../components/common/SkeletonLoader';
// Enable LayoutAnimation for Android if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
if (Platform.OS === "android" && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true); UIManager.setLayoutAnimationEnabledExperimental(true);
} }
const GroupCard = ({ item }: { item: any }) => { const GroupCard = ({ item }: { item: any }) => {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const theme = useTheme();
const toggleExpand = () => { const toggleExpand = () => {
LayoutAnimation.easeInEaseOut(); LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setExpanded(!expanded); setExpanded(!expanded);
}; };
const initial = item.groupName ? item.groupName.charAt(0).toUpperCase() : "?";
const initial = item.groupName ? item.groupName.charAt(0).toUpperCase() : '?';
return ( return (
<View style={styles.groupCard}> <View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<View style={styles.groupLeft}> <TouchableOpacity
<View style={styles.groupAvatar}> style={styles.cardHeader}
<Text style={styles.avatarText}>{initial}</Text> onPress={toggleExpand}
activeOpacity={0.7}
>
<View style={[styles.avatar, { backgroundColor: theme.colors.primary }]}>
<Text style={[styles.avatarText, { color: theme.colors.onPrimary }]}>{initial}</Text>
</View> </View>
<View style={{ flex: 1 }}> <View style={styles.headerInfo}>
<Text style={styles.groupName}>{item.groupName}</Text> <Text style={[styles.groupName, { color: theme.colors.textPrimary }]}>{item.groupName}</Text>
<Text style={styles.groupDescription}>{item.description}</Text> <Text style={[styles.groupMeta, { color: theme.colors.textSecondary }]} numberOfLines={1}>
<Text style={styles.groupMeta}> {item.visibility} • {item.cycleType} • {item.description || 'No description'}
{item.visibility} • {item.cycleType}
</Text> </Text>
</View> </View>
<TouchableOpacity onPress={toggleExpand}> <LucideIcon
<Ionicons name={expanded ? 'chevron-up' : 'chevron-forward'}
name={expanded ? "chevron-up-outline" : "chevron-forward-outline"} size={20}
size={22} color={theme.colors.textMuted}
color={colors.textLight}
/> />
</TouchableOpacity> </TouchableOpacity>
</View>
{expanded && ( {expanded && (
<View style={styles.detailsBox}> <View style={[styles.details, { borderTopColor: theme.colors.border }]}>
{Object.entries(item).map(([key, value]) => ( <View style={styles.detailRow}>
<Text key={key} style={styles.detailText}> <Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>Status</Text>
<Text style={{ fontWeight: "600" }}>{key}:</Text>{" "} <Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>{item.status}</Text>
{Array.isArray(value) ? value.join("-") : String(value)} </View>
</Text> <View style={styles.detailRow}>
))} <Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>Members</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>{item.memberCount || 0}</Text>
</View>
{item.baseAmount && (
<View style={styles.detailRow}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>Contribution</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>₹{item.baseAmount}</Text>
</View>
)}
</View> </View>
)} )}
</View> </View>
); );
}; };
// ✅ Group List with Search
const GroupList = () => { const GroupList = () => {
const [groups, setGroups] = useState<any[]>([]); const theme = useTheme();
const [filteredGroups, setFilteredGroups] = useState<any[]>([]); const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
useEffect(() => { const { data, isLoading } = useFetch<any>(MY_GROUPS_URL);
const fetchGroups = async () => {
const credentials = await keychain.getGenericPassword();
if (!credentials) {
ToastAndroid.show('You are not logged in', ToastAndroid.SHORT);
return;
}
const token = credentials.password;
try {
const res = await fetch(`${BASE_URL}fund/getAll/my/groups`, {
headers: { Accept: "*/*", Authorization: `Bearer ${token}` },
});
console.log("my groups response : ", res);
const data = await res.json();
setGroups(data.data || []);
setFilteredGroups(data.data || []);
} catch (e) {
console.error("API error", e);
} finally {
setLoading(false);
}
};
fetchGroups();
}, []);
useEffect(() => { const filteredGroups = useMemo(() => {
if (search.trim() === "") { const groups = data?.data || [];
setFilteredGroups(groups); if (!search.trim()) return groups;
} else { return groups.filter((g: any) =>
setFilteredGroups(
groups.filter((g) =>
g.groupName.toLowerCase().includes(search.toLowerCase()) g.groupName.toLowerCase().includes(search.toLowerCase())
) );
}, [data?.data, search]);
if (isLoading) {
return (
<View style={styles.loadingContainer}>
{[1, 2, 3].map((i) => (
<SkeletonLoader key={i} width="100%" height={80} style={{ marginBottom: 12, borderRadius: 16 }} />
))}
</View>
); );
} }
}, [search, groups]);
if (loading) return <ActivityIndicator size="large" color={colors.pastelBlue} />;
return ( return (
<> <KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<View style={styles.searchContainer}> <TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
<Ionicons name="search" size={20} color={colors.textLight} /> <View style={styles.container}>
<View style={[styles.searchBar, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<LucideIcon name="search" size={18} color={theme.colors.textMuted} />
<TextInput <TextInput
placeholder="Search groups..." placeholder="Search your circles..."
placeholderTextColor={colors.textLight} placeholderTextColor={theme.colors.textMuted}
style={styles.searchInput} style={[styles.searchInput, { color: theme.colors.textPrimary }]}
value={search} value={search}
onChangeText={setSearch} onChangeText={setSearch}
/> />
{search.length > 0 && (
<TouchableOpacity onPress={() => setSearch('')}>
<LucideIcon name="close-circle" size={18} color={theme.colors.textMuted} />
</TouchableOpacity>
)}
</View> </View>
<FlatList <FlatList
data={filteredGroups} data={filteredGroups}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => <GroupCard item={item} />} renderItem={({ item }) => <GroupCard item={item} />}
contentContainerStyle={styles.listContent}
ListEmptyComponent={ ListEmptyComponent={
<Text style={styles.emptyText}>No groups found</Text> <View style={styles.emptyState}>
<LucideIcon name="people-outline" size={48} color={theme.colors.textMuted} />
<Text style={[styles.emptyText, { color: theme.colors.textSecondary }]}>
{search ? 'No circles match your search' : 'You haven\'t created any circles yet'}
</Text>
</View>
} }
/> />
</> </View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
); );
}; };
export default GroupList; export default GroupList;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: {
searchContainer: { flex: 1,
flexDirection: "row", },
alignItems: "center", loadingContainer: {
backgroundColor: colors.white, padding: 16,
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
height: 48,
borderRadius: 12, borderRadius: 12,
borderWidth: 1,
paddingHorizontal: 12, paddingHorizontal: 12,
marginVertical: 10, marginHorizontal: 16,
height: 45, marginVertical: 12,
shadowColor: "#000",
shadowOpacity: 0.05,
shadowRadius: 5,
elevation: 2,
}, },
searchInput: { flex: 1, marginLeft: 8, color: colors.textDark }, searchInput: {
flex: 1,
groupCard: { marginLeft: 8,
backgroundColor: colors.white, fontSize: responsiveFontSize(1.6),
padding: 14, fontFamily: FontsFamily.nunitoRegular,
borderRadius: 14, padding: 0,
},
listContent: {
paddingHorizontal: 16,
paddingBottom: 24,
},
card: {
borderRadius: 16,
borderWidth: 1,
marginBottom: 12, marginBottom: 12,
shadowColor: "#000", overflow: 'hidden',
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
}, },
groupLeft: { flexDirection: "row", alignItems: "center" }, cardHeader: {
groupAvatar: { flexDirection: 'row',
width: 50, alignItems: 'center',
height: 50, padding: 12,
borderRadius: 25, },
backgroundColor: colors.pastelPink, avatar: {
justifyContent: "center", width: 44,
alignItems: "center", height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12, marginRight: 12,
}, },
avatarText: { fontSize: 18, fontWeight: "700", color: colors.black }, avatarText: {
groupName: { fontSize: 16, fontWeight: "600", color: colors.textDark }, fontSize: 18,
groupDescription: { fontSize: 13, color: colors.textLight, marginTop: 2 }, fontFamily: FontsFamily.nunitoBold,
groupMeta: { fontSize: 12, color: colors.black, marginTop: 2 }, },
detailsBox: { headerInfo: {
marginTop: 10, flex: 1,
padding: 10, },
borderRadius: 8, groupName: {
backgroundColor: "#f9f9f9", fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoSemiBold,
},
groupMeta: {
fontSize: responsiveFontSize(1.3),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 2,
},
details: {
padding: 12,
borderTopWidth: 1,
gap: 8,
},
detailRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
detailLabel: {
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoRegular,
},
detailValue: {
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoBold,
},
emptyState: {
alignItems: 'center',
marginTop: 64,
},
emptyText: {
marginTop: 12,
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoMedium,
textAlign: 'center',
}, },
detailText: { fontSize: 12, color: colors.textDark, marginBottom: 4 },
emptyText: { textAlign: "center", color: colors.textLight, marginTop: 20 },
centered: { flex: 1, justifyContent: "center", alignItems: "center" },
sectionTitle: { fontSize: 18, fontWeight: "600", color: colors.textDark },
}); });
+37 -81
View File
@@ -1,24 +1,22 @@
import React, { useState, useEffect } from "react"; import React, { useState } from 'react';
import { import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
View, import { responsiveFontSize } from 'react-native-responsive-dimensions';
Text, import { useTheme } from '../../hooks/ui/useTheme';
StyleSheet, import GroupList from './GroupList';
TouchableOpacity, import RequestList from './RequestList';
} from "react-native"; import FontsFamily from '../../constents/FontsFamily';
import colors from "../../components/colors";
import GroupList from "./GroupList";
import RequestList from "./RequestList";
// ✅ Tab Layout // ✅ Tab Layout
const GroupsTabs = () => { const GroupsTabs = () => {
const [activeTab, setActiveTab] = useState("MyGroups"); const [activeTab, setActiveTab] = useState('MyGroups');
const theme = useTheme();
const renderContent = () => { const renderContent = () => {
switch (activeTab) { switch (activeTab) {
case "MyGroups": case 'MyGroups':
return <GroupList />; return <GroupList />;
case "Requests": case 'Requests':
return <RequestList />; return <RequestList />;
default: default:
return null; return null;
@@ -26,101 +24,59 @@ const GroupsTabs = () => {
}; };
return ( return (
<> <View style={styles.container}>
<View style={[styles.tabs, { borderBottomColor: theme.colors.border }]}>
{/* Tabs */} {['MyGroups', 'Requests'].map((tab) => (
<View style={styles.tabs}>
{["MyGroups", "Requests"].map((tab) => (
<TouchableOpacity <TouchableOpacity
key={tab} key={tab}
style={[ style={[
styles.tabButton, styles.tabButton,
activeTab === tab && styles.activeTabButton, activeTab === tab && { borderBottomColor: theme.colors.primary, borderBottomWidth: 2 },
]} ]}
onPress={() => setActiveTab(tab)} onPress={() => setActiveTab(tab)}
activeOpacity={0.7}
> >
<Text <Text
style={[ style={[
styles.tabText, styles.tabText,
activeTab === tab && styles.activeTabText, { color: activeTab === tab ? theme.colors.primary : theme.colors.textMuted },
activeTab === tab && { fontFamily: FontsFamily.nunitoBold },
]} ]}
> >
{tab === "MyGroups" ? "My Groups" : tab} {tab === 'MyGroups' ? 'My Circles' : 'Join Requests'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>
<View style={styles.content}>
{/* Content */}
{renderContent()} {renderContent()}
</View>
</> </View>
); );
}; };
export default GroupsTabs; export default GroupsTabs;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: {
flex: 1,
},
tabs: { tabs: {
flexDirection: "row", flexDirection: 'row',
justifyContent: "space-around",
marginVertical: 10,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: "#ddd", marginHorizontal: 16,
}, },
tabButton: { paddingVertical: 10, flex: 1, alignItems: "center" }, tabButton: {
tabText: { fontSize: 16, color: colors.textLight }, paddingVertical: 12,
activeTabButton: { borderBottomWidth: 2, borderBottomColor: colors.Home }, marginRight: 24,
activeTabText: { color: colors.Home, fontWeight: "600" }, alignItems: 'center',
searchContainer: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.white,
borderRadius: 12,
paddingHorizontal: 12,
marginVertical: 10,
height: 45,
shadowColor: "#000",
shadowOpacity: 0.05,
shadowRadius: 5,
elevation: 2,
}, },
searchInput: { flex: 1, marginLeft: 8, color: colors.textDark }, tabText: {
fontSize: responsiveFontSize(1.6),
groupCard: { fontFamily: FontsFamily.nunitoMedium,
backgroundColor: colors.white,
padding: 14,
borderRadius: 14,
marginBottom: 12,
shadowColor: "#000",
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
}, },
groupLeft: { flexDirection: "row", alignItems: "center" }, content: {
groupAvatar: { flex: 1,
width: 50, marginTop: 8,
height: 50,
borderRadius: 25,
backgroundColor: colors.Home + "33",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
}, },
avatarText: { fontSize: 18, fontWeight: "700", color: colors.Home },
groupName: { fontSize: 16, fontWeight: "600", color: colors.textDark },
groupDescription: { fontSize: 13, color: colors.textLight, marginTop: 2 },
groupMeta: { fontSize: 12, color: colors.Home, marginTop: 2 },
detailsBox: {
marginTop: 10,
padding: 10,
borderRadius: 8,
backgroundColor: "#f9f9f9",
},
detailText: { fontSize: 12, color: colors.textDark, marginBottom: 4 },
emptyText: { textAlign: "center", color: colors.textLight, marginTop: 20 },
centered: { flex: 1, justifyContent: "center", alignItems: "center" },
sectionTitle: { fontSize: 18, fontWeight: "600", color: colors.textDark },
}); });
+136 -210
View File
@@ -1,200 +1,117 @@
import React, { useEffect, useState, useCallback } from "react"; import React, { useCallback, useMemo } from 'react';
import { import {
StyleSheet, StyleSheet,
Text, Text,
View, View,
TouchableOpacity, TouchableOpacity,
UIManager,
Platform,
FlatList, FlatList,
ListRenderItem, ListRenderItem,
Alert,
RefreshControl, RefreshControl,
StatusBar, } from 'react-native';
} from "react-native"; import { useDispatch, useSelector } from 'react-redux';
import AppView from "../../components/AppView"; import { useNavigation } from '@react-navigation/native';
import colors from "../../components/colors"; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useDispatch, useSelector } from "react-redux"; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from "@react-navigation/native"; import LucideIcon from '../../components/common/LucideIcon';
import { DASHBOARD_URL, PROFILE_URL, USER_STATUS } from "../../utils/ApiUrls";
import { setUserData } from "../../redux/userSlice";
import { TabNavigationProp } from "../../navigation/BottomTabs";
import UserProfileStatus from "./UserProfileStatus";
import AnimatedCard from "../../components/common/AnimatedCard";
import { Ionicons } from "@react-native-vector-icons/ionicons";
import { apiCall } from "../../utils/ApiHelper";
import CustomAlert, { CustomAlertProps } from "../../components/CostomAlart";
import { useTheme } from "../../theme/ThemeProvider";
import FontsFamily from "../../constents/FontsFamily";
import { responsiveFontSize, responsiveHeight } from "react-native-responsive-dimensions";
import { useSafeAreaInsets } from "react-native-safe-area-context";
if (Platform.OS === "android" && UIManager.setLayoutAnimationEnabledExperimental) { import AppView from '../../components/AppView';
UIManager.setLayoutAnimationEnabledExperimental(true); import { DASHBOARD_URL, PROFILE_URL, USER_STATUS } from '../../utils/ApiUrls';
} import { setUserData } from '../../redux/userSlice';
import { TabNavigationProp } from '../../navigation/BottomTabs';
type UserProfile = { import UserProfileStatus from './UserProfileStatus';
id: string; import AnimatedCard from '../../components/common/AnimatedCard';
userId: number; import { useTheme } from '../../hooks/ui/useTheme';
firstName: string | null; import { useFetch } from '../../hooks/core/useFetch';
lastName: string | null; import FontsFamily from '../../constents/FontsFamily';
imageBlob: string | null;
role?: string;
organizerStatus?: string;
profileStatus?: boolean;
};
interface ActionItem { interface ActionItem {
id: string; id: string;
title: string; title: string;
action: string; action: string;
tab?: "My Groups" | "Joined Groups" | "Requests" | "Your Requests"; tab?: 'My Groups' | 'Joined Groups' | 'Requests' | 'Your Requests';
iconName?: any; iconName: string;
backOne?: string; subtitle: string;
backTwo?: string;
subtitle?: string;
} }
const organizerActions: ActionItem[] = [ const organizerActions: ActionItem[] = [
{ id: "1", title: "My Groups", subtitle: "Manage groups", action: "NAVIGATE_MANAGEMENT", tab: "My Groups", iconName: "people-circle-outline", backOne: "#decbfcff", backTwo: "#B799FF" }, { id: '1', title: 'My Groups', subtitle: 'Manage your circles', action: 'NAVIGATE_MANAGEMENT', tab: 'My Groups', iconName: 'people-circle-outline' },
{ id: "2", title: "Joined Groups", subtitle: "View groups", action: "NAVIGATE_MANAGEMENT", tab: "Joined Groups", iconName: "people-outline", backOne: "#D7E9F7", backTwo: "#82AAE3" }, { id: '2', title: 'Joined Groups', subtitle: 'Circles you belong to', action: 'NAVIGATE_MANAGEMENT', tab: 'Joined Groups', iconName: 'people-outline' },
{ id: "3", title: "Requests", subtitle: "Check requests", action: "NAVIGATE_MANAGEMENT", tab: "Requests", iconName: "notifications-outline", backOne: "#DFF6DD", backTwo: "#88D18A" }, { id: '3', title: 'Requests', subtitle: 'Pending join requests', action: 'NAVIGATE_MANAGEMENT', tab: 'Requests', iconName: 'notifications-outline' },
{ id: "4", title: "Create Group", subtitle: "Start new group", action: "NAVIGATE_CREATE_GROUP", iconName: "add-circle-outline", backOne: "#FFF4D2", backTwo: "#FFB344" }, { id: '4', title: 'Create Group', subtitle: 'Start a new circle', action: 'NAVIGATE_CREATE_GROUP', iconName: 'add-circle-outline' },
{ id: "5", title: "Your Requests", subtitle: "Track requests", action: "NAVIGATE_MANAGEMENT", tab: "Your Requests", iconName: "mail-open-outline", backOne: "#FFF4D2", backTwo: "#FFB344" }, { id: '5', title: 'Your Requests', subtitle: 'Status of your applications', action: 'NAVIGATE_MANAGEMENT', tab: 'Your Requests', iconName: 'mail-open-outline' },
{ id: "6", title: "Transactions", subtitle: "View history", action: "TRANSACTION", iconName: "receipt-outline", backOne: "#decbfcff", backTwo: "#B799FF" }, { id: '6', title: 'Transactions', subtitle: 'View payment history', action: 'TRANSACTION', iconName: 'receipt-outline' },
]; ];
const userActions: ActionItem[] = [ const userActions: ActionItem[] = [
{ id: "1", title: "Joined Groups", subtitle: "See your groups", action: "NAVIGATE_MANAGEMENT", tab: "Joined Groups", iconName: "people-outline", backOne: "#D7E9F7", backTwo: "#82AAE3" }, { id: '1', title: 'Joined Groups', subtitle: 'Circles you belong to', action: 'NAVIGATE_MANAGEMENT', tab: 'Joined Groups', iconName: 'people-outline' },
{ id: "2", title: "Your Requests", subtitle: "Track requests", action: "NAVIGATE_MANAGEMENT", tab: "Your Requests", iconName: "mail-open-outline", backOne: "#FFF4D2", backTwo: "#FFB344" }, { id: '2', title: 'Your Requests', subtitle: 'Status of your applications', action: 'NAVIGATE_MANAGEMENT', tab: 'Your Requests', iconName: 'mail-open-outline' },
{ id: "3", title: "Transactions", subtitle: "View history", action: "TRANSACTION", iconName: "receipt-outline", backOne: "#decbfcff", backTwo: "#B799FF" }, { id: '3', title: 'Transactions', subtitle: 'View payment history', action: 'TRANSACTION', iconName: 'receipt-outline' },
{ id: "4", title: "Create Group", subtitle: "Start new group", action: "NAVIGATE_CREATE_GROUP", iconName: "add-circle-outline", backOne: "#DFF6DD", backTwo: "#88D18A" }, { id: '4', title: 'Create Group', subtitle: 'Start a new circle', action: 'NAVIGATE_CREATE_GROUP', iconName: 'add-circle-outline' },
]; ];
const Home: React.FC = () => { const Home: React.FC = () => {
const user = useSelector((state: any) => state.user); const user = useSelector((state: any) => state.user);
const notifications = useSelector((state: any) => state.notifications.notifications); const notifications = useSelector((state: any) => state.notifications.notifications);
const navigation = useNavigation<TabNavigationProp>(); const navigation = useNavigation<TabNavigationProp>();
const dispatch = useDispatch(); const dispatch = useDispatch();
const actions = user.role === "ORGANIZER" ? organizerActions : userActions;
const [apiUser, setApiUser] = useState<UserProfile | null>(null);
const [alertVisible, setAlertVisible] = useState(false);
const [alertAction, setAlertAction] = useState<CustomAlertProps | null>(null);
const [userDashboard, setUserDashboard] = useState<any>(null);
const [refreshing, setRefreshing] = useState(false);
const [statusData, setStatusData] = useState<any>(null);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const {theme} = useTheme(); const theme = useTheme();
const getDashboardData = async () => {
try {
const result = (await apiCall(DASHBOARD_URL, "GET")) as any;
if (result.status === "success") {
console.log("dashboard -----",result)
setUserDashboard(result.data);
}
} catch (error) {
console.error("❌ Error fetching dashboard data:", error);
}
};
const getUserStatus = async () => { // API Hooks
const result = await apiCall(USER_STATUS, "GET") as any; const { data: profileData, refetch: refetchProfile, isFetching: isFetchingProfile } = useFetch<any>(PROFILE_URL);
setStatusData(result.data); const { data: dashboardData, refetch: refetchDashboard, isFetching: isFetchingDashboard } = useFetch<any>(DASHBOARD_URL);
} const { data: statusData, refetch: refetchStatus, isFetching: isFetchingStatus } = useFetch<any>(USER_STATUS);
const fetchUserProfile = async () => { // Sync profile with Redux
try { React.useEffect(() => {
const result = (await apiCall(PROFILE_URL, "GET")) as any; if (profileData?.status === 'success') {
console.log("profile data", result); dispatch(setUserData({ ...profileData.data, profileStatus: true }));
if (result.status === "success") {
setApiUser(result.data);
dispatch(setUserData({ ...result.data, profileStatus: true }));
} }
} catch (error) { }, [profileData, dispatch]);
console.error("❌ Error fetching profile:", error);
}
};
const fetchData = useCallback(async () => { const onRefresh = useCallback(() => {
await Promise.all([fetchUserProfile(), getDashboardData(), getUserStatus()]); refetchProfile();
}, []); refetchDashboard();
refetchStatus();
}, [refetchProfile, refetchDashboard, refetchStatus]);
const onRefresh = useCallback(async () => { const actions = useMemo(() =>
setRefreshing(true); user.role === 'ORGANIZER' ? organizerActions : userActions,
await fetchData(); [user.role]);
setRefreshing(false);
}, [fetchData]);
useEffect(() => {
const unsubscribe = navigation.addListener("focus", fetchData);
fetchData();
return unsubscribe;
}, [navigation, fetchData]);
const handleActionPress = (item: ActionItem) => {
if (item.action === "NAVIGATE_MANAGEMENT" && item.tab) {
navigation.navigate("Management", { actionTab: item.tab });
} else if (item.action === "TRANSACTION") {
navigation.navigate("TransactionList" as never);
} else if (item.action === "NAVIGATE_CREATE_GROUP") {
switch (apiUser?.organizerStatus) {
case "NOT_APPLIED":
setAlertAction({
visible: true,
title: "Become an Organizer",
message: "You need to be an organizer to create groups.",
buttons: [
{
text: "Continue",
onPress: () => {
setAlertVisible(false);
navigation.navigate("BecomeOrg" as never);
},
},
{ text: "Close", onPress: () => setAlertVisible(false), style: "cancel" },
],
alertType: "error",
onClose: () => setAlertAction(null),
});
setAlertVisible(true);
break;
case "PENDING":
Alert.alert("Pending Approval", "Your organizer request is still pending.");
break;
case "APPROVED":
navigation.navigate("GroupCreation" as never);
break;
default:
Alert.alert("Unknown Status", "Could not determine your organizer status.");
break;
}
}
};
const unreadCount = notifications.filter((item: any) => !item.read).length; const unreadCount = notifications.filter((item: any) => !item.read).length;
const handleActionPress = (item: ActionItem) => {
if (item.action === 'NAVIGATE_MANAGEMENT' && item.tab) {
navigation.navigate('Management', { actionTab: item.tab });
} else if (item.action === 'TRANSACTION') {
navigation.navigate('TransactionList' as never);
} else if (item.action === 'NAVIGATE_CREATE_GROUP') {
const organizerStatus = profileData?.data?.organizerStatus || user.organizerStatus;
if (organizerStatus === 'APPROVED') {
navigation.navigate('GroupCreation' as never);
} else if (organizerStatus === 'PENDING') {
navigation.navigate('BecomeOrg' as never); // View status
} else {
navigation.navigate('BecomeOrg' as never); // Apply now
}
}
};
const renderGridItem: ListRenderItem<ActionItem> = ({ item }) => ( const renderGridItem: ListRenderItem<ActionItem> = ({ item }) => (
<View style={styles.cardContainer}>
<AnimatedCard <AnimatedCard
key={item.id}
title={item.title} title={item.title}
subtitle={item.subtitle} subtitle={item.subtitle}
iconName={item.iconName} iconName={item.iconName}
backOne={item.backOne}
backTwo={item.backTwo}
onPress={() => handleActionPress(item)} onPress={() => handleActionPress(item)}
/> />
</View>
); );
const getProfileImageUri = () => {
const blob = user.imageBlob || apiUser?.imageBlob;
return blob ? { uri: `data:image/jpeg;base64,${blob}` } : undefined;
};
return ( return (
<View style={[ styles.container, {backgroundColor: theme.colors.background, paddingBottom: insets.bottom + responsiveHeight(10) }]}> <AppView style={styles.container}>
<StatusBar barStyle={theme.mode === "dark" ? "light-content" : "dark-content"}/>
<FlatList <FlatList
data={actions} data={actions}
numColumns={2} numColumns={2}
@@ -203,56 +120,50 @@ const Home: React.FC = () => {
ListHeaderComponent={ ListHeaderComponent={
<> <>
<View style={styles.header}> <View style={styles.header}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 ,marginTop: responsiveHeight(2) }} > <View>
<Text style={[styles.greeting, { color: theme.colors.textPrimary }]}> <Text style={[styles.greeting, { color: theme.colors.textSecondary }]}>Hello,</Text>
Hello, <Text style={[styles.userName, { color: theme.colors.textPrimary }]}>
{user.firstName || profileData?.data?.firstName || 'User'}
</Text> </Text>
<Text style={{ fontFamily:FontsFamily.nunitoExtraBold,color: theme.colors.textPrimary,fontSize:responsiveFontSize(2.5) }}>{user.firstName || apiUser?.firstName || "User"}</Text>
</View> </View>
<TouchableOpacity onPress={() => navigation.navigate("Notification" as never)} style={{ marginTop: responsiveHeight(2) ,}}> <TouchableOpacity
onPress={() => navigation.navigate('Notification' as never)}
style={[styles.iconButton, { backgroundColor: theme.colors.surface }]}
>
{unreadCount > 0 && ( {unreadCount > 0 && (
<View style={styles.badge}> <View style={[styles.badge, { backgroundColor: theme.colors.primary }]}>
<Text style={styles.badgeText}>{unreadCount}</Text> <Text style={[styles.badgeText, { color: theme.colors.onPrimary }]}>{unreadCount}</Text>
</View> </View>
)} )}
<Ionicons name="notifications-outline" size={30} color={theme.colors.textPrimary} /> <LucideIcon name="notifications-outline" size={24} color={theme.colors.textPrimary} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<UserProfileStatus <UserProfileStatus
profileImage={getProfileImageUri()} profileImage={user.imageUrl ? { uri: user.imageUrl } : undefined}
statusData={statusData} statusData={statusData?.data}
userDashboard={userDashboard} userDashboard={dashboardData?.data}
/> />
<View style={styles.actionHeader}> <View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle,{ color: theme.colors.textPrimary }]}>Quick Actions </Text> <Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Quick Actions</Text>
</View> </View>
</> </>
} }
contentContainerStyle={{ paddingBottom: 100 }} contentContainerStyle={{
paddingBottom: insets.bottom + 86,
paddingHorizontal: 6,
}}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={refreshing} refreshing={isFetchingProfile || isFetchingDashboard || isFetchingStatus}
onRefresh={onRefresh} onRefresh={onRefresh}
colors={[colors.primary]} tintColor={theme.colors.primary}
tintColor={colors.primary} colors={[theme.colors.primary]}
/> />
} }
/> />
</AppView>
{alertAction && (
<CustomAlert
visible={alertVisible}
title={alertAction.title}
buttons={alertAction.buttons}
message={alertAction.message}
alertType={"error"}
onClose={() => setAlertVisible(false)}
/>
)}
</View>
); );
}; };
@@ -263,41 +174,56 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
}, },
header: { header: {
flexDirection: "row", flexDirection: 'row',
justifyContent: "space-between", justifyContent: 'space-between',
alignItems: "center", alignItems: 'center',
paddingHorizontal: 20, paddingHorizontal: 10,
paddingTop: 24, marginTop: 10,
paddingBottom: 16, marginBottom: 16,
}, },
greeting: { fontSize: 22,fontFamily: FontsFamily.nunitoLight }, greeting: {
sectionTitle: { fontSize: 20, fontWeight: "600", color: colors.textDark, flex: 1 ,fontFamily: FontsFamily.nunitoSemiBold}, fontSize: responsiveFontSize(1.8),
actionHeader: { fontFamily: FontsFamily.nunitoRegular,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginHorizontal: 20,
marginBottom: 10,
marginTop: 16
}, },
payAllButton: { userName: {
paddingHorizontal: 14, fontSize: responsiveFontSize(2.8),
paddingVertical: 6, fontFamily: FontsFamily.nunitoExtraBold,
backgroundColor: colors.primary, marginTop: -4,
borderRadius: 8, },
iconButton: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
sectionHeader: {
paddingHorizontal: 10,
marginBottom: 12,
marginTop: 16,
},
sectionTitle: {
fontSize: responsiveFontSize(2.2),
fontFamily: FontsFamily.nunitoBold,
},
cardContainer: {
flex: 1,
padding: 6,
}, },
payAllText: { color: "#fff", fontWeight: "bold", fontSize: 14 },
badge: { badge: {
position: "absolute", position: 'absolute',
right: -6, right: 8,
top: -4, top: 8,
backgroundColor: colors.danger,
borderRadius: 8, borderRadius: 8,
width: 16, minWidth: 16,
height: 16, height: 16,
justifyContent: "center", justifyContent: 'center',
alignItems: "center", alignItems: 'center',
zIndex: 1, zIndex: 1,
paddingHorizontal: 4,
},
badgeText: {
fontSize: 10,
fontFamily: FontsFamily.nunitoBold,
}, },
badgeText: { color: colors.white, fontSize: 10, fontWeight: "bold" },
}); });
+241 -486
View File
@@ -1,23 +1,28 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useMemo } from 'react';
import { import {
Keyboard,
KeyboardAvoidingView,
View, View,
Text, Text,
StyleSheet, StyleSheet,
ScrollView, FlatList,
TouchableOpacity, TouchableOpacity,
RefreshControl, TouchableWithoutFeedback,
Alert,
ActivityIndicator,
TextInput, TextInput,
ToastAndroid Alert,
ToastAndroid,
Platform,
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@react-native-vector-icons/ionicons'; import LucideIcon from '../../components/common/LucideIcon';
import colors from '../../components/colors'; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useTheme } from '../../hooks/ui/useTheme';
import { useFetch } from '../../hooks/core/useFetch';
import { useUpdate } from '../../hooks/core/useUpdate';
import { REQUESTS_URL, APPROVE_REQUEST_URL } from '../../utils/ApiUrls';
import DeclineModal from './DeclineModal'; import DeclineModal from './DeclineModal';
import * as keychain from 'react-native-keychain' import FontsFamily from '../../constents/FontsFamily';
import { BASE_URL } from '../../utils/ApiUrls'; import SkeletonLoader from '../../components/common/SkeletonLoader';
// Define TypeScript interfaces
interface GroupRequest { interface GroupRequest {
requestId: string; requestId: string;
groupName: string; groupName: string;
@@ -28,587 +33,337 @@ interface GroupRequest {
status: string; status: string;
} }
interface ApiResponse { const RequestCard = ({
message: string; request,
status: string; onAccept,
statusCode: number; onDecline,
data: GroupRequest[]; isProcessing,
} }: {
request: GroupRequest;
const RequestList: React.FC = () => { onAccept: () => void;
const [requests, setRequests] = useState<GroupRequest[]>([]); onDecline: () => void;
const [filteredRequests, setFilteredRequests] = useState<GroupRequest[]>([]); isProcessing: boolean;
const [refreshing, setRefreshing] = useState<boolean>(false); }) => {
const [loading, setLoading] = useState<boolean>(true); const theme = useTheme();
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
const [processingRequest, setProcessingRequest] = useState<string | null>(null);
const [showDeclineModal, setShowDeclineModal] = useState<{ modal: boolean, requestId: string | null }>({ modal: false, requestId: null });
const [selectedRequestId, setSelectedRequestId] = useState<string | null>(null);
// Fetch data from API
const fetchRequests = async (): Promise<void> => {
const credentials = await keychain.getGenericPassword();
if (!credentials) {
ToastAndroid.show('You are not logged in', ToastAndroid.SHORT);
return;
}
const token = credentials.password;
try {
setRefreshing(true);
setError(null);
const response = await fetch(
`${BASE_URL}`,
{
method: "GET",
headers: {
accept: "*/*",
Authorization: `Bearer ${token}`
}
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
if (data.status === "success") {
setRequests(data.data);
setFilteredRequests(data.data);
} else {
throw new Error("Failed to fetch requests");
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred";
setError(errorMessage);
console.error("API Error:", error);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchRequests();
}, []);
// Filter requests based on search query
useEffect(() => {
if (searchQuery.trim() === '') {
setFilteredRequests(requests);
} else {
const query = searchQuery.toLowerCase();
const filtered = requests.filter(request =>
request.groupName.toLowerCase().includes(query) ||
request.userName.toLowerCase().includes(query) ||
request.mobile.includes(query) ||
request.status.toLowerCase().includes(query)
);
setFilteredRequests(filtered);
}
}, [searchQuery, requests]);
const handleAccept = async (requestId: string): Promise<void> => {
Alert.alert(
"Accept Request",
"Are you sure you want to accept this request?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Accept",
onPress: async () => {
try {
setProcessingRequest(requestId);
// Make API call to approve the request
const response = await fetch(
`http://192.168.1.5:9060/api/fund/requests/${requestId}/approve`,
{
method: "PUT",
headers: {
"accept": "*/*",
"Authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI2MjY1MjI5MzcxIiwidXNlcklkIjoiMTAwMDAxIiwiaWF0IjoxNzU4MDkxMjEzLCJleHAiOjE3NTgxNzc2MTN9.T3BvU7nl8fnjwwVobIWRmSFFxQzOjtyIct1Ba0i9lIU"
}
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Parse response
const data = await response.json();
if (data.status === "success") {
// Remove the accepted request from the list
const updatedRequests = requests.filter(req => req.requestId !== requestId);
setRequests(updatedRequests);
ToastAndroid.show(data.message || "Request accepted successfully", ToastAndroid.SHORT);
fetchRequests();
} else {
throw new Error(data.message || "Failed to accept request");
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred";
console.error("Accept Error:", error);
} finally {
setProcessingRequest(null);
}
}
}
]
);
};
const handleDeclineConfirm = async (requestedId: string | null, reason: string) => {
setShowDeclineModal({ modal: false, requestId: null });
try {
setProcessingRequest(requestedId);
// Make API call to decline the request
const response = await fetch(
`http://192.168.1.5:9060/api/fund/requests/${requestedId}/reject?reason=/${reason}`,
{
method: "PUT",
headers: {
"accept": "*/*",
"Authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI2MjY1MjI5MzcxIiwidXNlcklkIjoiMTAwMDAxIiwiaWF0IjoxNzU4MDkxMjEzLCJleHAiOjE3NTgxNzc2MTN9.T3BvU7nl8fnjwwVobIWRmSFFxQzOjtyIct1Ba0i9lIU"
}
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Parse response
const data = await response.json();
if (data.status === "success") {
// Remove the declined request from the list
const updatedRequests = requests.filter(req => req.requestId !== selectedRequestId);
setRequests(updatedRequests);
ToastAndroid.show(data.message || "Request declined successfully", ToastAndroid.SHORT);
fetchRequests();
} else {
throw new Error(data.message || "Failed to decline request");
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred";
console.error("Decline Error:", error);
} finally {
setProcessingRequest(null);
}// call your API
};
const onRefresh = (): void => {
fetchRequests();
};
const getInitials = (name: string): string => { const getInitials = (name: string): string => {
return name return name
.split(' ') .split(' ')
.map(word => word.charAt(0)) .map((word) => word.charAt(0))
.join('') .join('')
.toUpperCase() .toUpperCase()
.substring(0, 2); .substring(0, 2);
}; };
const clearSearch = (): void => {
setSearchQuery('');
};
if (loading) {
return ( return (
<View style={styles.centerContainer}> <View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<ActivityIndicator size="large" color="#3498db" />
<Text style={styles.loadingText}>Loading requests...</Text>
</View>
);
}
if (error) {
return (
<View style={styles.centerContainer}>
<Ionicons name="alert-circle" size={64} color="#e74c3c" />
<Text style={styles.errorText}>Error loading requests</Text>
<Text style={styles.errorSubText}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={fetchRequests}>
<Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Pending Requests</Text>
<Text style={styles.headerSubtitle}>
{filteredRequests.length} pending group request{filteredRequests.length !== 1 ? 's' : ''}
{searchQuery ? ` matching "${searchQuery}"` : ''}
</Text>
</View>
{/* Search Bar */}
<View style={styles.searchContainer}>
<Ionicons name="search" size={20} color="#95a5a6" style={styles.searchIcon} />
<TextInput
style={styles.searchInput}
placeholder="Search by group, name, phone or status..."
value={searchQuery}
onChangeText={setSearchQuery}
placeholderTextColor="#95a5a6"
/>
{searchQuery ? (
<TouchableOpacity onPress={clearSearch} style={styles.clearButton}>
<Ionicons name="close-circle" size={20} color="#95a5a6" />
</TouchableOpacity>
) : null}
</View>
<ScrollView
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
contentContainerStyle={filteredRequests.length === 0 ? styles.emptyScrollView : undefined}
>
{filteredRequests.length === 0 ? (
<View style={styles.emptyState}>
{searchQuery ? (
<>
<Ionicons name="search" size={64} color="#ccc" />
<Text style={styles.emptyStateText}>No matching requests</Text>
<Text style={styles.emptyStateSubtext}>
No requests found for "{searchQuery}"
</Text>
<TouchableOpacity onPress={clearSearch} style={styles.clearSearchButton}>
<Text style={styles.clearSearchText}>Clear Search</Text>
</TouchableOpacity>
</>
) : (
<>
<Ionicons name="checkmark-done-circle" size={64} color="#ccc" />
<Text style={styles.emptyStateText}>No pending requests</Text>
<Text style={styles.emptyStateSubtext}>
You're all caught up! There are no pending group requests.
</Text>
</>
)}
</View>
) : (
filteredRequests.map((request) => (
<View key={request.requestId} style={styles.requestCard}>
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<View style={styles.groupInfo}> <View style={[styles.avatar, { backgroundColor: theme.colors.primary }]}>
<View style={styles.avatar}> <Text style={[styles.avatarText, { color: theme.colors.onPrimary }]}>
<Text style={styles.avatarText}>
{getInitials(request.groupName)} {getInitials(request.groupName)}
</Text> </Text>
</View> </View>
<View> <View style={styles.headerInfo}>
<Text style={styles.groupName}>{request.groupName}</Text> <Text style={[styles.groupName, { color: theme.colors.textPrimary }]}>{request.groupName}</Text>
<Text style={styles.requestText}>Group Join Request</Text> <Text style={[styles.requestText, { color: theme.colors.textSecondary }]}>Circle Join Request</Text>
</View> </View>
</View> <View style={[styles.statusBadge, { backgroundColor: theme.colors.background }]}>
<View style={[ <Text style={[styles.statusText, { color: theme.colors.textPrimary }]}>{request.status}</Text>
styles.statusBadge,
request.status === 'PENDING' ? styles.pendingBadge : styles.otherBadge
]}>
<Text style={styles.statusText}>{request.status}</Text>
</View> </View>
</View> </View>
<View style={styles.divider} /> <View style={[styles.divider, { backgroundColor: theme.colors.border }]} />
<View style={styles.userInfo}> <View style={styles.userInfo}>
<Ionicons name="person" size={16} color="#666" /> <View style={styles.infoRow}>
<Text style={styles.userName}>{request.userName}</Text> <LucideIcon name="person-outline" size={14} color={theme.colors.textMuted} />
<Text style={[styles.userName, { color: theme.colors.textPrimary }]}>{request.userName}</Text>
</View>
<View style={styles.infoRow}>
<LucideIcon name="call-outline" size={14} color={theme.colors.textMuted} />
<Text style={[styles.userDetail, { color: theme.colors.textSecondary }]}>{request.mobile}</Text>
</View> </View>
<View style={styles.userInfo}>
<Ionicons name="call" size={16} color="#666" />
<Text style={styles.userDetail}>{request.mobile}</Text>
</View> </View>
<View style={styles.actions}> <View style={styles.actions}>
<TouchableOpacity <TouchableOpacity
style={[styles.button, styles.declineButton]} style={[styles.actionButton, styles.declineButton, { borderColor: theme.colors.error }]}
onPress={() => setShowDeclineModal({ modal: true, requestId: request.requestId })} onPress={onDecline}
disabled={processingRequest === request.requestId} disabled={isProcessing}
> >
{processingRequest === request.requestId ? ( <Text style={[styles.declineText, { color: theme.colors.error }]}>Decline</Text>
<ActivityIndicator size="small" color="#e74c3c" />
) : (
<Text style={styles.declineButtonText}>Decline</Text>
)}
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.button, styles.acceptButton]} style={[styles.actionButton, styles.acceptButton, { backgroundColor: theme.colors.primary }]}
onPress={() => handleAccept(request.requestId)} onPress={onAccept}
disabled={processingRequest === request.requestId} disabled={isProcessing}
> >
{processingRequest === request.requestId ? ( <Text style={[styles.acceptText, { color: theme.colors.onPrimary }]}>
<ActivityIndicator size="small" color="#fff" /> {isProcessing ? 'Processing' : 'Accept'}
) : ( </Text>
<Text style={styles.acceptButtonText}>Accept</Text>
)}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
)) );
};
const RequestList: React.FC = () => {
const theme = useTheme();
const [searchQuery, setSearchQuery] = useState<string>('');
const [showDeclineModal, setShowDeclineModal] = useState<{ modal: boolean; requestId: string | null }>({
modal: false,
requestId: null,
});
const { data, isLoading, refetch } = useFetch<any>(REQUESTS_URL);
const { mutate: updateRequest, isPending: isUpdating } = useUpdate<any, any>('');
const filteredRequests = useMemo(() => {
const requests = data?.data || [];
if (!searchQuery.trim()) return requests;
const query = searchQuery.toLowerCase();
return requests.filter(
(request: GroupRequest) =>
request.groupName.toLowerCase().includes(query) ||
request.userName.toLowerCase().includes(query) ||
request.mobile.includes(query) ||
request.status.toLowerCase().includes(query)
);
}, [data?.data, searchQuery]);
const handleAccept = (requestId: string) => {
Alert.alert('Accept Request', 'Are you sure you want to approve this join request?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Accept',
onPress: () => {
updateRequest(
{
resource: `${APPROVE_REQUEST_URL}${requestId}/approve`,
payload: {}
} as any, // useUpdate might need adjustments if not handling dynamic resources well
{
onSuccess: () => {
ToastAndroid.show('Request accepted successfully', ToastAndroid.SHORT);
refetch();
},
onError: (error) => {
ToastAndroid.show(error.message || 'Failed to accept request', ToastAndroid.SHORT);
},
}
);
},
},
]);
};
const handleDeclineConfirm = (requestId: string | null, reason: string) => {
setShowDeclineModal({ modal: false, requestId: null });
updateRequest(
{
resource: `${APPROVE_REQUEST_URL}${requestId}/reject?reason=${reason}`,
payload: {}
} as any,
{
onSuccess: () => {
ToastAndroid.show('Request declined successfully', ToastAndroid.SHORT);
refetch();
},
onError: (error) => {
ToastAndroid.show(error.message || 'Failed to decline request', ToastAndroid.SHORT);
},
}
);
};
if (isLoading) {
return (
<View style={styles.loadingContainer}>
{[1, 2, 3].map((i) => (
<SkeletonLoader key={i} width="100%" height={160} style={{ marginBottom: 16, borderRadius: 16 }} />
))}
</View>
);
}
return (
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
<View style={styles.container}>
<View style={[styles.searchBar, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<LucideIcon name="search" size={18} color={theme.colors.textMuted} />
<TextInput
placeholder="Search by name, circle or phone..."
placeholderTextColor={theme.colors.textMuted}
style={[styles.searchInput, { color: theme.colors.textPrimary }]}
value={searchQuery}
onChangeText={setSearchQuery}
/>
{searchQuery.length > 0 && (
<TouchableOpacity onPress={() => setSearchQuery('')}>
<LucideIcon name="close-circle" size={18} color={theme.colors.textMuted} />
</TouchableOpacity>
)} )}
</ScrollView> </View>
{showDeclineModal.modal && (
<FlatList
data={filteredRequests}
keyExtractor={(item) => item.requestId}
renderItem={({ item }) => (
<RequestCard
request={item}
onAccept={() => handleAccept(item.requestId)}
onDecline={() => setShowDeclineModal({ modal: true, requestId: item.requestId })}
isProcessing={isUpdating}
/>
)}
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.emptyState}>
<LucideIcon name="mail-unread-outline" size={48} color={theme.colors.textMuted} />
<Text style={[styles.emptyText, { color: theme.colors.textSecondary }]}>
{searchQuery ? 'No requests match your search' : 'No pending requests at the moment'}
</Text>
</View>
}
/>
<DeclineModal <DeclineModal
visible={showDeclineModal.modal} visible={showDeclineModal.modal}
onClose={() => setShowDeclineModal({ modal: false, requestId: null })} onClose={() => setShowDeclineModal({ modal: false, requestId: null })}
onSubmit={(reason) => handleDeclineConfirm(showDeclineModal.requestId, reason)} onSubmit={(reason) => handleDeclineConfirm(showDeclineModal.requestId, reason)}
/> />
)}
</View> </View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
paddingBottom: '100%',
},
centerContainer: {
flex: 1, flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f7fb',
padding: 20,
}, },
loadingText: { loadingContainer: {
fontSize: 16, padding: 16,
color: '#666',
marginTop: 12,
}, },
errorText: { searchBar: {
fontSize: 18,
fontWeight: '600',
color: '#e74c3c',
marginTop: 16,
marginBottom: 8,
},
errorSubText: {
fontSize: 14,
color: '#7f8c8d',
textAlign: 'center',
marginBottom: 20,
},
retryButton: {
backgroundColor: colors.pastelPink,
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 8,
},
retryButtonText: {
color: '#fff',
fontWeight: '600',
},
header: {
// backgroundColor: '#fff',
// // padding: 20,
// borderBottomWidth: 1,
// borderBottomColor: '#eee',
// shadowColor: '#000',
// shadowOffset: { width: 0, height: 2 },
// shadowOpacity: 0.1,
// shadowRadius: 3,
// elevation: 3,
},
headerTitle: {
fontSize: 24,
fontWeight: 'bold',
color: '#2c3e50',
marginBottom: 5,
},
headerSubtitle: {
fontSize: 14,
color: '#7f8c8d',
},
// Search styles
searchContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: '#fff', height: 48,
marginVertical: 16, borderRadius: 12,
marginBottom: 8, borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 12, paddingHorizontal: 12,
height: 50, marginHorizontal: 16,
shadowColor: '#000', marginVertical: 12,
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
searchIcon: {
marginRight: 8,
}, },
searchInput: { searchInput: {
flex: 1, flex: 1,
height: '100%', marginLeft: 8,
fontSize: 16, fontSize: responsiveFontSize(1.6),
color: '#2c3e50', fontFamily: FontsFamily.nunitoRegular,
padding: 0, padding: 0,
}, },
clearButton: { listContent: {
padding: 4,
},
clearSearchButton: {
marginTop: 16,
paddingVertical: 8,
paddingHorizontal: 16, paddingHorizontal: 16,
backgroundColor: '#ecf0f1', paddingBottom: 24,
borderRadius: 6,
}, },
clearSearchText: { card: {
color: '#7f8c8d', borderRadius: 16,
fontWeight: '500', borderWidth: 1,
},
emptyScrollView: {
flexGrow: 1,
justifyContent: 'center',
},
requestCard: {
backgroundColor: '#fff',
borderRadius: 12,
marginBottom: 16,
padding: 16, padding: 16,
shadowColor: '#000', marginBottom: 16,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
}, },
cardHeader: { cardHeader: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginBottom: 12,
},
groupInfo: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
}, },
avatar: { avatar: {
width: 50, width: 48,
height: 50, height: 48,
borderRadius: 25, borderRadius: 24,
backgroundColor: colors.pastelPink,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginRight: 12, marginRight: 12,
}, },
avatarText: { avatarText: {
color: '#fff', fontSize: 18,
fontSize: 16, fontFamily: FontsFamily.nunitoBold,
fontWeight: 'bold', },
headerInfo: {
flex: 1,
}, },
groupName: { groupName: {
fontSize: 18, fontSize: responsiveFontSize(1.8),
fontWeight: '600', fontFamily: FontsFamily.nunitoSemiBold,
color: '#2c3e50',
}, },
requestText: { requestText: {
fontSize: 14, fontSize: responsiveFontSize(1.3),
color: '#7f8c8d', fontFamily: FontsFamily.nunitoRegular,
marginTop: 2, marginTop: 2,
}, },
statusBadge: { statusBadge: {
paddingHorizontal: 10, paddingHorizontal: 8,
paddingVertical: 5, paddingVertical: 4,
borderRadius: 12, borderRadius: 8,
},
pendingBadge: {
backgroundColor: '#fff4e6',
},
otherBadge: {
backgroundColor: '#e8f5e9',
}, },
statusText: { statusText: {
fontSize: 12, fontSize: responsiveFontSize(1.2),
fontWeight: '600', fontFamily: FontsFamily.nunitoBold,
}, },
divider: { divider: {
height: 1, height: 1,
backgroundColor: '#ecf0f1',
marginVertical: 12, marginVertical: 12,
}, },
userInfo: { userInfo: {
gap: 6,
},
infoRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: 8, gap: 8,
}, },
userName: { userName: {
fontSize: 16, fontSize: responsiveFontSize(1.6),
color: '#2c3e50', fontFamily: FontsFamily.nunitoSemiBold,
marginLeft: 8,
}, },
userDetail: { userDetail: {
fontSize: 14, fontSize: responsiveFontSize(1.4),
color: '#7f8c8d', fontFamily: FontsFamily.nunitoRegular,
marginLeft: 8,
}, },
actions: { actions: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 16, marginTop: 16,
gap: 12,
}, },
button: { actionButton: {
paddingVertical: 10, flex: 1,
paddingHorizontal: 20, height: 40,
borderRadius: 8, borderRadius: 10,
marginLeft: 12, justifyContent: 'center',
minWidth: 100,
alignItems: 'center', alignItems: 'center',
}, },
acceptButton: {
backgroundColor: '#2ecc71',
},
declineButton: { declineButton: {
backgroundColor: '#fff',
borderWidth: 1, borderWidth: 1,
borderColor: '#e74c3c',
}, },
acceptButtonText: { acceptButton: {},
color: '#fff', acceptText: {
fontWeight: '600', fontSize: responsiveFontSize(1.5),
fontFamily: FontsFamily.nunitoBold,
}, },
declineButtonText: { declineText: {
color: '#e74c3c', fontSize: responsiveFontSize(1.5),
fontWeight: '600', fontFamily: FontsFamily.nunitoBold,
}, },
emptyState: { emptyState: {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', marginTop: 64,
padding: 40,
}, },
emptyStateText: { emptyText: {
fontSize: 18, marginTop: 12,
fontWeight: '600', fontSize: responsiveFontSize(1.6),
color: '#7f8c8d', fontFamily: FontsFamily.nunitoMedium,
marginTop: 16,
marginBottom: 8,
},
emptyStateSubtext: {
fontSize: 14,
color: '#bdc3c7',
textAlign: 'center', textAlign: 'center',
}, },
}); });
+320 -488
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useState, useEffect } from "react"; import React, { useMemo, useState, useEffect } from 'react';
import { import {
StyleSheet, StyleSheet,
Text, Text,
@@ -9,55 +9,38 @@ import {
Image, Image,
ScrollView, ScrollView,
ActivityIndicator, ActivityIndicator,
} from "react-native"; } from 'react-native';
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from '@react-navigation/native';
import colors from "../../components/colors"; import LucideIcon from '../../components/common/LucideIcon';
import LinearGradient from "react-native-linear-gradient"; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { Ionicons } from "@react-native-vector-icons/ionicons"; import { useTheme } from '../../hooks/ui/useTheme';
import { apiCall } from "../../utils/ApiHelper"; import { useCreate } from '../../hooks/core/useCreate';
import { PAY_ALL_URL } from "../../utils/ApiUrls"; import { PAY_ALL_URL } from '../../utils/ApiUrls';
import { import FontsFamily from '../../constents/FontsFamily';
responsiveHeight,
responsiveWidth,
responsiveFontSize
} from "react-native-responsive-dimensions";
import { useTheme } from "../../theme/ThemeProvider";
import FontsFamily from "../../constents/FontsFamily";
type StatusData = { export type StatusData = {
role?: string; role?: string;
organizerStatus?: string; organizerStatus?: string;
profileStatus?: boolean; profileStatus?: boolean;
}; };
type DashboardData = { export type DashboardData = {
totalPaidAmountMonth?: number; totalPaidAmountMonth?: number;
multiplePayAmount?: number; multiplePayAmount?: number;
completeStatusMyGroupCount?: number;
activeStatusMyGroupCount?: number; activeStatusMyGroupCount?: number;
createdGroupActiveCount?: number;
createdGroupCompleteCount?: number;
myCreatedGroups?: any[]; myCreatedGroups?: any[];
myJoinedGroups?: Array<any>; myJoinedGroups?: Array<any>;
requestList?: any[]; completeStatusMyGroupCount?: number;
myRequests?: Array<{ status: string }>;
dueGroupDetails?: Array<{ dueGroupDetails?: Array<{
groupId: string; groupId: string;
groupName: string; groupName: string;
baseAmount: number; baseAmount: number;
lateFee: number; lateFee: number;
waivedAmount: number;
totalDue: number; totalDue: number;
dueDate: string;
isLate: boolean;
currentCycle: number;
lateFeeMode: string;
lateFeeValue: number;
hasPaid: boolean;
}>; }>;
}; };
type UserProfileStatusProps = { export type UserProfileStatusProps = {
profileImage?: { uri: string }; profileImage?: { uri: string };
statusData: StatusData | null; statusData: StatusData | null;
userDashboard: DashboardData | null; userDashboard: DashboardData | null;
@@ -68,53 +51,54 @@ const UserProfileStatus: React.FC<UserProfileStatusProps> = ({
statusData, statusData,
userDashboard, userDashboard,
}) => { }) => {
const navigation = useNavigation(); const navigation = useNavigation<any>();
const [showModal, setShowModal] = useState(false); const theme = useTheme();
// Payment UI states const [showModal, setShowModal] = useState(false);
const [showPayDialog, setShowPayDialog] = useState(false); const [showPayDialog, setShowPayDialog] = useState(false);
const [selectedGroupIds, setSelectedGroupIds] = useState<string[]>([]); const [selectedGroupIds, setSelectedGroupIds] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const {theme} = useTheme();
// console.log("user me : ", userDashboard)
// Preselect all due groups at mount and on change
useEffect(() => { useEffect(() => {
if (userDashboard?.dueGroupDetails) { if (userDashboard?.dueGroupDetails) {
setSelectedGroupIds(userDashboard.dueGroupDetails.map((g) => g.groupId)); setSelectedGroupIds(userDashboard.dueGroupDetails.map((g) => g.groupId));
} }
}, [userDashboard?.dueGroupDetails]); }, [userDashboard?.dueGroupDetails]);
// Show profile completion modal only if profile is incomplete
console.log("userDashboard : ", userDashboard)
useEffect(() => { useEffect(() => {
if (statusData && !statusData.profileStatus) setShowModal(true); if (statusData && !statusData.profileStatus) {
setShowModal(true);
}
}, [statusData]); }, [statusData]);
// Top grid info items const { mutate: payAll, isPending: submitting } = useCreate<any, any>(PAY_ALL_URL, {
onSuccess: (res) => {
if (res?.status) {
ToastAndroid.show('Payment initiated successfully', ToastAndroid.SHORT);
setShowPayDialog(false);
} else {
ToastAndroid.show(res?.message || 'Payment failed', ToastAndroid.SHORT);
}
},
onError: () => {
ToastAndroid.show('Something went wrong during payment', ToastAndroid.SHORT);
},
});
const infoItems = useMemo( const infoItems = useMemo(
() => [ () => [
{ icon: <Ionicons name="person-outline" size={20} color={theme.colors.black} />, label: "Role", value: statusData?.role || "-" }, { icon: 'person-outline', label: 'Role', value: statusData?.role || '-' },
{ icon: <Ionicons name="pie-chart-outline" size={20} color={theme.colors.black} />, label: "Active Groups", value: userDashboard?.activeStatusMyGroupCount?.toString() || "0" }, { icon: 'pie-chart-outline', label: 'Active', value: userDashboard?.activeStatusMyGroupCount?.toString() || '0' },
{ icon: <Ionicons name="book-outline" size={20} color={theme.colors.black} />, label: "Joined Groups", value: userDashboard?.myJoinedGroups?.length?.toString() || "0" }, { icon: 'book-outline', label: 'Joined', value: userDashboard?.myJoinedGroups?.length?.toString() || '0' },
{ icon: <Ionicons name="people-outline" size={20} color={theme.colors.black} />, label: "My Groups", value: userDashboard?.myCreatedGroups?.length?.toString() || "0" }, { icon: 'people-outline', label: 'My Circles', value: userDashboard?.myCreatedGroups?.length?.toString() || '0' },
{ icon: <Ionicons name="document-text-outline" size={20} color={theme.colors.black} />, label: "Total Due", value: `₹${userDashboard?.multiplePayAmount || 0}` },
{ icon: <Ionicons name="checkmark-done-outline" size={20} color={theme.colors.black} />, label: "Completed", value: userDashboard?.completeStatusMyGroupCount?.toString() || "0" },
], ],
[userDashboard, statusData] [userDashboard, statusData]
); );
const dueGroups = useMemo(() => userDashboard?.dueGroupDetails || [], [userDashboard?.dueGroupDetails]);
// Utility for info color const totalDue = useMemo(
const getValueColor = (value: string) => { () => dueGroups.reduce((sum, item) => sum + (item.totalDue || 0), 0),
if (value.includes("₹") || value === "Active" || value === "Yes ✅") return "#4CAF50"; [dueGroups]
if (value === "Inactive" || value === "No ❌") return "#FF6B6B"; );
return "#fff";
};
// Dues and payment logic
const dueGroups = userDashboard?.dueGroupDetails || [];
const totalDue = dueGroups.reduce((sum, item) => sum + (item.totalDue || 0), 0);
const totalDueSelected = useMemo( const totalDueSelected = useMemo(
() => () =>
dueGroups dueGroups
@@ -125,222 +109,151 @@ const UserProfileStatus: React.FC<UserProfileStatusProps> = ({
const handleToggleGroup = (groupId: string) => { const handleToggleGroup = (groupId: string) => {
setSelectedGroupIds((prev) => setSelectedGroupIds((prev) =>
prev.includes(groupId) prev.includes(groupId) ? prev.filter((id) => id !== groupId) : [...prev, groupId]
? prev.filter((id) => id !== groupId)
: [...prev, groupId]
); );
}; };
const handlePayAll = async () => {
if (selectedGroupIds.length === 0) {
ToastAndroid.show("Select at least one group", ToastAndroid.SHORT);
return;
}
setSubmitting(true);
try {
const response = await apiCall(PAY_ALL_URL, 'POST', { groupIds: selectedGroupIds }, undefined, "Payment failed. Try again.") as any;
console.log("-=-=-=-=-=-=-=-=-=-=-=", response)
if (response.status) {
ToastAndroid.show("Payment initiated!", ToastAndroid.LONG);
setShowPayDialog(false);
} else {
ToastAndroid.show("Payment failed. Try again.", ToastAndroid.SHORT);
}
} catch {
ToastAndroid.show("Network error. Try again.", ToastAndroid.SHORT);
}
setSubmitting(false);
};
// Bottom bar
const renderDueBar = () => ( const renderDueBar = () => (
<View style={[ styles.dueBar, { borderTopColor: theme.colors.border, backgroundColor: theme.colors.gray }]}> <View style={[styles.dueBar, { backgroundColor: theme.colors.surface, borderTopColor: theme.colors.border }]}>
<Text style={[styles.dueBarText, { color: theme.colors.black }]}> <View>
Total Due: <Text style={{ color: theme.colors.black, fontWeight: "bold" }}>₹{totalDue}</Text> <Text style={[styles.dueBarLabel, { color: theme.colors.textSecondary }]}>Total Outstanding</Text>
</Text> <Text style={[styles.dueBarValue, { color: theme.colors.textPrimary }]}>₹{totalDue}</Text>
</View>
<TouchableOpacity <TouchableOpacity
style={[styles.payAllButton, { backgroundColor: theme.colors.black }]} style={[styles.payButton, { backgroundColor: theme.colors.primary }]}
onPress={() => setShowPayDialog(true)} onPress={() => setShowPayDialog(true)}
disabled={dueGroups.length === 0} disabled={dueGroups.length === 0}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Ionicons name="wallet-outline" size={20} color={theme.colors.white} /> <LucideIcon name="wallet-outline" size={18} color={theme.colors.onPrimary} />
<Text style={[styles.payAllButtonText, { color: theme.colors.white }]}>Pay All</Text> <Text style={[styles.payButtonText, { color: theme.colors.onPrimary }]}>Pay Selected</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );
// Payment dialog modal return (
const renderPayDialog = () => ( <View style={styles.container}>
<Modal visible={showPayDialog} transparent animationType="fade" onRequestClose={() => setShowPayDialog(false)}> <View style={[styles.card, { backgroundColor: theme.colors.background, borderColor: theme.colors.border }]}>
<View style={styles.payModalOverlay}> <View style={styles.headerRow}>
<View <View style={styles.profileSection}>
style={[styles.payModalBox, { backgroundColor: theme.colors.reverseBackground }]} <TouchableOpacity
onPress={() => navigation.navigate('Profile')}
style={styles.avatarWrapper}
> >
<View style={styles.payDialogHeader}> {profileImage ? (
<Ionicons name="card-outline" color={theme.colors.reverseText} size={22} /> <Image source={profileImage} style={styles.avatar} />
<Text style={[styles.payDialogTitle, { color: theme.colors.reverseText }]}>Select Groups to Pay</Text> ) : (
<View style={[styles.avatarPlaceholder, { backgroundColor: theme.colors.surface }]}>
<LucideIcon name="person" size={32} color={theme.colors.textMuted} />
</View>
)}
</TouchableOpacity>
<View style={styles.badgeLabel}>
<View style={[styles.dot, { backgroundColor: statusData?.profileStatus ? '#4CAF50' : theme.palette.greyMedium }]} />
<Text style={[styles.badgeText, { color: theme.colors.textSecondary }]}>
{statusData?.profileStatus ? 'Verified' : 'Incomplete'}
</Text>
</View>
</View>
<View style={styles.statsSection}>
<View style={styles.grid}>
{infoItems.map((item) => (
<View key={item.label} style={styles.gridItem}>
<Text style={[styles.itemLabel, { color: theme.colors.textMuted }]}>{item.label}</Text>
<Text style={[styles.itemValue, { color: theme.colors.textPrimary }]}>{item.value}</Text>
</View>
))}
</View>
</View>
</View>
{renderDueBar()}
</View>
<Modal visible={showPayDialog} transparent animationType="slide" onRequestClose={() => setShowPayDialog(false)}>
<View style={styles.modalOverlay}>
<View style={[styles.modalContent, { backgroundColor: theme.colors.background }]}>
<View style={[styles.modalHeader, { borderBottomColor: theme.colors.border }]}>
<Text style={[styles.modalTitle, { color: theme.colors.textPrimary }]}>Select Payments</Text>
<TouchableOpacity onPress={() => setShowPayDialog(false)}> <TouchableOpacity onPress={() => setShowPayDialog(false)}>
<Ionicons name="close-circle" color={theme.colors.reverseText} size={24} /> <LucideIcon name="close" size={24} color={theme.colors.textPrimary} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<ScrollView <ScrollView style={styles.groupsList}>
style={{ maxHeight: 200, width: "100%" }}
contentContainerStyle={{ paddingVertical: 8, paddingHorizontal: 4 }}
>
{dueGroups.map((group) => ( {dueGroups.map((group) => (
<TouchableOpacity <TouchableOpacity
key={group.groupId} key={group.groupId}
style={[ style={[
styles.payGroupOption, styles.groupItem,
{ borderColor: theme.colors.background, borderWidth: 1 }, {
backgroundColor: selectedGroupIds.includes(group.groupId) ? theme.colors.surface : 'transparent',
selectedGroupIds.includes(group.groupId) && { borderColor: theme.colors.border
...styles.payOptionSelected, }
borderColor: theme.colors.reverseBackground, ]}
},
]}
onPress={() => handleToggleGroup(group.groupId)} onPress={() => handleToggleGroup(group.groupId)}
activeOpacity={0.85}
> >
<Ionicons <LucideIcon
name={selectedGroupIds.includes(group.groupId) ? "checkbox" : "square-outline"} name={selectedGroupIds.includes(group.groupId) ? 'checkbox' : 'square-outline'}
size={22} size={24}
color={selectedGroupIds.includes(group.groupId) ? theme.colors.reverseText : theme.colors.reverseText} color={selectedGroupIds.includes(group.groupId) ? theme.colors.primary : theme.colors.textMuted}
/> />
<View style={{ flex: 1 }}> <View style={styles.groupInfo}>
<Text style={[styles.payGroupName, {color: theme.colors.reverseText}]}>{group.groupName}</Text> <Text style={[styles.groupName, { color: theme.colors.textPrimary }]}>{group.groupName}</Text>
<Text style={[styles.payDueText, {color: theme.colors.reverseText}]}> <Text style={[styles.groupSub, { color: theme.colors.textSecondary }]}>
₹{group.baseAmount} Amount: ₹{group.baseAmount} {group.lateFee > 0 ? `+ ₹${group.lateFee} fee` : ''}
{group.lateFee > 0 ? ` (Late: ₹${group.lateFee})` : ""}
</Text> </Text>
</View> </View>
{/* <Text style={[styles.payAmount, { color: "#4CAF50" }]}>₹{group.totalDue}</Text> */} <Text style={[styles.groupAmount, { color: theme.colors.textPrimary }]}>₹{group.totalDue}</Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</ScrollView> </ScrollView>
<View style={styles.payDialogFooter}> <View style={[styles.modalFooter, { borderTopColor: theme.colors.border }]}>
<Text style={[styles.payDialogTotal, { color: theme.colors.reverseText }]}> <View>
Total : <Text style={{ fontFamily: FontsFamily.nunitoBold, color: theme.colors.red }}>₹{totalDueSelected}</Text> <Text style={[styles.totalLabel, { color: theme.colors.textSecondary }]}>Payable</Text>
</Text> <Text style={[styles.totalValue, { color: theme.colors.textPrimary }]}>₹{totalDueSelected}</Text>
</View>
<TouchableOpacity <TouchableOpacity
style={[styles.payConfirmButton, { opacity: submitting ? 0.7 : 1 , backgroundColor: theme.colors.background}]} style={[styles.confirmButton, { backgroundColor: theme.colors.primary, opacity: submitting ? 0.7 : 1 }]}
onPress={handlePayAll} onPress={() => payAll({ groupIds: selectedGroupIds })}
disabled={submitting || selectedGroupIds.length === 0} disabled={submitting || selectedGroupIds.length === 0}
activeOpacity={0.8}
> >
{submitting ? ( {submitting ? (
<ActivityIndicator color={theme.colors.textPrimary} /> <ActivityIndicator color={theme.colors.onPrimary} />
) : ( ) : (
<> <Text style={[styles.confirmText, { color: theme.colors.onPrimary }]}>Confirm Payment</Text>
<Ionicons name="checkmark-circle" size={22} color={theme.colors.textPrimary} />
<Text style={[styles.payButtonText, { fontFamily: FontsFamily.nunitoSemiBold ,color: theme.colors.textPrimary}]}>Pay Selected</Text>
</>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
</View> </View>
</Modal> </Modal>
);
return (
<View style={styles.container}>
<View
style={[styles.card, { backgroundColor: theme.colors.reverseBackground }]}
>
<View style={styles.headerSection}>
<View style={styles.titleWithIcon}>
<Ionicons name="stats-chart" size={24} color={theme.colors.reverseText} style={styles.headerIcon} />
<Text style={[styles.header, { color: theme.colors.reverseText} ]}>Profile Overview</Text>
</View>
<View style={[styles.statusBadge, { backgroundColor: theme.colors.background}]}>
<Ionicons name="shield-checkmark" size={16} color={theme.colors.textPrimary} />
<Text style={[styles.statusBadgeText, { color: theme.colors.textPrimary }]}>
{statusData?.profileStatus ? "Verified" : "Pending"}
</Text>
</View>
</View>
<View style={styles.contentSection}>
<View style={styles.leftSection}>
<View style={styles.infoGrid}>
{infoItems.map((item, idx) => (
<View key={item.label} style={[styles.infoCard,{backgroundColor: theme.colors.background}]}>
<View style={styles.infoCardHeader}>
<View style={[styles.iconContainer, {backgroundColor: theme.colors.optCard || theme.colors.card}]}>
{item.icon}
</View>
<Text style={[styles.infoLabel,{color: theme.colors.textPrimary}]} >
{item.label}
</Text>
</View>
<Text style={[styles.infoValue, { color: theme.colors.textPrimary }]} numberOfLines={1}>
{item.value}
</Text>
</View>
))}
</View>
</View>
<View style={styles.profileSection}>
<TouchableOpacity
onPress={() => navigation.navigate("Profile" as never)}
activeOpacity={0.8}
>
<View style={styles.profileContainer}>
{profileImage !== undefined ? (
<Image source={{ uri: profileImage.uri }} style={styles.avatar} />
) : (
<View style={styles.avatarPlaceholder}>
<Ionicons name="person" size={45} color="#fff" />
</View>
)}
</View>
<View style={[styles.infoCardOne,{backgroundColor: theme.colors.background}]}>
<Text style={{ color: theme.colors.textPrimary, fontSize: responsiveWidth(3), fontFamily: FontsFamily.nunitoSemiBold,}}>Total Paid Amount</Text>
<Text style={{ color: theme.colors.textPrimary, fontWeight: "400", fontFamily: FontsFamily.nunitoRegular}}>{userDashboard?.totalPaidAmountMonth}</Text>
</View>
</TouchableOpacity>
</View>
</View>
{renderDueBar()}
</View>
{renderPayDialog()}
{showModal && ( {showModal && (
<Modal visible transparent animationType="fade"> <Modal visible transparent animationType="fade">
<View style={styles.modalOverlay}> <View style={styles.alertOverlay}>
<View style={styles.modalBox}> <View style={[styles.alertBox, { backgroundColor: theme.colors.background }]}>
<Ionicons name="alert-circle" size={60} color={colors.primary} /> <View style={[styles.alertIcon, { backgroundColor: theme.colors.surface }]}>
<Text style={styles.modalTitle}>Complete Your Profile</Text> <LucideIcon name="person-outline" size={32} color={theme.colors.primary} />
<Text style={styles.modalText}> </View>
Your profile is incomplete. Update your details to unlock all features. <Text style={[styles.alertTitle, { color: theme.colors.textPrimary }]}>Complete Your Profile</Text>
<Text style={[styles.alertMsg, { color: theme.colors.textSecondary }]}>
Update your identity and bank details to start earning and managing circles.
</Text> </Text>
<View style={styles.modalButtons}>
<TouchableOpacity <TouchableOpacity
style={[styles.button, styles.primaryButton]} style={[styles.alertButton, { backgroundColor: theme.colors.primary }]}
onPress={() => { onPress={() => {
setShowModal(false); setShowModal(false);
navigation.navigate("Profile" as never); navigation.navigate('Profile');
}} }}
> >
<Ionicons name="person" size={18} color="#fff" /> <Text style={[styles.alertButtonText, { color: theme.colors.onPrimary }]}>Update Now</Text>
<Text style={styles.buttonText}>Complete Profile</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity style={styles.laterButton} onPress={() => setShowModal(false)}>
style={[styles.button, styles.secondaryButton]} <Text style={[styles.laterText, { color: theme.colors.textMuted }]}>I'll do it later</Text>
onPress={() => setShowModal(false)}
>
<Ionicons name="time" size={18} color="#666" />
<Text style={[styles.buttonText, styles.secondaryText]}>Remind Later</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
</View>
</Modal> </Modal>
)} )}
</View> </View>
@@ -351,307 +264,226 @@ export default UserProfileStatus;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
marginHorizontal: 8, paddingHorizontal: 10,
marginBottom: 16, marginTop: 4,
}, },
card: { card: {
borderRadius: 24, borderRadius: 16,
overflow: "hidden",
elevation: 8,
shadowColor: "#000",
shadowOpacity: 0.3,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
},
headerSection: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 16,
paddingHorizontal: 18,
marginBottom: 16,
},
titleWithIcon: {
flexDirection: 'row',
alignItems: 'center'
},
headerIcon: {
marginRight: 8
},
header: {
fontSize: responsiveWidth(4.5),
fontFamily: FontsFamily.nunitoBold,
color: "#fff"
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.2)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
},
statusBadgeText: {
color: "#fff",
fontSize: responsiveWidth(2),
fontWeight: '600',
marginLeft: 4
},
contentSection: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingHorizontal: 16,
paddingBottom: 16,
},
leftSection: {
flex: 1,
marginRight: 16
},
infoGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10
},
infoCard: {
padding: 12,
flex: 1,
minWidth: '45%',
marginBottom: 8,
borderRadius:12
},
infoCardOne: {
backgroundColor: 'rgba(255,255,255,0.15)',
borderRadius: 14,
padding: 12,
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255,255,255,0.2)', overflow: 'hidden',
// flex: 1,
minWidth: '45%',
marginTop: 21,
}, },
infoCardHeader: { headerRow: {
flexDirection: 'row', flexDirection: 'row',
padding: 14,
alignItems: 'center', alignItems: 'center',
marginBottom: 6
},
iconContainer: {
width: responsiveWidth(8),
height: responsiveHeight(3.5),
borderRadius: responsiveWidth(2),
justifyContent: 'center',
alignItems: 'center',
marginRight: 8,
},
infoLabel: {
fontSize: responsiveWidth(3),
fontFamily: FontsFamily.nunitoSemiBold,
flexShrink: 1,
},
infoValue: {
fontFamily: FontsFamily.nunitoBold,
fontSize: responsiveWidth(3),
textAlign: 'left',
}, },
profileSection: { profileSection: {
width: 90,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', marginRight: 14,
}, },
profileContainer: { avatarWrapper: {
alignItems: 'center' width: 72,
height: 72,
borderRadius: 36,
overflow: 'hidden',
}, },
avatar: { avatar: {
width: 80, width: 72,
height: 80, height: 72,
borderRadius: 40,
borderWidth: 3,
borderColor: "#fff",
}, },
avatarPlaceholder: { avatarPlaceholder: {
width: 80, width: 72,
height: 80, height: 72,
borderRadius: 40,
backgroundColor: 'rgba(255,255,255,0.2)',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
borderWidth: 3,
borderColor: "#fff",
}, },
profileBadge: { badgeLabel: {
position: 'absolute', flexDirection: 'row',
bottom: 0,
right: 0,
width: 26,
height: 26,
backgroundColor: '#fff',
borderRadius: 13,
justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginTop: 8,
},
dot: {
width: 6,
height: 6,
borderRadius: 3,
marginRight: 6,
},
badgeText: {
fontSize: responsiveFontSize(1.3),
fontFamily: FontsFamily.nunitoSemiBold,
},
statsSection: {
flex: 1,
},
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
gridItem: {
width: '45%',
},
itemLabel: {
fontSize: responsiveFontSize(1.3),
fontFamily: FontsFamily.nunitoRegular,
},
itemValue: {
fontSize: responsiveFontSize(1.7),
fontFamily: FontsFamily.nunitoBold,
marginTop: 2,
}, },
dueBar: { dueBar: {
flexDirection: "row", flexDirection: 'row',
justifyContent: "space-between", alignItems: 'center',
alignItems: "center", justifyContent: 'space-between',
padding: 16, paddingHorizontal: 14,
paddingVertical: 10,
borderTopWidth: 1, borderTopWidth: 1,
}, },
dueBarText: { dueBarLabel: {
fontFamily: FontsFamily.nunitoExtraBold, fontSize: responsiveFontSize(1.3),
fontSize: responsiveWidth(4), fontFamily: FontsFamily.nunitoRegular,
}, },
payAllButton: { dueBarValue: {
flexDirection: "row", fontSize: responsiveFontSize(2),
alignItems: "center", fontFamily: FontsFamily.nunitoExtraBold,
paddingHorizontal: 16, },
paddingVertical: 10, payButton: {
borderRadius: 20, flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 10,
gap: 6, gap: 6,
}, },
payAllButtonText: {
fontFamily: FontsFamily.nunitoBold,
fontSize: responsiveWidth(2.5)
},
payModalOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.3)",
justifyContent: "center",
alignItems: "center",
},
payModalBox: {
width: "90%",
borderRadius: 24,
overflow: "hidden",
alignItems: "center",
shadowColor: "#000",
shadowOpacity: 0.15,
shadowRadius: 24,
elevation: 12,
},
payDialogHeader: {
flexDirection: "row",
alignItems: "center",
width: "100%",
justifyContent: "space-between",
paddingVertical: 14,
paddingHorizontal: 18,
borderBottomWidth: 1,
borderColor: "rgba(255,255,255,0.1)",
},
payDialogTitle: {
fontSize: responsiveWidth(4),
fontFamily: FontsFamily.nunitoBold,
},
payGroupOption: {
flexDirection: "row",
alignItems: "center",
gap: 16,
paddingVertical: 12,
paddingHorizontal: 14,
borderRadius: 12,
marginBottom: 8,
},
payOptionSelected: {
borderWidth: 1,
},
payGroupName: {
fontFamily: FontsFamily.nunitoSemiBold,
fontSize: responsiveWidth(3)
},
payDueText: {
fontSize: responsiveWidth(3),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 2
},
payAmount: {
fontWeight: "bold",
fontSize: responsiveWidth(2.5),
color: "#4CAF50"
},
payDialogFooter: {
flexDirection: "row",
alignItems: "center",
width: "100%",
justifyContent: "space-between",
paddingVertical: 16,
paddingHorizontal: 18,
},
payDialogTotal: {
fontFamily: FontsFamily.nunitoBold,
fontSize: responsiveWidth(3.5)
},
payConfirmButton: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 8,
paddingHorizontal: 18,
borderRadius: 12,
elevation: 4,
gap: 8,
},
payButtonText: { payButtonText: {
color: "#ffffffff", fontSize: responsiveFontSize(1.4),
fontWeight: "bold", fontFamily: FontsFamily.nunitoBold,
fontSize: responsiveWidth(3),
}, },
modalOverlay: { modalOverlay: {
flex: 1, flex: 1,
backgroundColor: "rgba(0,0,0,0.6)", backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: "center", justifyContent: 'flex-end',
alignItems: "center",
}, },
modalBox: { modalContent: {
width: "85%", borderTopLeftRadius: 24,
backgroundColor: "#fff", borderTopRightRadius: 24,
borderRadius: 24, paddingBottom: 24,
padding: 24, maxHeight: '80%',
alignItems: "center",
elevation: 12,
}, },
modalTitle: { modalHeader: {
fontSize: responsiveWidth(2.2),
fontWeight: "700",
color: colors.primary,
marginBottom: 8,
textAlign: 'center',
},
modalText: {
textAlign: "center",
color: "#666",
fontSize: responsiveWidth(1.4),
lineHeight: 20,
marginBottom: 20,
},
modalButtons: {
width: '100%',
gap: 10,
},
button: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'space-between',
paddingVertical: 14, padding: 16,
borderRadius: 12, borderBottomWidth: 1,
gap: 8,
}, },
primaryButton: { modalTitle: {
backgroundColor: colors.primary, fontSize: responsiveFontSize(2.2),
fontFamily: FontsFamily.nunitoBold,
}, },
secondaryButton: { groupsList: {
backgroundColor: "#f5f5f5", padding: 10,
},
groupItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 14,
borderRadius: 16,
borderWidth: 1, borderWidth: 1,
borderColor: '#e0e0e0', marginBottom: 8,
}, },
buttonText: { groupInfo: {
color: "#fff", flex: 1,
fontWeight: "600", marginLeft: 12,
fontSize:responsiveWidth(2.5),
}, },
secondaryText: { groupName: {
color: "#666", fontSize: responsiveFontSize(1.7),
fontFamily: FontsFamily.nunitoSemiBold,
},
groupSub: {
fontSize: responsiveFontSize(1.3),
fontFamily: FontsFamily.nunitoRegular,
},
groupAmount: {
fontSize: responsiveFontSize(1.7),
fontFamily: FontsFamily.nunitoBold,
},
modalFooter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderTopWidth: 1,
},
totalLabel: {
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoRegular,
},
totalValue: {
fontSize: responsiveFontSize(2.2),
fontFamily: FontsFamily.nunitoExtraBold,
},
confirmButton: {
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 12,
minWidth: 160,
alignItems: 'center',
},
confirmText: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoBold,
},
alertOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'center',
alignItems: 'center',
padding: 24,
},
alertBox: {
width: '100%',
borderRadius: 24,
padding: 24,
alignItems: 'center',
},
alertIcon: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
},
alertTitle: {
fontSize: responsiveFontSize(2.5),
fontFamily: FontsFamily.nunitoBold,
marginBottom: 8,
},
alertMsg: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
textAlign: 'center',
lineHeight: 22,
marginBottom: 24,
},
alertButton: {
width: '100%',
paddingVertical: 16,
borderRadius: 12,
alignItems: 'center',
marginBottom: 12,
},
alertButtonText: {
fontSize: responsiveFontSize(1.7),
fontFamily: FontsFamily.nunitoBold,
},
laterButton: {
paddingVertical: 8,
},
laterText: {
fontSize: responsiveFontSize(1.5),
fontFamily: FontsFamily.nunitoMedium,
}, },
}); });
+47 -43
View File
@@ -9,8 +9,9 @@ import {
UIManager, UIManager,
Platform, Platform,
} from 'react-native'; } from 'react-native';
import { Ionicons } from "@react-native-vector-icons/ionicons"; import colors from "../../../components/colors"; // Assuming a colors file import LucideIcon from '../../../components/common/LucideIcon';
import { responsiveFontSize } from 'react-native-responsive-dimensions'; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useTheme } from "../../../hooks/ui/useTheme";
// Enable LayoutAnimation for Android // Enable LayoutAnimation for Android
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
@@ -71,9 +72,18 @@ interface CycleDashboardData {
const formatDate = (dateStr: string | null): string => { const formatDate = (dateStr: string | null): string => {
if (!dateStr) return 'N/A'; if (!dateStr) return 'N/A';
// If dateStr is an array (as per interface), handle it
if (Array.isArray(dateStr)) {
const [year, month, day] = dateStr;
return `${day}-${month}-${year}`;
}
if (typeof dateStr === 'string' && dateStr.includes('-')) {
const [year, month, day] = dateStr.split('-'); const [year, month, day] = dateStr.split('-');
return `${day}-${month}-${year}`; return `${day}-${month}-${year}`;
}
return String(dateStr);
}; };
const formatCurrency = (amount: number): string => { const formatCurrency = (amount: number): string => {
@@ -88,47 +98,56 @@ const formatCurrency = (amount: number): string => {
// --- Child Components --- // --- Child Components ---
const CycleItem = ({ item, isExpanded, onToggle }: { item: Cycle, isExpanded: boolean, onToggle: () => void }) => { const CycleItem = ({ item, isExpanded, onToggle }: { item: Cycle, isExpanded: boolean, onToggle: () => void }) => {
const theme = useTheme();
return ( return (
<View style={styles.cycleCard}> <View style={[styles.cycleCard, { backgroundColor: theme.colors.card }]}>
<TouchableOpacity onPress={onToggle} style={styles.cycleHeader}> <TouchableOpacity onPress={onToggle} style={styles.cycleHeader}>
<View> <View>
<Text style={styles.cycleTitle}>Cycle {item.cycleNumber}</Text> <Text style={[styles.cycleTitle, { color: theme.colors.textPrimary }]}>Cycle {item.cycleNumber}</Text>
<Text style={styles.cycleDateRange}> <Text style={[styles.cycleDateRange, { color: theme.colors.textMuted }]}>
{formatDate(item.cycleStart)} - {formatDate(item.cycleEnd)} {formatDate(item.cycleStart as any)} - {formatDate(item.cycleEnd as any)}
</Text> </Text>
</View> </View>
<Ionicons name={isExpanded ? 'chevron-up' : 'chevron-down'} size={24} color={colors.black} /> <LucideIcon name={isExpanded ? 'chevron-up' : 'chevron-down'} size={24} color={theme.colors.textPrimary} />
</TouchableOpacity> </TouchableOpacity>
{isExpanded && ( {isExpanded && (
<View style={styles.cycleDetails}> <View style={[styles.cycleDetails, { borderTopColor: theme.colors.borderLight }]}>
{/* Withdrawal Info */} {/* Withdrawal Info */}
{item.withdrawal && ( {item.withdrawal && (
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Withdrawal</Text> <Text style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}>Withdrawal</Text>
<View style={styles.withdrawalCard}> <View style={[styles.withdrawalCard, { backgroundColor: theme.colors.primarySoft }]}>
<Ionicons name="arrow-down-circle-outline" size={32} color={colors.Home} /> <LucideIcon name="arrow-down-circle-outline" size={32} color={theme.colors.primary} />
<View style={styles.withdrawalInfo}> <View style={styles.withdrawalInfo}>
<Text style={styles.withdrawalUser}>{item.withdrawal.userName}</Text> <Text style={[styles.withdrawalUser, { color: theme.colors.primary }]}>{item.withdrawal.userName}</Text>
<Text style={styles.withdrawalAmount}>{formatCurrency(item.withdrawal.totalAmount)}</Text> <Text style={[styles.withdrawalAmount, { color: theme.colors.primary }]}>{formatCurrency(item.withdrawal.totalAmount)}</Text>
</View> </View>
<Text style={styles.withdrawalDate}>{formatDate(item.withdrawal.paidDate)}</Text> <Text style={[styles.withdrawalDate, { color: theme.colors.textMuted }]}>{formatDate(item.withdrawal.paidDate as any)}</Text>
</View> </View>
</View> </View>
)} )}
{/* Contributions List */} {/* Contributions List */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Contributions ({item.paidParticipants}/{item.totalParticipants})</Text> <Text style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}>Contributions ({item.paidParticipants}/{item.totalParticipants})</Text>
{item.contributions.map(contribution => ( {item.contributions.map(contribution => (
<View key={contribution.transactionId} style={[styles.contributionItem, { backgroundColor: contribution.status === 'SUCCESS' ? '#D1FAE5' : contribution.status === 'PENDING' ? '#FEF3C7' : '#FEE2E2' }]}> <View key={contribution.transactionId} style={[styles.contributionItem, {
<Ionicons name="person-circle-outline" size={28} color="#475569" /> backgroundColor: contribution.status === 'SUCCESS' ? theme.colors.success + '15' : contribution.status === 'PENDING' ? theme.colors.warning + '15' : theme.colors.error + '15',
borderBottomColor: theme.colors.borderLight
}]}>
<LucideIcon name="person-circle-outline" size={28} color={theme.colors.textSecondary} />
<View style={{ flex: 1, marginLeft: 10 }}> <View style={{ flex: 1, marginLeft: 10 }}>
<Text style={styles.contributorName} numberOfLines={1} > {contribution.userName}</Text> <Text style={[styles.contributorName, { color: theme.colors.textPrimary }]} numberOfLines={1} > {contribution.userName}</Text>
<Text style={styles.contributionDate}>{formatDate(contribution.paidDate)}</Text> <Text style={[styles.contributionDate, { color: theme.colors.textMuted }]}>{formatDate(contribution.paidDate as any)}</Text>
<Text style={{color: contribution.status === 'SUCCESS' ? 'green' : contribution.status === 'PENDING' ? 'orange' : 'red', fontSize:responsiveFontSize(1.5), textTransform:'capitalize'}}>{contribution.status}</Text> <Text style={{
color: contribution.status === 'SUCCESS' ? theme.colors.success : contribution.status === 'PENDING' ? theme.colors.warning : theme.colors.error,
fontSize: responsiveFontSize(1.5),
textTransform: 'capitalize'
}}>{contribution.status}</Text>
</View> </View>
<Text style={styles.contributionAmount}>{formatCurrency(contribution.totalAmount)}</Text> <Text style={[styles.contributionAmount, { color: theme.colors.textPrimary }]}>{formatCurrency(contribution.totalAmount)}</Text>
</View> </View>
))} ))}
</View> </View>
@@ -143,6 +162,7 @@ const CycleItem = ({ item, isExpanded, onToggle }: { item: Cycle, isExpanded: bo
const CycleDashboard = ({ data }: { data: CycleDashboardData }) => { const CycleDashboard = ({ data }: { data: CycleDashboardData }) => {
const [expandedCycle, setExpandedCycle] = useState<number | null>(data.currentCycle); const [expandedCycle, setExpandedCycle] = useState<number | null>(data.currentCycle);
const theme = useTheme();
const handleToggle = (cycleNumber: number) => { const handleToggle = (cycleNumber: number) => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
@@ -152,19 +172,19 @@ const CycleDashboard = ({ data }: { data: CycleDashboardData }) => {
console.log("expanded cycle : ", data) console.log("expanded cycle : ", data)
const renderHeader = () => ( const renderHeader = () => (
<View style={styles.headerContainer}> <View style={styles.headerContainer}>
<Text style={styles.groupName}>{data.groupName}</Text> <Text style={[styles.groupName, { color: theme.colors.textPrimary }]}>{data.groupName}</Text>
<View style={styles.summaryRow}> <View style={styles.summaryRow}>
<View style={styles.summaryBox}> <View style={styles.summaryBox}>
<Text style={styles.summaryLabel}>Total Collected</Text> <Text style={[styles.summaryLabel, { color: theme.colors.textMuted }]}>Total Collected</Text>
<Text style={styles.summaryValue}>{formatCurrency(data.totalCollected)}</Text> <Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>{formatCurrency(data.totalCollected)}</Text>
</View> </View>
<View style={styles.summaryBox}> <View style={styles.summaryBox}>
<Text style={styles.summaryLabel}>Status</Text> <Text style={[styles.summaryLabel, { color: theme.colors.textMuted }]}>Status</Text>
<Text style={[styles.summaryValue, { color: data.status === 'COMPLETED' ? colors.Settings : colors.Home }]}>{data.status}</Text> <Text style={[styles.summaryValue, { color: data.status === 'COMPLETED' ? theme.colors.success : theme.colors.primary }]}>{data.status}</Text>
</View> </View>
<View style={styles.summaryBox}> <View style={styles.summaryBox}>
<Text style={styles.summaryLabel}>Cycles</Text> <Text style={[styles.summaryLabel, { color: theme.colors.textMuted }]}>Cycles</Text>
<Text style={styles.summaryValue}>{data.currentCycle}/{data.totalParticipants}</Text> <Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>{data.currentCycle}/{data.totalParticipants}</Text>
</View> </View>
</View> </View>
</View> </View>
@@ -204,7 +224,6 @@ const styles = StyleSheet.create({
groupName: { groupName: {
fontSize: responsiveFontSize(2.4), fontSize: responsiveFontSize(2.4),
fontWeight: 'bold', fontWeight: 'bold',
color: '#1e293b',
marginBottom: 15, marginBottom: 15,
}, },
summaryRow: { summaryRow: {
@@ -216,17 +235,14 @@ const styles = StyleSheet.create({
}, },
summaryLabel: { summaryLabel: {
fontSize: responsiveFontSize(1.4), fontSize: responsiveFontSize(1.4),
color: '#64748b',
marginBottom: 4, marginBottom: 4,
}, },
summaryValue: { summaryValue: {
fontSize: responsiveFontSize(1.6), fontSize: responsiveFontSize(1.6),
fontWeight: '600', fontWeight: '600',
color: '#0f172a',
}, },
// Cycle Item Card // Cycle Item Card
cycleCard: { cycleCard: {
backgroundColor: '#fff',
borderRadius: 12, borderRadius: 12,
marginBottom: 15, marginBottom: 15,
elevation: 2, elevation: 2,
@@ -245,18 +261,15 @@ const styles = StyleSheet.create({
cycleTitle: { cycleTitle: {
fontSize: responsiveFontSize(1.8), fontSize: responsiveFontSize(1.8),
fontWeight: '600', fontWeight: '600',
color: '#1e293b',
}, },
cycleDateRange: { cycleDateRange: {
fontSize: responsiveFontSize(1.3), fontSize: responsiveFontSize(1.3),
color: '#64748b',
marginTop: 2, marginTop: 2,
}, },
cycleDetails: { cycleDetails: {
paddingHorizontal: 16, paddingHorizontal: 16,
paddingBottom: 16, paddingBottom: 16,
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: '#f1f5f9',
}, },
section: { section: {
marginTop: 16, marginTop: 16,
@@ -264,14 +277,12 @@ const styles = StyleSheet.create({
sectionTitle: { sectionTitle: {
fontSize: responsiveFontSize(1.6), fontSize: responsiveFontSize(1.6),
fontWeight: '600', fontWeight: '600',
color: '#334155',
marginBottom: 12, marginBottom: 12,
}, },
// Withdrawal Card // Withdrawal Card
withdrawalCard: { withdrawalCard: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: colors.pastelPink, // Example color
padding: 12, padding: 12,
borderRadius: 8, borderRadius: 8,
}, },
@@ -282,15 +293,12 @@ const styles = StyleSheet.create({
withdrawalUser: { withdrawalUser: {
fontSize: responsiveFontSize(1.5), fontSize: responsiveFontSize(1.5),
fontWeight: 'bold', fontWeight: 'bold',
color: colors.Home
}, },
withdrawalAmount: { withdrawalAmount: {
fontSize: responsiveFontSize(1.4), fontSize: responsiveFontSize(1.4),
color: colors.Home
}, },
withdrawalDate: { withdrawalDate: {
fontSize: responsiveFontSize(1.2), fontSize: responsiveFontSize(1.2),
color: '#475569'
}, },
// Contribution Item // Contribution Item
contributionItem: { contributionItem: {
@@ -301,20 +309,16 @@ const styles = StyleSheet.create({
borderRadius: 6, borderRadius: 6,
marginBottom: 10, marginBottom: 10,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#f1f5f9',
}, },
contributorName: { contributorName: {
fontSize: responsiveFontSize(1.5), fontSize: responsiveFontSize(1.5),
color: '#1e293b',
}, },
contributionDate: { contributionDate: {
fontSize: responsiveFontSize(1.2), fontSize: responsiveFontSize(1.2),
color: '#64748b',
}, },
contributionAmount: { contributionAmount: {
fontSize: responsiveFontSize(1.5), fontSize: responsiveFontSize(1.5),
fontWeight: '500', fontWeight: '500',
color: '#1e293b',
}, },
}); });
+412 -249
View File
@@ -1,77 +1,37 @@
import React, { useEffect, useState, useCallback } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { import {
FlatList,
RefreshControl,
StyleSheet, StyleSheet,
Text, Text,
View,
TouchableOpacity,
ToastAndroid, ToastAndroid,
RefreshControl, TouchableOpacity,
View,
ScrollView, ScrollView,
Modal, Image,
Modal
} from "react-native"; } from "react-native";
import { useRoute } from "@react-navigation/native"; import LinearGradient from "react-native-linear-gradient";
import AppView from "../../../components/AppView";
import colors from "../../../components/colors"; import { useTheme } from "../../../hooks/ui/useTheme";
import Ionicons from '@react-native-vector-icons/ionicons'; import { useNavigation, useRoute } from "@react-navigation/native";
import Header from "../../../components/common/Header";
import { apiCall } from "../../../utils/ApiHelper"; import { apiCall } from "../../../utils/ApiHelper";
import { BASE_URL, CONTRIBUTE, GROUP_ALL_MEMBER } from "../../../utils/ApiUrls"; import { BASE_URL, CONTRIBUTE, GROUP_ALL_MEMBER } from "../../../utils/ApiUrls";
import LucideIcon from '../../../components/common/LucideIcon';
import { responsiveFontSize } from "react-native-responsive-dimensions";
import ContributionScreen from "../../ContributionScreen"; import ContributionScreen from "../../ContributionScreen";
import CycleDashboard from "./CycleDashboard"; import CycleDashboard from "./CycleDashboard";
import SkeletonLoader from "../../../components/common/SkeletonLoader";
import FontsFamily from "../../../constents/FontsFamily";
const CustomDropdown = ({ items, selectedValue, onValueChange, placeholder = "Select an item" }: any) => { import AppView from "../../../components/AppView";
const [open, setOpen] = useState(false); // import CustomDropdown from "../../../components/CustomDropdown";
const selectedLabel = items.find((item: any) => item.value === selectedValue)?.label || placeholder;
return (
<View style={styles.dropdownWrapper}>
<TouchableOpacity onPress={() => setOpen(true)} style={styles.dropdownHeader}>
<Text style={styles.headerText}>{selectedLabel}</Text>
<Ionicons name='chevron-down' size={22} color={colors.black} />
</TouchableOpacity>
<Modal
transparent
animationType="fade"
visible={open}
onRequestClose={() => setOpen(false)}
>
<TouchableOpacity style={styles.modalOverlay} onPress={() => setOpen(false)}>
<View style={styles.dropdownListContainer}>
<ScrollView>
{items.map((item: any) => (
<TouchableOpacity
key={item.value}
onPress={() => {
onValueChange(item.value);
setOpen(false);
}}
style={[
styles.dropdownItem,
item.value === selectedValue && styles.selectedItem
]}
>
<Text style={styles.itemText}>{item.label}</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
</TouchableOpacity>
</Modal>
</View>
);
};
const GroupDetails = () => { const GroupDetails = () => {
const theme = useTheme();
const route = useRoute<any>(); const route = useRoute<any>();
const navigation = useNavigation<any>();
console.log(" route id : ", route)
const [memberData, setMemberData] = useState([]); const [memberData, setMemberData] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [openModal, setOpenModal] = useState(false); const [openModal, setOpenModal] = useState(false);
@@ -82,7 +42,6 @@ const GroupDetails = () => {
const [cycleItems, setCycleItems] = useState([]); const [cycleItems, setCycleItems] = useState([]);
const [selectedUser, setSelectedUser] = useState() as string | any; const [selectedUser, setSelectedUser] = useState() as string | any;
const group = { const group = {
id: route.params.item?.id, id: route.params.item?.id,
name: route.params.item?.groupName, name: route.params.item?.groupName,
@@ -93,24 +52,18 @@ const GroupDetails = () => {
members: memberData, members: memberData,
}; };
const getPaymentStatus = async () => { const getPaymentStatus = async () => {
const response = await apiCall(`${BASE_URL}payments/group/${group.id || route?.params?.groupId}/payment/paid`, 'GET') as { status: string; data?: { currentCycle: number, allPaid: boolean, selectedUser: string }; message?: string }; const response = await apiCall(`${BASE_URL}payments/group/${group.id || route?.params?.groupId}/payment/paid`, 'GET') as any;
console.log("paymentStatus", response.data)
if (response.status === 'success') { if (response.status === 'success') {
setSelectedUser(response.data); setSelectedUser(response.data);
} }
} }
const getCycleDashboard = async () => { const getCycleDashboard = async () => {
try { try {
const response = await apiCall(`${BASE_URL}payments/groups/${group.id || route?.params?.groupId}/cycle-dashboard`, "GET") as any; const response = await apiCall(`${BASE_URL}payments/groups/${group.id || route?.params?.groupId}/cycle-dashboard`, "GET") as any;
const responseData = response.data; const responseData = response.data;
setCycleDashboardData(responseData || []); setCycleDashboardData(responseData || []);
console.log("cycle data", responseData)
if (responseData?.cycles) { if (responseData?.cycles) {
const items = responseData.cycles.map((cycle: any) => ({ const items = responseData.cycles.map((cycle: any) => ({
label: `Cycle ${cycle.cycleNumber}`, label: `Cycle ${cycle.cycleNumber}`,
@@ -126,18 +79,15 @@ const GroupDetails = () => {
} }
}; };
const fetchGroupDetails = async () => { const fetchGroupDetails = async () => {
try { try {
const res = await apiCall(`${GROUP_ALL_MEMBER}?groupId=${group.id || route?.params?.groupId}`, "GET") as any; const res = await apiCall(`${GROUP_ALL_MEMBER}?groupId=${group.id || route?.params?.groupId}`, "GET") as any;
console.log("Group Members Response: ", res);
setMemberData(res.data); setMemberData(res.data);
} catch (error: any) { } catch (error: any) {
ToastAndroid.show(error?.message || "Failed to fetch group details", ToastAndroid.SHORT); ToastAndroid.show(error?.message || "Failed to fetch group details", ToastAndroid.SHORT);
} }
}; };
const handleContribution = async () => { const handleContribution = async () => {
try { try {
const res = await apiCall(`${CONTRIBUTE}${group.id || route?.params?.groupId}/calculate-contribution`, 'GET') as any; const res = await apiCall(`${CONTRIBUTE}${group.id || route?.params?.groupId}/calculate-contribution`, 'GET') as any;
@@ -147,7 +97,6 @@ const GroupDetails = () => {
} }
}; };
const fetchAllData = useCallback(async () => { const fetchAllData = useCallback(async () => {
setLoading(true); setLoading(true);
await Promise.all([ await Promise.all([
@@ -158,7 +107,6 @@ const GroupDetails = () => {
setLoading(false); setLoading(false);
}, [group.id || route?.params?.groupId]); }, [group.id || route?.params?.groupId]);
useEffect(() => { useEffect(() => {
if (group.id || route?.params?.groupId) { if (group.id || route?.params?.groupId) {
fetchAllData(); fetchAllData();
@@ -192,92 +140,200 @@ const GroupDetails = () => {
const selectedCycleData = cycleDashboardData?.cycles?.find((c: any) => c.cycleNumber === selectedCycleNumber); const selectedCycleData = cycleDashboardData?.cycles?.find((c: any) => c.cycleNumber === selectedCycleNumber);
// const selectedCycleData = cycleDashboardData?.cycles.find((c: any) => c.cycleNumber === selectedCycleNumber);
const renderSkeletons = () => (
<View style={styles.skeletonContainer}>
<SkeletonLoader width="100%" height={200} style={{ borderRadius: 24, marginBottom: 20 }} />
<SkeletonLoader width="100%" height={60} style={{ borderRadius: 12, marginBottom: 10 }} />
<SkeletonLoader width="100%" height={100} style={{ borderRadius: 16 }} />
</View>
);
return ( return (
<AppView paddingHorizontal={0} style={{ flex: 1 }}> <AppView paddingHorizontal={0} style={{ flex: 1, backgroundColor: theme.colors.background }}>
{selectedUser?.firstName && ( {/* Standard Header */}
<View style={styles.floatingWinnerContainer}> <View style={[styles.header, { backgroundColor: theme.colors.background }]}>
<Text style={styles.floatingWinnerText}> <TouchableOpacity onPress={() => navigation.goBack()} style={[styles.iconButton, { backgroundColor: theme.colors.card }]}>
Winner: {`${selectedUser.firstName} ${selectedUser.lastName || ''}`} <LucideIcon name="arrow-back" size={24} color={theme.colors.textPrimary} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: theme.colors.textPrimary, fontFamily: FontsFamily.nunitoBold }]}>
Circle Wallet
</Text> </Text>
<View style={styles.iconButtonSpacer} />
</View> </View>
)}
<ScrollView <ScrollView
refreshControl={ refreshControl={
<RefreshControl refreshing={refreshing || loading} onRefresh={onRefresh} colors={[colors.black]} /> <RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={[theme.colors.primary]} tintColor={theme.colors.onPrimary} />
} }
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
> >
<Header title={group.name || route.name} /> {loading ? renderSkeletons() : (
<Text style={styles.description}>{group.description || route.name}</Text> <View style={styles.contentPadding}>
{/* Premium Digital Wallet Card */}
<LinearGradient
colors={['#1A1A2E', '#16213E', '#0F3460']}
start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }}
style={styles.walletCard}
>
{/* Background Elements for Depth - Removed as per user request */}
{/* <View style={styles.cardCircle1} />
<View style={styles.cardCircle2} /> */}
<View style={styles.statsRow}> <View style={styles.cardHeader}>
<View style={styles.statBox}> <View>
<Text style={styles.statValue}>{memberData.length ?? 0}</Text> <Text style={[styles.cardLabel, { fontFamily: FontsFamily.nunitoSemiBold }]}>Total Balance</Text>
<Text style={styles.statLabel}>Members</Text> <Text style={[styles.cardBalance, { fontFamily: FontsFamily.nunitoExtraBold }]}>
₹{cycleDashboardData?.contributionAmount * memberData.length || group.goal}
</Text>
</View> </View>
<View style={styles.statBox}> <View style={styles.iconContainer}>
<Text style={styles.statValue}>₹{cycleDashboardData?.contributionAmount || group.collected}</Text> <LucideIcon name="wallet-outline" size={24} color="#fff" />
<Text style={styles.statLabel}>Contribution</Text>
</View>
<View style={styles.statBox}>
<Text style={styles.statValue}>₹{cycleDashboardData?.contributionAmount * memberData.length || group.goal}</Text>
<Text style={styles.statLabel}>Total Amount</Text>
</View> </View>
</View> </View>
<View style={styles.cardFooter}>
<View>
<Text style={[styles.cardFooterLabel, { fontFamily: FontsFamily.nunitoRegular }]}>Group Name</Text>
<Text style={[styles.cardFooterValue, { fontFamily: FontsFamily.nunitoBold }]}>{group.name}</Text>
</View>
<View style={{ alignItems: 'flex-end' }}>
<View style={[styles.cardStatusBadge, {
backgroundColor: group.status === 'ACTIVE' ? 'rgba(34, 197, 94, 0.2)' : 'rgba(255, 255, 255, 0.15)',
borderColor: group.status === 'ACTIVE' ? '#22C55E' : 'rgba(255, 255, 255, 0.3)',
borderWidth: 1
}]}>
<Text style={[styles.cardStatusText, {
fontFamily: FontsFamily.nunitoBold,
color: group.status === 'ACTIVE' ? '#22C55E' : '#fff'
}]}>
{cycleDashboardData?.status || group?.status}
</Text>
</View>
</View>
</View>
</LinearGradient>
{/* Winner Notification Strip */}
{selectedUser?.firstName && (
<View style={[styles.winnerStrip, { backgroundColor: theme.colors.success + '20', borderColor: theme.colors.success }]}>
<View style={[styles.winnerIconBox, { backgroundColor: theme.colors.success }]}>
<LucideIcon name="trophy" size={16} color="#fff" />
</View>
<View style={styles.winnerContent}>
<Text style={[styles.winnerText, { color: theme.colors.textPrimary, fontFamily: FontsFamily.nunitoSemiBold }]}>
<Text style={{ fontFamily: FontsFamily.nunitoBold }}>Cycle Winner: </Text>
{selectedUser.firstName} {selectedUser.lastName}
</Text>
</View>
<LucideIcon name="chevron-forward" size={16} color={theme.colors.success} />
</View>
)}
{/* Stats Row (Mini) */}
<View style={styles.miniStatsRow}>
<View style={[styles.miniStatItem, { backgroundColor: theme.colors.card }]}>
<View style={[styles.iconCircle, { backgroundColor: theme.colors.primary + '20' }]}>
<LucideIcon name="people" size={18} color={theme.colors.primary} />
</View>
<View>
<Text style={[styles.miniStatValue, { color: theme.colors.textPrimary, fontFamily: FontsFamily.nunitoBold }]}>{memberData.length}</Text>
<Text style={[styles.miniStatLabel, { color: theme.colors.textMuted, fontFamily: FontsFamily.nunitoRegular }]}>Members</Text>
</View>
</View>
<View style={[styles.miniStatItem, { backgroundColor: theme.colors.card }]}>
<View style={[styles.iconCircle, { backgroundColor: theme.colors.success + '20' }]}>
<LucideIcon name="cash" size={18} color={theme.colors.success} />
</View>
<View>
<Text style={[styles.miniStatValue, { color: theme.colors.textPrimary, fontFamily: FontsFamily.nunitoBold }]}>₹{cycleDashboardData?.contributionAmount || group.collected}</Text>
<Text style={[styles.miniStatLabel, { color: theme.colors.textMuted, fontFamily: FontsFamily.nunitoRegular }]}>Per Head</Text>
</View>
</View>
</View>
{/* Cycle Timeline */}
{cycleItems.length > 0 && ( {cycleItems.length > 0 && (
<CustomDropdown <View style={styles.sectionContainer}>
<Text style={[styles.sectionHeader, { color: theme.colors.textPrimary, fontFamily: FontsFamily.nunitoBold }]}>Cycle Statement</Text>
{/* <CustomDropdown
items={cycleItems} items={cycleItems}
selectedValue={selectedCycleNumber} selectedValue={selectedCycleNumber}
onValueChange={setSelectedCycleNumber} onValueChange={setSelectedCycleNumber}
placeholder="Select a Cycle" placeholder="Select Statement Cycle"
/> /> */}
)}
{selectedCycleData && cycleDashboardData && ( {selectedCycleData && cycleDashboardData && (
<View style={{ marginTop: 12 }}>
<CycleDashboard data={{ ...cycleDashboardData, cycles: [selectedCycleData] }} /> <CycleDashboard data={{ ...cycleDashboardData, cycles: [selectedCycleData] }} />
</View>
)}
</View>
)} )}
{/* Members List - Contact Style */}
<View style={styles.sectionContainer}>
<Text style={[styles.sectionHeader, { color: theme.colors.textPrimary, fontFamily: FontsFamily.nunitoBold }]}>Circle Contacts</Text>
<View style={styles.membersHeader}> <View style={[styles.contactsContainer, { backgroundColor: theme.colors.card }]}>
<Text style={styles.sectionTitle}>Members</Text> {memberData.map((member: any, index) => (
<View style={styles.statusBadge}> <View key={member.userId} style={[
<Text style={styles.statusText}>{cycleDashboardData?.status || group?.status}</Text> styles.contactRow,
index !== memberData.length - 1 && { borderBottomWidth: 1, borderBottomColor: theme.colors.borderLight }
]}>
<View style={[styles.contactAvatar, { backgroundColor: theme.colors.primarySoft }]}>
<Text style={[styles.avatarInitials, { color: theme.colors.primary, fontFamily: FontsFamily.nunitoBold }]}>
{member.firstName?.charAt(0)}{member.lastName?.charAt(0)}
</Text>
{member.role === "Organizer" && (
<View style={styles.contactBadge}>
<LucideIcon name="star" size={8} color="#fff" />
</View> </View>
)}
</View> </View>
<View style={styles.contactInfo}>
<Text style={[styles.contactName, { color: theme.colors.textPrimary, fontFamily: FontsFamily.nunitoBold }]}>
{memberData.map((member: any) => ( {member.firstName} {member.lastName}
<View key={member.userId} style={styles.memberCard}> </Text>
<Ionicons name="person" size={40} color={colors.black} /> <Text style={[styles.contactRole, { color: theme.colors.textMuted, fontFamily: FontsFamily.nunitoRegular }]}>
<View style={styles.memberInfo}> {member.role === "Organizer" ? "Group Admin" : "Member"}
<Text style={styles.memberName}>
{member.firstName} {member.role === "Organizer" ? "(Organizer)" : ""}
</Text> </Text>
</View> </View>
<TouchableOpacity style={[styles.contactAction, { backgroundColor: theme.colors.background }]}>
<LucideIcon name="chatbubble-ellipses-outline" size={18} color={theme.colors.textMuted} />
</TouchableOpacity>
</View> </View>
))} ))}
</View>
</View>
{/* Contribute Button */}
{(cycleDashboardData?.status === 'ACTIVE' || cycleDashboardData?.status === 'DELAY') && !payable?.hasPaid && ( {(cycleDashboardData?.status === 'ACTIVE' || cycleDashboardData?.status === 'DELAY') && !payable?.hasPaid && (
<TouchableOpacity onPress={() => setOpenModal(true)} style={styles.button}> <TouchableOpacity
<Text style={styles.buttonText}>Contribute</Text> onPress={() => setOpenModal(true)}
style={[styles.payButton, { backgroundColor: theme.colors.primary }]}
>
<LucideIcon name="card" size={20} color="#fff" style={{ marginRight: 8 }} />
<Text style={[styles.payButtonText, { color: theme.colors.onPrimary, fontFamily: FontsFamily.nunitoBold }]}>
Pay Contribution
</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
</View>
)}
</ScrollView> </ScrollView>
{openModal && ( {openModal && (
<ContributionScreen <ContributionScreen
data={payable} data={payable}
visible={openModal} visible={openModal}
onClose={() => setOpenModal(false)} onClose={() => setOpenModal(false)}
onConfirm={() => { onConfirm={() => {
confirmPayment();
setOpenModal(false); setOpenModal(false);
navigation.navigate('PaymentMode' as any, {
groupId: group.id || route?.params?.groupId,
});
}} }}
/> />
)} )}
@@ -285,155 +341,262 @@ const GroupDetails = () => {
); );
}; };
export default GroupDetails; export default GroupDetails;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
dropdownWrapper: { header: {
marginHorizontal: 15,
marginBottom: 20,
},
dropdownHeader: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', paddingHorizontal: 16,
padding: 12,
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
backgroundColor: '#fff',
},
headerText: {
fontSize: 15,
color: colors.black,
},
modalOverlay: {
flex: 1,
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.4)',
padding: 20,
},
dropdownListContainer: {
backgroundColor: '#fff',
borderRadius: 8,
padding: 10,
maxHeight: 300,
},
dropdownItem: {
paddingVertical: 12, paddingVertical: 12,
paddingHorizontal: 10,
}, },
selectedItem: { iconButton: {
backgroundColor: colors.pastelOrange, width: 40,
borderRadius: 6, height: 40,
},
itemText: {
fontSize: 15,
color: colors.black,
},
description: {
fontSize: 14,
color: "#475569",
marginBottom: 20,
lineHeight: 20,
paddingHorizontal: 15,
},
statsRow: {
flexDirection: "row",
justifyContent: "space-around",
marginBottom: 20,
paddingHorizontal: 15,
},
statBox: {
alignItems: 'center',
flex: 1,
},
statValue: {
fontSize: 18,
fontWeight: '700',
color: '#1e293b',
},
statLabel: {
fontSize: 12,
color: '#64748b',
marginTop: 4,
},
membersHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: 'center',
paddingHorizontal: 15,
marginTop: 20,
marginBottom: 10,
},
sectionTitle: {
fontSize: 18,
fontWeight: "600",
color: "#0f172a",
},
statusBadge: {
backgroundColor: colors.pastelOrange,
borderRadius: 20, borderRadius: 20,
paddingHorizontal: 12, justifyContent: 'center',
paddingVertical: 6,
elevation: 2,
},
statusText: {
fontSize: 14,
fontWeight: '600',
color: colors.black,
},
memberCard: {
flexDirection: "row",
alignItems: 'center', alignItems: 'center',
backgroundColor: '#fff',
padding: 12,
borderRadius: 12,
marginBottom: 10,
elevation: 2,
marginHorizontal: 15,
}, },
memberInfo: { iconButtonSpacer: {
marginLeft: 10, width: 40,
}, },
memberName: { headerTitle: {
fontSize: 15, fontSize: responsiveFontSize(2),
fontWeight: "600",
color: "#1e293b",
},
button: {
backgroundColor: colors.pastelOrange,
marginHorizontal: 15,
marginTop: 20,
marginBottom: 30,
elevation: 5,
paddingVertical: 14,
borderRadius: 10,
alignItems: "center",
},
buttonText: {
fontSize: 16,
fontWeight: "600",
color: "#000",
}, },
scrollContent: { scrollContent: {
paddingBottom: 50, paddingBottom: 100,
}, },
floatingWinnerContainer: { contentPadding: {
position: 'absolute', paddingHorizontal: 16,
bottom: 80, paddingTop: 10,
alignSelf: 'center', },
backgroundColor: colors.pastelBlue, skeletonContainer: {
paddingHorizontal: 15, paddingHorizontal: 16,
paddingVertical: 8, marginTop: 20,
borderRadius: 20, },
elevation: 5, // Wallet Card
walletCard: {
borderRadius: 24,
padding: 24,
position: 'relative',
overflow: 'hidden',
minHeight: 180,
justifyContent: 'space-between',
elevation: 10,
shadowColor: '#16213E',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.4,
shadowRadius: 16,
marginBottom: 24,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
zIndex: 10, zIndex: 10,
}, },
floatingWinnerText: { iconContainer: {
color: colors.black, width: 48,
fontWeight: 'bold', height: 48,
fontSize: 16, borderRadius: 24,
backgroundColor: 'rgba(255,255,255,0.1)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.2)',
},
cardLabel: {
color: 'rgba(255,255,255,0.6)',
fontSize: responsiveFontSize(1.5),
textTransform: 'uppercase',
letterSpacing: 1.5,
marginBottom: 4,
},
cardBalance: {
color: '#fff',
fontSize: responsiveFontSize(4),
marginVertical: 10,
},
cardFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
},
cardFooterLabel: {
color: 'rgba(255,255,255,0.6)',
fontSize: responsiveFontSize(1.4),
marginBottom: 4,
},
cardFooterValue: {
color: '#fff',
fontSize: responsiveFontSize(2),
},
cardStatusBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20, // Pill shape
},
cardStatusText: {
fontSize: responsiveFontSize(1.4),
textTransform: 'uppercase',
letterSpacing: 0.5,
},
cardCircle1: {
position: 'absolute',
top: -60,
right: -60,
width: 240,
height: 240,
borderRadius: 120,
backgroundColor: 'rgba(255,255,255,0.03)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.05)',
},
cardCircle2: {
position: 'absolute',
bottom: -40,
left: -40,
width: 180,
height: 180,
borderRadius: 90,
backgroundColor: 'rgba(113, 31, 247, 0.1)', // Hint of primary color
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.05)',
},
// Winner Strip
winnerStrip: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderRadius: 16,
marginBottom: 20,
borderWidth: 1,
},
winnerIconBox: {
width: 32,
height: 32,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
winnerContent: {
flex: 1,
},
winnerText: {
fontSize: responsiveFontSize(1.8),
},
// Mini Stats
miniStatsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 24,
},
miniStatItem: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderRadius: 16,
marginHorizontal: 4,
elevation: 2,
shadowOpacity: 0.1,
shadowRadius: 4,
},
iconCircle: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
miniStatValue: {
fontSize: responsiveFontSize(2),
},
miniStatLabel: {
fontSize: responsiveFontSize(1.4),
},
// Generic Section
sectionContainer: {
marginBottom: 24,
},
sectionHeader: {
fontSize: responsiveFontSize(2),
marginBottom: 12,
paddingLeft: 4,
},
// Contacts List
contactsContainer: {
borderRadius: 20,
overflow: 'hidden',
elevation: 2,
shadowOpacity: 0.1,
},
contactRow: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
},
contactAvatar: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
avatarInitials: {
fontSize: responsiveFontSize(1.8),
},
contactBadge: {
position: 'absolute',
bottom: 0,
right: 0,
backgroundColor: '#FFD700', // Gold for organizer
width: 16,
height: 16,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1.5,
borderColor: '#fff',
},
contactInfo: {
flex: 1,
},
contactName: {
fontSize: responsiveFontSize(1.8),
marginBottom: 2,
},
contactRole: {
fontSize: responsiveFontSize(1.4),
},
contactAction: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: 'center',
alignItems: 'center',
},
// Pay Button
payButton: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 18,
borderRadius: 50, // Pill shape
elevation: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
marginBottom: 20,
},
payButtonText: {
fontSize: responsiveFontSize(2),
}, },
}); });
File diff suppressed because it is too large Load Diff
+345 -123
View File
@@ -1,178 +1,400 @@
// src/screens/ManagementScreen.tsx import React, { useState, useMemo, useEffect, useCallback } from 'react';
import React, { useState, useCallback, FC, useEffect } from "react";
import { import {
View, View,
Text, Text,
StyleSheet, StyleSheet,
FlatList, FlatList,
TouchableOpacity, TouchableOpacity,
ActivityIndicator, ScrollView,
Alert,
ToastAndroid, ToastAndroid,
} from "react-native"; RefreshControl,
import { useNavigation, useFocusEffect, useRoute, RouteProp } from "@react-navigation/native"; } from 'react-native';
import { StackNavigationProp } from "@react-navigation/stack"; import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native';
import { APPROVE_REQUEST_URL, DECLINE_REQUEST_URL, GET_JOINED_GROUP_URL, MY_GROUPS_URL, REQUESTS_URL, USER_REQUEST } from "../../utils/ApiUrls"; import { useSelector } from 'react-redux';
import colors from "../../components/colors"; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import AppView from "../../components/AppView"; import LucideIcon from '../../components/common/LucideIcon';
import { apiCall } from "../../utils/ApiHelper"; import {
import { MyGroup, JoinedGroup, JoinRequest, ApiResponse, UserJoinRequest } from '../../types'; APPROVE_REQUEST_URL,
import { MyGroupCard } from "../../components/MyGroupCard"; DECLINE_REQUEST_URL,
import { JoinedGroupCard } from "../../components/JoinedGroupCard"; GET_JOINED_GROUP_URL,
import { RequestCard } from "../../components/RequestCard"; MY_GROUPS_URL,
import { useSelector } from "react-redux"; REQUESTS_URL,
import { UserRequested } from "../../components/UserRequested"; USER_REQUEST
import { RootTabParamList } from "../../navigation/BottomTabs"; } from '../../utils/ApiUrls';
import { RootStackParamList } from "../../navigation/AppNavigator"; import AppView from '../../components/AppView';
import { useTheme } from '../../hooks/ui/useTheme';
import { useFetch } from '../../hooks/core/useFetch';
import { useUpdate } from '../../hooks/core/useUpdate';
import { MyGroup, JoinRequest } from '../../types';
import MyGroupCard from '../../components/MyGroupCard';
import { JoinedGroupCard } from '../../components/JoinedGroupCard';
import { RequestCard } from '../../components/RequestCard';
import { UserRequested } from '../../components/UserRequested';
import SkeletonLoader from '../../components/common/SkeletonLoader';
import FontsFamily from '../../constents/FontsFamily';
import { RootTabParamList } from '../../navigation/BottomTabs';
// --- TYPE DEFINITIONS ---
type GroupManagementNavProp = StackNavigationProp<RootStackParamList, 'ManagementTabs'>;
type ManagementRouteProp = RouteProp<RootTabParamList, 'Management'>; type ManagementRouteProp = RouteProp<RootTabParamList, 'Management'>;
type ActiveTab = "My Groups" | "Joined Groups" | "Requests" | "Your Requests"; type ActiveTab = 'My Groups' | 'Joined Groups' | 'Requests' | 'Your Requests';
type GroupStatusFilter = 'PENDING' | 'APPROVED' | 'ACTIVE' | 'DELAY' | 'COMPLETED' | 'REJECTED' | 'CANCELLED';
const ManagementScreen: FC = () => { const GROUP_STATUS_FILTERS: GroupStatusFilter[] = [
'PENDING',
'APPROVED',
'ACTIVE',
'DELAY',
'COMPLETED',
'REJECTED',
'CANCELLED',
];
const JOINED_GROUP_STATUS_FILTERS: GroupStatusFilter[] = [
'ACTIVE',
'DELAY',
'COMPLETED',
];
const getListFromResponse = <T,>(response: any): T[] => {
if (!response) return [];
if (Array.isArray(response?.data?.content)) return response.data.content;
if (Array.isArray(response?.data)) return response.data;
return [];
};
const ManagementScreen: React.FC = () => {
const theme = useTheme();
const user = useSelector((state: any) => state.user); const user = useSelector((state: any) => state.user);
const navigation = useNavigation<GroupManagementNavProp>(); const navigation = useNavigation<any>();
const route = useRoute<ManagementRouteProp>(); const route = useRoute<ManagementRouteProp>();
const [activeTab, setActiveTab] = useState<ActiveTab>(user.role === "USER" ? "Joined Groups" : "My Groups");
const [myGroups, setMyGroups] = useState<MyGroup[]>([]);
const [joinedGroups, setJoinedGroups] = useState<JoinedGroup[]>([]);
const [requests, setRequests] = useState<JoinRequest[]>([]);
const [userRequest, setUserRequest] = useState<UserJoinRequest[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<ActiveTab>(
user.role === 'ORGANIZER' ? 'My Groups' : 'Joined Groups'
);
const [myGroupStatusFilter, setMyGroupStatusFilter] = useState<GroupStatusFilter>('APPROVED');
const [joinedGroupStatusFilter, setJoinedGroupStatusFilter] = useState<GroupStatusFilter>('ACTIVE');
useEffect(() => { useEffect(() => {
const passedTab = route.params?.actionTab; if (!route.params?.actionTab) return;
if (passedTab) { const routeTab = route.params.actionTab as ActiveTab;
const availableTabs = user.role === 'ORGANIZER' ? ["My Groups", "Joined Groups", "Requests"] : ["Joined Groups", "Your Requests"]; setActiveTab(routeTab);
if (availableTabs.includes(passedTab)) { }, [route.params?.actionTab]);
setActiveTab(passedTab);
}
}
}, [route.params?.actionTab, user.role]);
const myGroupsParams = useMemo(
() => ({ status: myGroupStatusFilter, page: 0, size: 10 }),
[myGroupStatusFilter]
);
const fetchData = useCallback(async () => { const joinedGroupsParams = useMemo(
() => ({ status: joinedGroupStatusFilter, page: 0, size: 10 }),
[joinedGroupStatusFilter]
);
if (loading) return; // Data Fetching
const { data: myGroupsRes, isLoading: myLoading, refetch: refetchMy } = useFetch<any>(
MY_GROUPS_URL,
myGroupsParams,
{ enabled: user.role === 'ORGANIZER' }
);
const { data: joinedRes, isLoading: joinedLoading, refetch: refetchJoined } = useFetch<any>(
GET_JOINED_GROUP_URL,
joinedGroupsParams
);
const { data: requestsRes, isLoading: requestsLoading, refetch: refetchRequests } = useFetch<any>(
REQUESTS_URL,
undefined,
{ enabled: user.role === 'ORGANIZER' }
);
const { data: userReqRes, isLoading: userReqLoading, refetch: refetchUserReq } = useFetch<any>(
USER_REQUEST,
undefined,
{ enabled: user.role !== 'ORGANIZER' }
);
setLoading(true); const { mutate: updateRequest } = useUpdate<any, any>('');
setError(null);
try {
if (user.role === "ORGANIZER") {
const [myGroupsRes, joinedGroupsRes, requestsRes] = await Promise.all([
apiCall(MY_GROUPS_URL, "GET") as Promise<ApiResponse<MyGroup[]>>,
apiCall(GET_JOINED_GROUP_URL, "GET") as Promise<ApiResponse<JoinedGroup[]>>,
apiCall(REQUESTS_URL, "GET") as Promise<ApiResponse<JoinRequest[]>>,
]);
setMyGroups(myGroupsRes.data || []);
setJoinedGroups(joinedGroupsRes.data || []);
setRequests(requestsRes.data || []);
} else {
const [joinedGroupsRes, userRequestRes] = await Promise.all([
apiCall(GET_JOINED_GROUP_URL, "GET") as Promise<ApiResponse<JoinedGroup[]>>,
apiCall(USER_REQUEST, "GET") as Promise<ApiResponse<UserJoinRequest[]>>,
]);
console.log("GET_JOINED_GROUP_URL",joinedGroupsRes)
setJoinedGroups(joinedGroupsRes.data || []);
setUserRequest(userRequestRes.data || []);
}
} catch (e) {
console.error("Fetch Data Error:", e);
setError("Failed to fetch data. Please pull to refresh.");
} finally {
setLoading(false);
}
}, [user.role]); const myGroups = useMemo(() => getListFromResponse<MyGroup>(myGroupsRes), [myGroupsRes]);
const joinedGroups = useMemo(() => getListFromResponse<any>(joinedRes), [joinedRes]);
const incomingRequests = useMemo(() => getListFromResponse<JoinRequest>(requestsRes), [requestsRes]);
const myRequests = useMemo(() => getListFromResponse<any>(userReqRes), [userReqRes]);
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(() => {
setRefreshing(true);
Promise.all([
refetchMy(),
refetchJoined(),
refetchRequests(),
refetchUserReq(),
]).finally(() => {
setRefreshing(false);
});
}, [refetchMy, refetchJoined, refetchRequests, refetchUserReq]);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
fetchData(); onRefresh();
}, [fetchData]) }, [])
); );
const handleViewGroup = (item: JoinedGroup) => { const tabs: ActiveTab[] = useMemo(() =>
user.role === 'ORGANIZER'
? ['My Groups', 'Joined Groups', 'Requests']
: ['Joined Groups', 'Your Requests'],
[user.role]);
const handleApprove = (req: JoinRequest) => {
updateRequest({ resource: `${APPROVE_REQUEST_URL}${req.requestId}/approve`, payload: {} } as any, {
onSuccess: () => {
ToastAndroid.show('Request approved successfully', ToastAndroid.SHORT);
refetchRequests();
},
onError: (err) => ToastAndroid.show(err.message || 'Failed to approve', ToastAndroid.SHORT),
});
}; };
const handleAction = async (url: string, successMessage: string, failureMessage: string) => { const handleDecline = (req: JoinRequest) => {
try { updateRequest({ resource: `${DECLINE_REQUEST_URL}${req.requestId}/reject?reason=/`, payload: {} } as any, {
// No need to set loading here if the whole screen is already loading onSuccess: () => {
const res = await apiCall(url, "PUT", {}) as ApiResponse<null> | null; ToastAndroid.show('Request declined', ToastAndroid.SHORT);
if (res && res.statusCode === 200) { refetchRequests();
ToastAndroid.show("Success", ToastAndroid.SHORT); },
fetchData(); // Refresh data after action onError: (err) => ToastAndroid.show(err.message || 'Failed to decline', ToastAndroid.SHORT),
} else { });
throw new Error(res?.message || failureMessage);
}
} catch (err: any) {
console.error("Action Error:", err);
ToastAndroid.show(err.message || failureMessage, ToastAndroid.SHORT);
}
}; };
const handleApprove = (req: JoinRequest) => handleAction(APPROVE_REQUEST_URL + req.requestId + "/approve", `${req.userName} has been added.`, "Failed to approve request.");
const handleDecline = (req: JoinRequest) => handleAction(DECLINE_REQUEST_URL + req.requestId + "/reject?reason=/", `Request from ${req.userName} has been declined.`, "Failed to decline request.");
const handleManageGroup = (group: MyGroup) => navigation.navigate('ManagementTabs', { groupId: group.id }); const handleManageGroup = (group: MyGroup) => navigation.navigate('ManagementTabs', { groupId: group.id });
const renderContent = () => { const renderSkeletons = () => (
if (error) return <Text style={styles.emptyText}>{error}</Text>; <View style={styles.skeletonContainer}>
{[1, 2, 3].map(i => (
if (loading && myGroups.length === 0 && joinedGroups.length === 0 && requests.length === 0) { <SkeletonLoader key={i} width="100%" height={120} style={{ marginBottom: 16, borderRadius: 16 }} />
return <ActivityIndicator size="large" color={colors.primary} style={styles.loader} />; ))}
} </View>
);
const getFilteredData = () => {
switch (activeTab) { switch (activeTab) {
case "My Groups": case 'My Groups': return myGroups;
return <FlatList data={myGroups} renderItem={({ item }) => <MyGroupCard item={item} onManage={handleManageGroup} />} keyExtractor={(item) => item.id} ListEmptyComponent={<Text style={styles.emptyText}>You haven't created any groups yet.</Text>} onRefresh={fetchData} refreshing={loading} contentContainerStyle={{ paddingBottom: 100 }} />; case 'Joined Groups': return joinedGroups;
case "Joined Groups": case 'Requests': return incomingRequests;
return <FlatList data={joinedGroups} renderItem={({ item }) => <JoinedGroupCard item={item} onViewPress={() => { case 'Your Requests': return myRequests;
handleViewGroup(item)}} />} keyExtractor={(item) => item.id} ListEmptyComponent={<Text style={styles.emptyText}>You haven't joined any groups yet.</Text>} onRefresh={fetchData} refreshing={loading} contentContainerStyle={{ paddingBottom: 100 }} />; default: return [];
case "Requests":
return <FlatList data={requests} renderItem={({ item }) => <RequestCard item={item} onApprove={handleApprove} onDecline={handleDecline} />} keyExtractor={(item) => item.requestId} ListEmptyComponent={<Text style={styles.emptyText}>No pending join requests.</Text>} onRefresh={fetchData} refreshing={loading} contentContainerStyle={{ paddingBottom: 100 }} />;
case "Your Requests":
return <FlatList data={userRequest} renderItem={({ item }) => <UserRequested item={item} />} keyExtractor={(item) => item.requestId} ListEmptyComponent={<Text style={styles.emptyText}>You haven't sent any join requests.</Text>} onRefresh={fetchData} refreshing={loading} contentContainerStyle={{ paddingBottom: 100 }} />;
default: return null;
} }
}; };
const tabs: ActiveTab[] = user.role === 'ORGANIZER' ? ["My Groups", "Joined Groups", "Requests"] : ["Joined Groups", "Your Requests"]; const isLoading = myLoading || joinedLoading || requestsLoading || userReqLoading;
return ( return (
<AppView style={styles.container} paddingHorizontal={0}> <AppView paddingHorizontal={0}>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}>Group Management</Text> <Text style={[styles.title, { color: theme.colors.textPrimary }]}>Account Hub</Text>
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>Manage your circles and requests</Text>
</View> </View>
<View style={styles.segmentedControl}>
<View style={[styles.tabContainer, { backgroundColor: theme.colors.borderLight }]}>
{tabs.map((tab) => ( {tabs.map((tab) => (
<TouchableOpacity key={tab} style={[styles.segmentButton, activeTab === tab && styles.segmentActive]} onPress={() => setActiveTab(tab)}> <TouchableOpacity
<Text style={[styles.segmentText, activeTab === tab && styles.segmentTextActive]}>{tab}</Text> key={tab}
onPress={() => setActiveTab(tab)}
style={[
styles.tab,
activeTab === tab && { backgroundColor: theme.colors.card, borderRadius: 12, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 2 }
]}
>
<Text style={[
styles.tabText,
{ color: activeTab === tab ? theme.colors.textPrimary : theme.colors.textMuted },
activeTab === tab && { fontFamily: FontsFamily.nunitoBold }
]}>
{tab}
</Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>
<View style={styles.contentArea}>
{renderContent()} {user.role === 'ORGANIZER' && activeTab === 'My Groups' && (
<View style={styles.statusFilterContainer}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.statusFilterRow}
>
{GROUP_STATUS_FILTERS.map((status, index) => {
const isActive = myGroupStatusFilter === status;
return (
<TouchableOpacity
key={status}
onPress={() => setMyGroupStatusFilter(status)}
style={[
styles.statusFilterChip,
index !== GROUP_STATUS_FILTERS.length - 1 && styles.statusFilterChipSpaced,
{
borderColor: isActive ? theme.colors.primary : theme.colors.border,
backgroundColor: isActive ? theme.colors.primarySoft : theme.colors.surface,
},
]}
>
<Text
style={[
styles.statusFilterText,
{
color: isActive ? theme.colors.textPrimary : theme.colors.textSecondary,
},
]}
>
{status}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
</View> </View>
)}
{activeTab === 'Joined Groups' && (
<View style={styles.statusFilterContainer}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.statusFilterRow}
>
{JOINED_GROUP_STATUS_FILTERS.map((status, index) => {
const isActive = joinedGroupStatusFilter === status;
return (
<TouchableOpacity
key={status}
onPress={() => setJoinedGroupStatusFilter(status)}
style={[
styles.statusFilterChip,
index !== JOINED_GROUP_STATUS_FILTERS.length - 1 && styles.statusFilterChipSpaced,
{
borderColor: isActive ? theme.colors.primary : theme.colors.border,
backgroundColor: isActive ? theme.colors.primarySoft : theme.colors.surface,
},
]}
>
<Text
style={[
styles.statusFilterText,
{
color: isActive ? theme.colors.textPrimary : theme.colors.textSecondary,
},
]}
>
{status}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
</View>
)}
<FlatList
data={getFilteredData()}
contentContainerStyle={styles.listContent}
keyExtractor={(item, index) => String(item.id || item.requestId || index)}
renderItem={({ item }) => {
if (activeTab === 'My Groups') return <MyGroupCard item={item} onManage={handleManageGroup} />;
if (activeTab === 'Joined Groups') return <JoinedGroupCard item={item} />;
if (activeTab === 'Requests') return <RequestCard item={item} onApprove={handleApprove} onDecline={handleDecline} />;
if (activeTab === 'Your Requests') return <UserRequested item={item} />;
return null;
}}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[theme.colors.primary]}
/>
}
ListEmptyComponent={
!isLoading ? (
<View style={styles.emptyState}>
<LucideIcon name="documents-outline" size={64} color={theme.colors.textMuted} />
<Text style={[styles.emptyText, { color: theme.colors.textSecondary }]}>Nothing to show here</Text>
</View>
) : renderSkeletons()
}
/>
</AppView> </AppView>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f5f6fa' }, header: {
header: { paddingHorizontal: 20, paddingBottom: 15, paddingTop: 10 }, paddingHorizontal: 14,
title: { fontSize: 22, fontWeight: "600"}, paddingTop: 10,
segmentedControl: { flexDirection: 'row',borderRadius: 10,marginBottom: 20, padding: 3 }, paddingBottom: 12,
segmentButton: { flex: 1, paddingVertical: 12, alignItems: 'center', borderRadius: 8 }, zIndex: 10,
segmentActive: { backgroundColor: colors.pastelOrange, elevation: 5,borderRadius:15, shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 5 }, },
segmentText: { fontSize: 14, fontWeight: '600', color: '#6c757d' }, title: {
segmentTextActive: { color: colors.textDark }, fontSize: responsiveFontSize(3.2),
contentArea: { flex: 1}, fontFamily: FontsFamily.nunitoBold,
loader: { marginTop: 50 }, },
emptyText: { textAlign: 'center', marginTop: 60, fontSize: 16, color: '#6c757d' }, subtitle: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 4,
},
tabContainer: {
flexDirection: 'row',
marginHorizontal: 12,
padding: 5,
borderRadius: 16,
marginBottom: 12,
zIndex: 10,
},
tab: {
flex: 1,
paddingVertical: 10,
alignItems: 'center',
justifyContent: 'center',
},
tabText: {
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoMedium,
},
statusFilterContainer: {
minHeight: 46,
maxHeight: 46,
marginBottom: 10,
},
statusFilterRow: {
paddingHorizontal: 12,
alignItems: 'center',
},
statusFilterChip: {
borderWidth: 1,
borderRadius: 999,
minHeight: 34,
paddingHorizontal: 14,
paddingVertical: 0,
justifyContent: 'center',
alignSelf: 'center',
},
statusFilterChipSpaced: {
marginRight: 8,
},
statusFilterText: {
fontSize: responsiveFontSize(1.25),
fontFamily: FontsFamily.nunitoSemiBold,
},
listContent: {
paddingBottom: 86,
},
skeletonContainer: {
paddingHorizontal: 12,
},
emptyState: {
alignItems: 'center',
marginTop: 64,
},
emptyText: {
marginTop: 16,
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoMedium,
},
}); });
export default ManagementScreen; export default ManagementScreen;
@@ -2,37 +2,51 @@ import React from 'react';
import { createBottomTabNavigator, BottomTabBarProps } from '@react-navigation/bottom-tabs'; import { createBottomTabNavigator, BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@react-native-vector-icons/ionicons'; import LucideIcon from '../../components/common/LucideIcon';
import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { RootStackParamList } from '../../navigation/AppNavigator'; // Adjust path import { RootStackParamList } from '../../navigation/AppNavigator';
import colors from '../../components/colors'; import { useTheme } from '../../hooks/ui/useTheme';
import FontsFamily from '../../constents/FontsFamily';
// --- Screen Imports --- // --- Screen Imports ---
import EditGroupScreen from './EditGroupScreen'; import EditGroupScreen from './EditGroupScreen';
import Payments from './Payments'; import Payments from './Payments';
import Member from './Member'; import Member from './Member';
// --- Type Definitions --- // --- Type Definitions ---
type Props = NativeStackScreenProps<RootStackParamList, 'ManagementTabs'>; type Props = NativeStackScreenProps<RootStackParamList, 'ManagementTabs'>;
const Tab = createBottomTabNavigator(); const Tab = createBottomTabNavigator();
// --- Icon Mapping --- // --- Icon Mapping ---
const iconMap: { [key: string]: string } = { const iconMap: { [key: string]: any } = {
EditGroup: 'trail-sign-outline', EditGroup: 'settings-outline',
Payments: 'trending-up', Payments: 'card-outline',
Member: 'people-outline', Member: 'people-outline',
}; };
// --- Custom Floating Tab Bar Component --- // --- Custom Floating Tab Bar Component ---
const FloatingTabBar = ({ state, descriptors, navigation }: BottomTabBarProps) => { const FloatingTabBar = ({ state, descriptors, navigation }: BottomTabBarProps) => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const theme = useTheme();
return ( return (
<View style={[styles.tabBarContainer, { bottom: insets.bottom > 0 ? insets.bottom : 16 }]}> <View
style={[
styles.tabBarContainer,
{
bottom: Math.max(insets.bottom, 10),
backgroundColor: theme.colors.surface,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.border,
shadowColor: '#000',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.12,
shadowRadius: 8,
elevation: 6,
}
]}
>
{state.routes.map((route, index) => { {state.routes.map((route, index) => {
const { options } = descriptors[route.key]; const { options } = descriptors[route.key];
const label = options.tabBarLabel?.toString() ?? options.title ?? route.name; const label = options.tabBarLabel?.toString() ?? options.title ?? route.name;
@@ -52,33 +66,38 @@ const FloatingTabBar = ({ state, descriptors, navigation }: BottomTabBarProps) =
} }
}; };
const onLongPress = () => {
navigation.emit({
type: 'tabLongPress',
target: route.key,
});
};
return ( return (
<TouchableOpacity <TouchableOpacity
key={route.key} key={route.key}
accessibilityRole="button" accessibilityRole="button"
accessibilityState={isFocused ? { selected: true } : {}} accessibilityState={isFocused ? { selected: true } : {}}
accessibilityLabel={options.tabBarAccessibilityLabel}
testID={options.tabBarButtonTestID}
onPress={onPress} onPress={onPress}
onLongPress={onLongPress} style={[
// The style for the tab item itself no longer needs elevation styles.tabItem,
style={[styles.tabItem, { backgroundColor: isFocused ? colors.pastelOrange : 'transparent' }]} {
backgroundColor: isFocused ? theme.colors.primary : 'transparent',
borderRadius: theme.borderRadius.full,
}
]}
> >
<Ionicons <LucideIcon
name={iconName} name={iconName}
size={24} size={20}
color={isFocused ? colors.black : '#6B7280'} color={isFocused ? theme.colors.onPrimary : theme.colors.textMuted}
/> />
<Text style={[styles.tabLabel, { color: isFocused ? colors.black : '#6B7280' }]}> {isFocused && (
<Text
numberOfLines={1}
style={[
styles.tabLabel,
{
color: theme.colors.onPrimary,
}
]}
>
{label} {label}
</Text> </Text>
)}
</TouchableOpacity> </TouchableOpacity>
); );
})} })}
@@ -86,7 +105,6 @@ const FloatingTabBar = ({ state, descriptors, navigation }: BottomTabBarProps) =
); );
}; };
// --- Main Tab Navigator Component --- // --- Main Tab Navigator Component ---
const ManagementTabNavigation: React.FC<Props> = ({ route }) => { const ManagementTabNavigation: React.FC<Props> = ({ route }) => {
const { groupId } = route.params; const { groupId } = route.params;
@@ -126,50 +144,29 @@ const ManagementTabNavigation: React.FC<Props> = ({ route }) => {
); );
}; };
// --- Styles ---
const styles = StyleSheet.create({ const styles = StyleSheet.create({
tabBarContainer: { tabBarContainer: {
flexDirection: 'row', flexDirection: 'row',
position: 'absolute', position: 'absolute',
left: 20, // Adjusted for better visual balance left: 16,
right: 20, // Adjusted for better visual balance right: 16,
backgroundColor: 'white', paddingVertical: 6,
borderRadius: 30, paddingHorizontal: 6,
paddingVertical: 8,
paddingHorizontal: 8, // Added padding
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-around', justifyContent: 'space-between',
// iOS Shadow
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.1,
shadowRadius: 10,
// Android Shadow
elevation: 8,
}, },
tabItem: { tabItem: {
flex: 1, // Allow items to fill the space flex: 1,
flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingVertical: 10, // Increased padding for a better touch area paddingVertical: 9,
borderRadius: 25, gap: 8,
shadowColor: '#000', marginHorizontal: 4,
shadowOffset: {
width: 0,
height: 4,
}, },
shadowOpacity: 0.1,
shadowRadius: 10, // This is for the background color highlight
},
// The 'activeTab' style is no longer needed as the logic is handled inline
tabLabel: { tabLabel: {
fontSize: 12, fontSize: 12,
marginTop: 4, fontFamily: FontsFamily.nunitoBold,
fontWeight: '600',
}, },
}); });
+115 -198
View File
@@ -1,23 +1,20 @@
import React, { useEffect, useState, useCallback } from 'react'; import React from 'react';
import { import {
StyleSheet, StyleSheet,
Text, Text,
View, View,
FlatList, FlatList,
ActivityIndicator, RefreshControl,
SafeAreaView,
Image,
TouchableOpacity,
Alert,
} from 'react-native'; } from 'react-native';
import { Ionicons } from "@react-native-vector-icons/ionicons"; // Assuming you use Expo or have vector icons installed import LucideIcon from '../../components/common/LucideIcon';
import { apiCall } from '../../utils/ApiHelper'; // Adjust path to your apiCall utility import { responsiveFontSize } from 'react-native-responsive-dimensions';
import AppView from '../../components/AppView'; import AppView from '../../components/AppView';
import colors from '../../components/colors'; import { useTheme } from '../../hooks/ui/useTheme';
import { RouteProp, useRoute } from '@react-navigation/native'; import { useFetch } from '../../hooks/core/useFetch';
import { BASE_URL } from '../../utils/ApiUrls'; import { BASE_URL } from '../../utils/ApiUrls';
import FontsFamily from '../../constents/FontsFamily';
import SkeletonLoader from '../../components/common/SkeletonLoader';
// --- Type Definitions ---
interface Member { interface Member {
id: string; id: string;
userId: number; userId: number;
@@ -31,270 +28,190 @@ interface Member {
role: 'USER' | 'ORGANIZER'; role: 'USER' | 'ORGANIZER';
} }
interface ApiResponse { const MemberScreen = ({ route }: any) => {
message: string; const theme = useTheme();
status: string; const { groupId } = route?.params || {};
statusCode: number;
data: Member[];
}
type RootStackParamList = {
Member: { groupId: string }; // or number, depending on your data type
};
type MemberScreenRouteProp = RouteProp<RootStackParamList, 'Member'>; const { data: membersRes, isLoading, refetch } = useFetch<any>(
interface Props {
route: MemberScreenRouteProp;
}
// --- Main Screen Component ---
// This assumes the component receives `groupId` from a navigator (e.g., Tab Navigator)
const MemberScreen = ({ route }: Props) => {
const { groupId } = route.params;
const [members, setMembers] = useState<Member[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const fetchMembers = useCallback(async () => {
if (!groupId) {
setError('Group ID is missing.');
setLoading(false);
return;
}
setLoading(true);
try {
const response = await apiCall<ApiResponse>(
`${BASE_URL}fund/group/all/member?groupId=${groupId}`, `${BASE_URL}fund/group/all/member?groupId=${groupId}`,
'GET' { enabled: !!groupId }
); );
if (response && response.status === 'success') { const members: Member[] = membersRes?.data || [];
setMembers(response.data);
setError(null);
} else {
throw new Error(response?.message || 'Failed to fetch members.');
}
} catch (e: any) {
setError(e.message || 'An unexpected network error occurred.');
} finally {
setLoading(false);
}
}, [groupId]);
useEffect(() => {
fetchMembers();
}, [fetchMembers]);
// --- Render Functions ---
const renderItem = ({ item }: { item: Member }) => { const renderItem = ({ item }: { item: Member }) => {
const fullName = `${item.firstName} ${item.lastName || ''}`.trim(); const fullName = `${item.firstName} ${item.lastName || ''}`.trim();
const initial = item.firstName ? item.firstName.charAt(0).toUpperCase() : '?'; const initial = item.firstName ? item.firstName.charAt(0).toUpperCase() : '?';
const isOrganizer = item.role === 'ORGANIZER'; const isOrganizer = item.role === 'ORGANIZER';
return ( return (
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<TouchableOpacity style={styles.card}> <View style={[styles.avatarPlaceholder, { backgroundColor: theme.colors.background }]}>
<View style={styles.avatarPlaceholder}> <Text style={[styles.avatarText, { color: theme.colors.textPrimary }]}>{initial}</Text>
<Text style={styles.avatarText}>{initial}</Text>
</View> </View>
<View style={styles.infoContainer}> <View style={styles.infoContainer}>
<View style={{ flexDirection: "row", justifyContent: "space-between" }}> <View style={styles.nameRow}>
<Text style={styles.name}>{fullName}</Text> <Text style={[styles.name, { color: theme.colors.textPrimary }]}>{fullName}</Text>
{isOrganizer && ( {isOrganizer && (
<View style={styles.organizerIcon}> <View style={[styles.badge, { backgroundColor: theme.colors.primary }]}>
<Text>Orginzer</Text> <Text style={[styles.badgeText, { color: theme.colors.onPrimary }]}>Organizer</Text>
</View> </View>
)} )}
</View> </View>
<Text style={styles.username}>@{item.username}</Text> <Text style={[styles.username, { color: theme.colors.textSecondary }]}>@{item.username}</Text>
<View style={styles.contactInfo}>
<LucideIcon name="call-outline" size={12} color={theme.colors.textMuted} />
<Text style={[styles.contactText, { color: theme.colors.textMuted }]}>{item.mobile}</Text>
</View>
</View>
</View> </View>
</TouchableOpacity>
); );
}; };
const renderContent = () => { const renderSkeletons = () => (
if (loading) { <View style={styles.skeletonList}>
return ( {[1, 2, 3, 4, 5].map(i => (
<View style={styles.centered}> <SkeletonLoader key={i} width="100%" height={80} style={{ marginBottom: 12, borderRadius: 12 }} />
<ActivityIndicator size="large" color="#4F46E5" /> ))}
<Text style={styles.loadingText}>Loading Members...</Text>
</View> </View>
); );
}
if (error) {
return ( return (
<View style={styles.centered}> <AppView paddingHorizontal={0}>
<Text style={styles.errorText}>no members found</Text> <View style={styles.header}>
<Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}>Group Members</Text>
<Text style={[styles.headerSubtitle, { color: theme.colors.textSecondary }]}>
{members.length} member{members.length !== 1 ? 's' : ''} in this circle
</Text>
</View> </View>
);
}
if (members.length === 0) { {isLoading ? (
return ( renderSkeletons()
<View style={styles.centered}> ) : (
<Ionicons name="people-outline" size={60} color="#9CA3AF" />
<Text style={styles.emptyTitle}>No Members Yet</Text>
<Text style={styles.emptyText}>Invite people to join your group.</Text>
</View>
)
}
return (
<FlatList <FlatList
data={members} data={members}
renderItem={renderItem} renderItem={renderItem}
keyExtractor={item => item.id} keyExtractor={item => item.id}
contentContainerStyle={styles.listContainer} contentContainerStyle={styles.listContainer}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
/> refreshControl={
); <RefreshControl refreshing={isLoading} onRefresh={refetch} colors={[theme.colors.primary]} />
}; }
ListEmptyComponent={
return ( <View style={styles.emptyContainer}>
<AppView> <LucideIcon name="people-outline" size={64} color={theme.colors.textMuted} />
<View style={styles.header}> <Text style={[styles.emptyTitle, { color: theme.colors.textPrimary }]}>No Members Yet</Text>
<Text style={styles.headerTitle}>Group Members</Text> <Text style={[styles.emptySubtitle, { color: theme.colors.textSecondary }]}>
<Text style={styles.headerSubtitle}>{members.length} member(s)</Text> Invite people to join your group to get started.
</Text>
</View> </View>
{renderContent()} }
/>
)}
</AppView> </AppView>
); );
}; };
// --- Styles ---
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F3F4F6',
},
header: { header: {
paddingHorizontal: 20, paddingHorizontal: 14,
paddingTop: 20, paddingVertical: 10,
paddingBottom: 12,
}, },
headerTitle: { headerTitle: {
fontSize: 28, fontSize: responsiveFontSize(2.8),
fontWeight: 'bold', fontFamily: FontsFamily.nunitoBold,
color: '#111827',
}, },
headerSubtitle: { headerSubtitle: {
fontSize: 16, fontSize: responsiveFontSize(1.6),
color: '#6B7280', fontFamily: FontsFamily.nunitoRegular,
marginTop: 4, marginTop: 4,
}, },
listContainer: { listContainer: {
padding: 16, paddingHorizontal: 14,
paddingBottom: 86,
},
skeletonList: {
paddingHorizontal: 14,
}, },
card: { card: {
flexDirection: 'row', flexDirection: 'row',
backgroundColor: '#FFFFFF',
padding: 16, padding: 16,
borderRadius: 12, borderRadius: 16,
marginBottom: 16, marginBottom: 12,
alignItems: 'center', alignItems: 'center',
shadowColor: '#374151', borderWidth: 1,
shadowOpacity: 0.08, elevation: 2,
shadowRadius: 10, shadowColor: '#000',
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 2 },
elevation: 3, shadowOpacity: 0.05,
shadowRadius: 8,
}, },
avatarPlaceholder: { avatarPlaceholder: {
width: 52, width: 48,
height: 52, height: 48,
borderRadius: 26, borderRadius: 24,
backgroundColor: '#D1D5DB',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginRight: 16, marginRight: 16,
}, },
avatarText: { avatarText: {
color: '#4B5563', fontSize: responsiveFontSize(2),
fontSize: 24, fontFamily: FontsFamily.nunitoBold,
fontWeight: 'bold',
},
organizerIcon: {
borderRadius: 10,
padding: 2,
borderWidth: 2,
borderColor: colors.pastelOrange
}, },
infoContainer: { infoContainer: {
flex: 1, flex: 1,
}, },
nameRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
name: { name: {
fontSize: 18, fontSize: responsiveFontSize(1.8),
fontWeight: '700', fontFamily: FontsFamily.nunitoBold,
color: '#111827', },
badge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
},
badgeText: {
fontSize: responsiveFontSize(1),
fontFamily: FontsFamily.nunitoBold,
textTransform: 'uppercase',
}, },
username: { username: {
fontSize: 14, fontSize: responsiveFontSize(1.4),
color: '#6B7280', fontFamily: FontsFamily.nunitoRegular,
marginBottom: 8, marginTop: 2,
}, },
contactRow: { contactInfo: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: 4, marginTop: 6,
gap: 4,
}, },
contactText: { contactText: {
marginLeft: 8, fontSize: responsiveFontSize(1.3),
fontSize: 14, fontFamily: FontsFamily.nunitoMedium,
color: '#374151',
}, },
menuButton: { emptyContainer: {
padding: 8,
marginLeft: 8,
},
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
padding: 20, marginTop: 80,
}, paddingHorizontal: 40,
loadingText: {
marginTop: 10,
fontSize: 16,
color: '#6B7280',
},
errorTitle: {
fontSize: 22,
fontWeight: 'bold',
color: '#EF4444',
marginBottom: 8,
},
errorText: {
fontSize: 16,
color: '#374151',
textAlign: 'center',
marginBottom: 20,
},
retryButton: {
backgroundColor: '#4F46E5',
paddingVertical: 12,
paddingHorizontal: 30,
borderRadius: 8,
},
retryButtonText: {
color: 'white',
fontSize: 16,
fontWeight: 'bold',
}, },
emptyTitle: { emptyTitle: {
fontSize: 22, fontSize: responsiveFontSize(2.2),
fontWeight: 'bold', fontFamily: FontsFamily.nunitoBold,
color: '#374151', marginTop: 20,
marginBottom: 8,
}, },
emptyText: { emptySubtitle: {
fontSize: 16, fontSize: responsiveFontSize(1.6),
color: '#6B7280', fontFamily: FontsFamily.nunitoRegular,
textAlign: 'center', textAlign: 'center',
} marginTop: 8,
lineHeight: 22,
},
}); });
export default MemberScreen; export default MemberScreen;
+336 -220
View File
@@ -1,22 +1,28 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, ScrollView, RefreshControl, Alert, TouchableOpacity, Modal, TextInput, ToastAndroid } from 'react-native'; import {
import { useRoute, RouteProp } from '@react-navigation/native'; View,
import { Ionicons } from "@react-native-vector-icons/ionicons"; Text,
import colors from '../../components/colors'; StyleSheet,
import AppView from '../../components/AppView'; ScrollView,
import { apiCall } from '../../utils/ApiHelper'; RefreshControl,
import { BASE_URL } from '../../utils/ApiUrls'; TouchableOpacity,
TextInput,
ToastAndroid,
} from 'react-native';
import { useRoute } from '@react-navigation/native';
import LucideIcon from '../../components/common/LucideIcon';
import { responsiveFontSize } from 'react-native-responsive-dimensions'; 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 { BASE_URL } from '../../utils/ApiUrls';
import FontsFamily from '../../constents/FontsFamily';
import SkeletonLoader from '../../components/common/SkeletonLoader';
import CustomModal from '../../components/common/CustomModal';
import { clampRGBA } from 'react-native-reanimated/lib/typescript/Colors';
// --- TYPE DEFINITIONS --- // --- TYPE DEFINITIONS ---
interface ApiResponse<T> {
status: 'success' | 'error';
data: T;
message?: string;
statusCode: number;
}
interface Participant { interface Participant {
userId: string; userId: string;
firstName: string; firstName: string;
@@ -24,13 +30,12 @@ interface Participant {
mobile: string; mobile: string;
contributionAmount: number; contributionAmount: number;
paidAmount: number; paidAmount: number;
payAmount?: number;
status: 'SUCCESS' | 'PENDING' | 'FAILED' | 'UNPAID'; status: 'SUCCESS' | 'PENDING' | 'FAILED' | 'UNPAID';
lateFee: number; lateFee: number;
lateFeeWaived: boolean; lateFeeWaived: boolean;
totalPaidAmount: string; totalPaidAmount: string;
expectedAmount: number; expectedAmount: number;
waivedAmount:number waivedAmount: number;
} }
interface PaymentDetails { interface PaymentDetails {
@@ -44,242 +49,262 @@ interface PaymentDetails {
cycleCompleted: boolean; cycleCompleted: boolean;
} }
type PaymentStackParamList = { Payments: { groupId: string } };
type PaymentsRouteProp = RouteProp<PaymentStackParamList, 'Payments'>;
// The component is a valid React Functional Component, which is required for using hooks.
const Payments: React.FC = () => { const Payments: React.FC = () => {
// --- HOOKS --- const theme = useTheme();
// All hooks are called at the top level, which is correct. const route = useRoute<any>();
const route = useRoute<PaymentsRouteProp>(); const { groupId } = route?.params || {};
const { groupId } = route.params;
const [paymentDetails, setPaymentDetails] = useState<PaymentDetails | null>(null); const [isWaiverModalVisible, setWaiverModalVisible] = useState(false);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [isGroupActive, setIsGroupActive] = useState(true);
const [isModalVisible, setModalVisible] = useState(false);
const [currentParticipant, setCurrentParticipant] = useState<Participant | null>(null); const [currentParticipant, setCurrentParticipant] = useState<Participant | null>(null);
const [waiverAmount, setWaiverAmount] = useState(''); const [waiverAmount, setWaiverAmount] = useState('');
const [cycleData, setCycleData] = useState<any>(); const {
data: groupDetailsRes,
isLoading: isGroupLoading,
isRefetching: isRefetchingGroup,
refetch: refetchGroup,
} = useFetch<any>(
`${BASE_URL}fund/group/details?groupId=${groupId}`,
undefined,
{ enabled: !!groupId }
);
console.log("groupDetailsRes", groupDetailsRes);
// Fetch Payment Details
const {
data: paymentDetailsRes,
isLoading: isPaymentsLoading,
refetch: refetchPayments,
error: paymentError,
} = useFetch<any>(`${BASE_URL}payments/current/payment/details/${groupId}`, {
enabled: !!groupId,
onError: (error: any) => {
console.log("payment error", error);
},
});
// --- FUNCTIONS --- // Fetch Cycle Dashboard
// These are regular functions and do not call hooks inside them, which is correct. const {
const getCycleData = async () => { isLoading: isCycleLoading,
try { refetch: refetchCycle,
const response = await apiCall(`${BASE_URL}/payments/groups/${groupId}/cycle-dashboard`, 'GET') as ApiResponse<any>; } = useFetch<any>(`${BASE_URL}payments/groups/${groupId}/cycle-dashboard`, {
if (response && response.statusCode === 200) { enabled: !!groupId,
setCycleData(response.data); });
const paymentDetails: PaymentDetails | null = paymentDetailsRes?.data || null;
const isGroupActive = paymentDetailsRes?.statusCode !== 400;
// Waiver Update
const { mutate: waiveFee, isPending: isWaiving } = useUpdate<any, any>(
'',
{
onSuccess: () => {
ToastAndroid.show('Waiver applied successfully', ToastAndroid.SHORT);
setWaiverModalVisible(false);
refetchPayments();
refetchCycle();
},
onError: (error: any) => {
ToastAndroid.show(error?.message || 'Failed to apply waiver', ToastAndroid.SHORT);
},
} }
} catch (error: any) { );
console.error("Failed to fetch cycle data:", error.message);
}
};
const fetchPaymentDetails = useCallback(async () => {
if (!refreshing) setLoading(true);
setIsGroupActive(true);
try {
const response = await apiCall(`${BASE_URL}payments/current/payment/details/${groupId}`, 'GET') as ApiResponse<PaymentDetails>;
// console.log("fchffryjtc", response)
if (response && response.statusCode === 200) {
setPaymentDetails(response.data);
} else if (response && response.statusCode === 400) {
setIsGroupActive(false);
setPaymentDetails(null);
} else {
setIsGroupActive(false);
}
} catch (error: any) {
console.log("payment error: ", error);
setIsGroupActive(false);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [groupId, refreshing]);
// --- EFFECT HOOK ---
// useEffect is correctly used to call functions when the component mounts or dependencies change.
useEffect(() => {
getCycleData();
fetchPaymentDetails();
}, [fetchPaymentDetails]);
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(() => { const onRefresh = useCallback(() => {
setRefreshing(true); setRefreshing(true);
getCycleData(); Promise.all([
fetchPaymentDetails(); refetchPayments(),
}, [fetchPaymentDetails]); refetchCycle(),
]).finally(() => {
setRefreshing(false);
});
}, [refetchPayments, refetchCycle]);
const openWaiverDialog = (participant: Participant) => { const openWaiverDialog = (participant: Participant) => {
setCurrentParticipant(participant); setCurrentParticipant(participant);
setWaiverAmount(participant.lateFee.toString()); setWaiverAmount(participant.lateFee.toString());
setModalVisible(true); setWaiverModalVisible(true);
}; };
const handleWaiveFee = async () => { const handleWaiveFee = () => {
console.log("first approch ")
// if (!currentParticipant || !paymentDetails) console.log("second approch ") ;
const amountToWaive = parseFloat(waiverAmount); const amountToWaive = parseFloat(waiverAmount);
if (isNaN(amountToWaive) || amountToWaive < 0) { if (isNaN(amountToWaive) || amountToWaive < 0) {
ToastAndroid.show("Please enter a valid waiver amount.", ToastAndroid.SHORT); ToastAndroid.show('Please enter a valid amount', ToastAndroid.SHORT);
return; return;
} }
if (amountToWaive > currentParticipant.lateFee) { if (currentParticipant && amountToWaive > currentParticipant.lateFee) {
ToastAndroid.show(`Waiver cannot exceed the current late fee of ₹${currentParticipant.lateFee}.`, ToastAndroid.SHORT); ToastAndroid.show(`Waiver cannot exceed ₹${currentParticipant.lateFee}`, ToastAndroid.SHORT);
return; return;
} }
try { const url = `${BASE_URL}payments/groups/${groupId}/cycles/${paymentDetails?.currentCycle}/waive/${currentParticipant?.userId}?amountToWaive=${amountToWaive}`;
const url = `${BASE_URL}payments/groups/${groupId}/cycles/${paymentDetails.currentCycle}/waive/${currentParticipant.userId}?amountToWaive=${amountToWaive}`; waiveFee({ resource: url, payload: {} } as any);
const response = await apiCall(url, 'PUT', {}) as any;
console.log("vtitttit hbfbf sjbj j b", response)
if (response && response.statusCode === 200) {
ToastAndroid.show(`₹${amountToWaive} has been waived successfully.`, ToastAndroid.SHORT);
setModalVisible(false);
onRefresh();
} else {
throw new Error(response?.message || "Failed to apply waiver.");
}
} catch (error: any) {
ToastAndroid.show(error.message || "Failed to apply waiver.", ToastAndroid.SHORT);
console.error("Waive Fee Error:", error);
}
}; };
// --- RENDER LOGIC ---
const renderParticipant = (participant: Participant) => { const renderParticipant = (participant: Participant) => {
const isPending = participant.status === 'UNPAID'; const isUnpaid = participant.status === 'UNPAID';
const canWaive = participant.lateFee > 0 && isPending const canWaive = participant.lateFee > 0 && isUnpaid;
return ( return (
<View key={participant.userId} style={styles.participantCard}> <View
key={participant.userId}
style={[styles.participantCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}
>
<View style={styles.participantInfo}> <View style={styles.participantInfo}>
<Text style={styles.participantName}>{participant.firstName} {participant.lastName}</Text> <Text style={[styles.participantName, { color: theme.colors.textPrimary }]}>
<Text style={styles.participantMobile}>{participant.mobile}</Text> {participant.firstName} {participant.lastName}
</Text>
<Text style={[styles.participantMobile, { color: theme.colors.textSecondary }]}>{participant.mobile}</Text>
</View> </View>
<View style={styles.paymentStatus}> <View style={styles.paymentStatus}>
<Text style={[styles.statusText, { <Text
color: participant.status === "PENDING" style={[
? colors.warning styles.statusText,
: participant.status === "FAILED" {
? 'red' color:
: participant.status === "UNPAID" ? '#f68a89' : colors.success participant.status === 'SUCCESS'
}]}> ? theme.colors.success
: participant.status === 'UNPAID'
? theme.colors.error
: theme.colors.warning,
},
]}
>
{participant.status} {participant.status}
</Text> </Text>
{participant.status === "UNPAID" && <Text style={styles.amountText}> <Text style={[styles.amountText, { color: theme.colors.textPrimary }]}>
{`₹${participant?.expectedAmount} `} ₹{participant.status === 'SUCCESS' ? participant.totalPaidAmount : participant.expectedAmount}
</Text>} </Text>
{participant.status === "SUCCESS" && <Text style={styles.amountText}>
{`₹${participant?.totalPaidAmount}`}
</Text>}
{/* <Text style={styles.amountText}>
{isPending ? `₹${participant.payAmount || 0}` : `₹${participant.totalPaidAmount}`}
</Text> */}
{participant.lateFee > 0 && ( {participant.lateFee > 0 && (
<Text style={styles.lateFeeText}>Late Fee: ₹{participant.lateFee}</Text> <Text style={[styles.lateFeeText, { color: theme.colors.error }]}>Late Fee: ₹{participant.lateFee}</Text>
)}
{participant.lateFeeWaived && (
<Text style={[styles.waivedText, { color: theme.colors.success }]}>Waived: ₹{participant.waivedAmount}</Text>
)} )}
{/* {participant.lateFeeWaived && <Text style={styles.waivedText}>Fee Waived</Text>} */}
{
participant?.lateFeeWaived && <Text>Waived Amount : {participant?.waivedAmount}</Text>
}
</View> </View>
<View style={styles.menuContainer}>
{canWaive && ( {canWaive && (
<TouchableOpacity onPress={() => openWaiverDialog(participant)}> <TouchableOpacity onPress={() => openWaiverDialog(participant)} style={styles.menuButton}>
<Ionicons name="ellipsis-vertical" size={22} color="#6c757d" /> <LucideIcon name="ellipsis-vertical" size={20} color={theme.colors.textMuted} />
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
</View>
); );
}; };
const renderSkeletons = () => (
<View style={styles.skeletonContainer}>
<SkeletonLoader width="100%" height={120} style={{ borderRadius: 16, marginBottom: 20 }} />
{[1, 2, 3, 4].map(i => (
<SkeletonLoader key={i} width="100%" height={80} style={{ borderRadius: 12, marginBottom: 12 }} />
))}
</View>
);
return ( return (
<AppView paddingHorizontal={0}> <AppView paddingHorizontal={0}>
<Modal <CustomModal
transparent={true} visible={isWaiverModalVisible}
visible={isModalVisible} onClose={() => setWaiverModalVisible(false)}
animationType="fade" title="Waive Late Fee"
onRequestClose={() => setModalVisible(false)} onConfirm={handleWaiveFee}
confirmText={isWaiving ? 'Applying...' : 'Apply Waiver'}
> >
<View style={styles.modalOverlay}> <Text style={[styles.modalSubtitle, { color: theme.colors.textSecondary }]}>
<View style={styles.modalContainer}> For {currentParticipant?.firstName}. Current late fee: ₹{currentParticipant?.lateFee}
<Text style={styles.modalTitle}>Waive Late Fee</Text>
<Text style={styles.modalSubtitle}>
For {currentParticipant?.firstName}. Current late fee is ₹{currentParticipant?.lateFee}.
</Text> </Text>
<TextInput <TextInput
style={styles.input} style={[
styles.input,
{
backgroundColor: theme.colors.background,
color: theme.colors.textPrimary,
borderColor: theme.colors.border,
},
]}
keyboardType="numeric" keyboardType="numeric"
value={waiverAmount} value={waiverAmount}
onChangeText={setWaiverAmount} onChangeText={setWaiverAmount}
placeholder="Enter amount to waive" placeholder="Enter amount to waive"
placeholderTextColor={theme.colors.textMuted}
/> />
<View style={styles.modalButtonContainer}> </CustomModal>
<TouchableOpacity style={[styles.modalButton, styles.cancelButton]} onPress={() => setModalVisible(false)}>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.modalButton, styles.saveButton]} onPress={handleWaiveFee}>
<Text style={styles.saveButtonText}>Apply Waiver</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
{loading && !refreshing ? ( {((paymentError as any)?.response?.status === 400 || (paymentError as any)?.statusCode === 400 || !isGroupActive || groupDetailsRes?.data?.status === 'APPROVED' || groupDetailsRes?.data?.status === 'PENDING') ? (
<ActivityIndicator style={{ marginTop: 50 }} size="large" color={colors.primary} />
) : !isGroupActive ? (
<View style={styles.fullScreenMessage}> <View style={styles.fullScreenMessage}>
<Ionicons name="warning-outline" size={40} color={colors.black} /> <LucideIcon name="alert-circle-outline" size={60} color={theme.colors.textMuted} />
<Text style={styles.fullScreenMessageText}> <Text style={[styles.fullScreenMessageText, { color: theme.colors.textPrimary }]}>
This group is not currently active. Group is not Active
</Text>
<Text style={{
marginTop: 8,
fontSize: 14,
fontFamily: FontsFamily.nunitoRegular,
color: theme.colors.textSecondary,
textAlign: 'center'
}}>
Please activate the group to view payments.
</Text> </Text>
<TouchableOpacity onPress={onRefresh} style={styles.refreshButton}> <TouchableOpacity onPress={onRefresh} style={styles.refreshButton}>
<Text style={styles.refreshButtonText}>Tap to Refresh</Text> <Text style={[styles.refreshButtonText, { color: theme.colors.primary }]}>Tap to Refresh</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : ( ) : (
<ScrollView <ScrollView
style={styles.container} style={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />} contentContainerStyle={{ paddingBottom: 86 }}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[theme.colors.primary]}
/>
}
> >
{paymentDetails && ( {isPaymentsLoading ? (
renderSkeletons()
) : (
<> <>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.headerTitle}>{paymentDetails.groupName}</Text> <Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}>{paymentDetails?.groupName}</Text>
</View> </View>
<View style={styles.summaryContainer}>
{paymentDetails && (
<View style={[styles.summaryContainer, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<View style={styles.summaryBox}> <View style={styles.summaryBox}>
<Text style={styles.summaryLabel}>Cycle</Text> <Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>Cycle</Text>
<Text style={styles.summaryValue}>{paymentDetails.currentCycle}</Text> <Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
#{paymentDetails.currentCycle}
</Text>
</View> </View>
<View style={styles.divider} />
<View style={styles.summaryBox}> <View style={styles.summaryBox}>
<Text style={styles.summaryLabel}>Contribution</Text> <Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>Contribution</Text>
<Text style={styles.summaryValue}>₹ {paymentDetails.contributionAmount}</Text> <Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
₹{paymentDetails.contributionAmount}
</Text>
</View> </View>
<View style={styles.divider} />
<View style={styles.summaryBox}> <View style={styles.summaryBox}>
<Text style={styles.summaryLabel}>Total Amount</Text> <Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>Total Amount</Text>
<Text style={[styles.summaryValue, { color: colors.black }]}>₹{paymentDetails.totalDueWithLateFee}</Text> <Text style={[styles.summaryValue, { color: theme.colors.primary }]}>
₹{paymentDetails.totalDueWithLateFee}
</Text>
</View> </View>
</View> </View>
<Text style={styles.sectionTitle}>Participants</Text> )}
{paymentDetails?.cycleCompleted &&
<Text style={{ marginLeft: 20, marginBottom: 10, color: '#6c757d' }}>Next Cycle from : {paymentDetails?.nextCycleStart}</Text> <View style={styles.sectionHeader}>
}{paymentDetails.participants.map(renderParticipant)} <Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Participants</Text>
{paymentDetails?.cycleCompleted && (
<Text style={[styles.nextCycleText, { color: theme.colors.textSecondary }]}>
Next: {new Date(paymentDetails.nextCycleStart).toLocaleDateString()}
</Text>
)}
</View>
{paymentDetails?.participants.map(renderParticipant)}
</> </>
)} )}
</ScrollView> </ScrollView>
@@ -289,58 +314,149 @@ const Payments: React.FC = () => {
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f4f6f8' }, container: {
header: { padding: 20, paddingBottom: 15 }, flex: 1,
headerTitle: { color: '#000000', fontSize: 24, fontWeight: 'bold' }, },
summaryContainer: { flexDirection: 'row', justifyContent: 'space-around', padding: 20, backgroundColor: '#fff', marginHorizontal: 15, borderRadius: 12, elevation: 2 }, header: {
summaryBox: { alignItems: 'center' }, paddingHorizontal: 14,
summaryLabel: { fontSize: 14, color: '#6c757d', marginBottom: 4 }, paddingVertical: 10,
summaryValue: { fontSize: 18, fontWeight: '700', color: colors.black }, },
sectionTitle: { fontSize: 20, fontWeight: 'bold', marginLeft: 20, marginTop: 20, marginBottom: 10 }, headerTitle: {
participantCard: { backgroundColor: '#fff', marginHorizontal: 15, marginBottom: 10, borderRadius: 10, padding: 15, flexDirection: 'row', alignItems: 'center', elevation: 1 }, fontSize: responsiveFontSize(2.8),
participantInfo: { flex: 1 }, fontFamily: FontsFamily.nunitoBold,
participantName: { fontSize: 16, fontWeight: '600' }, },
participantMobile: { fontSize: 14, color: '#6c757d', marginTop: 2 }, summaryContainer: {
paymentStatus: { alignItems: 'flex-end', marginHorizontal: 10 }, flexDirection: 'row',
statusText: { fontSize: 14, fontWeight: 'bold', textTransform: 'uppercase' }, marginHorizontal: 14,
amountText: { fontSize: 14, color: '#495057', marginTop: 2 }, padding: 16,
lateFeeText: { fontSize: 13, color: colors.danger, fontWeight: '600', marginTop: 4 }, borderRadius: 16,
waivedText: { fontSize: 13, color: colors.success, fontWeight: '600', marginTop: 4 }, borderWidth: 1,
menuContainer: { justifyContent: 'center', alignItems: 'center', minWidth: 25 }, justifyContent: 'space-between',
modalOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)', justifyContent: 'center', alignItems: 'center' }, alignItems: 'center',
modalContainer: { width: '85%', backgroundColor: 'white', borderRadius: 15, padding: 20, elevation: 10 }, marginBottom: 16,
modalTitle: { fontSize: 20, fontWeight: 'bold', color: '#333', marginBottom: 5 }, elevation: 2,
modalSubtitle: { fontSize: 14, color: '#666', marginBottom: 20 }, shadowColor: '#000',
input: { width: '100%', borderWidth: 1, borderColor: '#ddd', borderRadius: 8, paddingHorizontal: 15, paddingVertical: 12, fontSize: 16, marginBottom: 20 }, shadowOffset: { width: 0, height: 2 },
modalButtonContainer: { flexDirection: 'row', justifyContent: 'flex-end' }, shadowOpacity: 0.05,
modalButton: { paddingVertical: 10, paddingHorizontal: 20, borderRadius: 8, marginLeft: 10 }, shadowRadius: 8,
cancelButton: { backgroundColor: '#f0f0f0' }, },
cancelButtonText: { color: '#333', fontWeight: 'bold' }, summaryBox: {
saveButton: { backgroundColor: colors.primary }, flex: 1,
saveButtonText: { color: 'white', fontWeight: 'bold' }, alignItems: 'center',
},
divider: {
width: 1,
height: '60%',
backgroundColor: '#E5E7EB',
},
summaryLabel: {
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoRegular,
marginBottom: 4,
},
summaryValue: {
fontSize: responsiveFontSize(2),
fontFamily: FontsFamily.nunitoBold,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 14,
marginBottom: 10,
},
sectionTitle: {
fontSize: responsiveFontSize(2.2),
fontFamily: FontsFamily.nunitoBold,
},
nextCycleText: {
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoRegular,
},
participantCard: {
flexDirection: 'row',
marginHorizontal: 14,
marginBottom: 10,
borderRadius: 16,
padding: 14,
alignItems: 'center',
borderWidth: 1,
},
participantInfo: {
flex: 1,
},
participantName: {
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoBold,
},
participantMobile: {
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 2,
},
paymentStatus: {
alignItems: 'flex-end',
marginRight: 8,
},
statusText: {
fontSize: responsiveFontSize(1.2),
fontFamily: FontsFamily.nunitoBold,
textTransform: 'uppercase',
},
amountText: {
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoBold,
marginTop: 2,
},
lateFeeText: {
fontSize: responsiveFontSize(1.2),
fontFamily: FontsFamily.nunitoMedium,
marginTop: 4,
},
waivedText: {
fontSize: responsiveFontSize(1.2),
fontFamily: FontsFamily.nunitoMedium,
marginTop: 2,
},
menuButton: {
padding: 8,
},
skeletonContainer: {
paddingHorizontal: 14,
paddingTop: 14,
},
modalSubtitle: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
marginBottom: 16,
},
input: {
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoMedium,
borderWidth: 1,
},
fullScreenMessage: { fullScreenMessage: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
backgroundColor: '#f4f6f8', padding: 40,
padding: 20,
}, },
fullScreenMessageText: { fullScreenMessageText: {
fontSize: responsiveFontSize(1.8), fontSize: responsiveFontSize(2),
color: '#333', fontFamily: FontsFamily.nunitoBold,
textAlign: 'center', textAlign: 'center',
marginTop: 15, marginTop: 20,
fontWeight: 'bold',
}, },
refreshButton: { refreshButton: {
marginTop: 20, marginTop: 16,
paddingHorizontal: 20, padding: 12,
paddingVertical: 10,
borderRadius: 8,
}, },
refreshButtonText: { refreshButtonText: {
color: colors.black, fontSize: responsiveFontSize(1.8),
fontWeight: 'bold', fontFamily: FontsFamily.nunitoBold,
}, },
}); });
File diff suppressed because it is too large Load Diff
+156 -173
View File
@@ -1,43 +1,46 @@
import React, { useEffect, useRef } from 'react' import React, { useEffect, useRef } from 'react';
import { StyleSheet, Text, View, Animated, ToastAndroid } from 'react-native' import { StyleSheet, Text, View, Animated, ToastAndroid } from 'react-native';
import { Ionicons } from '@react-native-vector-icons/ionicons' import LucideIcon from '../../components/common/LucideIcon';
import CustomModal from '../../components/common/CustomModal' import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { apiCall } from '../../utils/ApiHelper' import CustomModal from '../../components/common/CustomModal';
import { BASE_URL } from '../../utils/ApiUrls' import { useTheme } from '../../hooks/ui/useTheme';
import colors from '../../components/colors' import { useUpdate } from '../../hooks/core/useUpdate';
import { BASE_URL } from '../../utils/ApiUrls';
import FontsFamily from '../../constents/FontsFamily';
interface WinnerUserProps { interface WinnerUserProps {
data: { data: {
withdrawalDone: boolean withdrawalDone: boolean;
selectionDone: boolean selectionDone: boolean;
selectionId: string selectionId: string;
currentCycle: number currentCycle: number;
pendingParticipants: number pendingParticipants: number;
withdrawalDetails: any withdrawalDetails: any;
paidParticipants: number paidParticipants: number;
selectedUser: { selectedUser: {
firstName: string firstName: string;
lastName: string lastName: string;
mobile: string mobile: string;
id: string id: string;
userId: number userId: number;
email: string email: string;
} };
cycleEndDate: number[] cycleEndDate: number[];
cycleStartDate: number[] cycleStartDate: number[];
allPaid: boolean allPaid: boolean;
selectionDate: number[] selectionDate: number[];
totalParticipants: number totalParticipants: number;
cycleStatus: string cycleStatus: string;
selectionMethod: string selectionMethod: string;
canProceedToWithdrawal: boolean canProceedToWithdrawal: boolean;
} };
visible: boolean visible: boolean;
onClose: () => void onClose: () => void;
groupId: string | number groupId: string | number;
} }
const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId }) => { const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId }) => {
const theme = useTheme();
const { const {
withdrawalDone, withdrawalDone,
selectionDone, selectionDone,
@@ -50,13 +53,11 @@ const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId
cycleStatus, cycleStatus,
selectionMethod, selectionMethod,
canProceedToWithdrawal, canProceedToWithdrawal,
} = data || {} } = data || {};
const scaleAnim = useRef(new Animated.Value(0)).current const scaleAnim = useRef(new Animated.Value(0)).current;
const pulseAnim = useRef(new Animated.Value(1)).current const pulseAnim = useRef(new Animated.Value(1)).current;
const fadeAnim = useRef(new Animated.Value(0)).current const fadeAnim = useRef(new Animated.Value(0)).current;
console.log("winner data : ", data)
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
@@ -64,190 +65,172 @@ const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId
toValue: 1, toValue: 1,
friction: 5, friction: 5,
useNativeDriver: true, useNativeDriver: true,
}).start() }).start();
Animated.loop( Animated.loop(
Animated.sequence([ Animated.sequence([
Animated.timing(pulseAnim, { Animated.timing(pulseAnim, {
toValue: 1.1, toValue: 1.05,
duration: 600, duration: 800,
useNativeDriver: true, useNativeDriver: true,
}), }),
Animated.timing(pulseAnim, { Animated.timing(pulseAnim, {
toValue: 1, toValue: 1,
duration: 600, duration: 800,
useNativeDriver: true, useNativeDriver: true,
}), }),
]) ])
).start() ).start();
Animated.timing(fadeAnim, { Animated.timing(fadeAnim, {
toValue: 1, toValue: 1,
duration: 1500, duration: 1000,
useNativeDriver: true, useNativeDriver: true,
}).start() }).start();
} else { } else {
scaleAnim.setValue(0) scaleAnim.setValue(0);
pulseAnim.setValue(1) pulseAnim.setValue(1);
fadeAnim.setValue(0) fadeAnim.setValue(0);
} }
}, [visible]) }, [visible]);
const onConfirm = async () => { const { mutate: withdrawFunds, isPending } = useUpdate<any, any>(
try { `${BASE_URL}payments/withdraw`,
const res = await apiCall(`${BASE_URL}payments/withdraw`, 'PUT', { groupId, userId: data?.selectedUser?.id, cycleNumber: currentCycle }) as any {
console.log("res winner user : ", res) onSuccess: () => {
ToastAndroid.show('Withdrawal successful', ToastAndroid.SHORT) ToastAndroid.show('Withdrawal successful', ToastAndroid.SHORT);
onClose() onClose();
} catch (error) { },
console.log('Confirm payment error : ', error) onError: (err) => {
} finally { ToastAndroid.show(err?.message || 'Withdrawal failed', ToastAndroid.SHORT);
onClose() },
}
} }
);
const handleWithdraw = () => {
withdrawFunds({
groupId,
userId: selectedUser?.id,
cycleNumber: currentCycle,
});
};
const renderRow = (icon: any, label: string, value: any, color?: string) => (
<View style={[styles.row, { borderBottomColor: theme.colors.borderLight }]}>
<View style={styles.rowLabelContainer}>
<LucideIcon name={icon} size={16} color={theme.colors.textMuted} />
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>{label}</Text>
</View>
<Text style={[styles.value, { color: color || theme.colors.textPrimary }]}>{value}</Text>
</View>
);
return ( return (
<CustomModal <CustomModal
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
confirmText={canProceedToWithdrawal ? 'Proceed to Withdraw' : 'Close'} confirmText={canProceedToWithdrawal ? (isPending ? 'Processing...' : 'Withdraw Funds') : 'Close'}
onConfirm={canProceedToWithdrawal ? onConfirm : onClose} onConfirm={canProceedToWithdrawal ? handleWithdraw : onClose}
confirmButton={colors.pastelOrange} confirmButton={theme.colors.primary}
> >
<View style={styles.container}> <View style={styles.container}>
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}> <Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
{/* Header */}
<View style={styles.header}> <View style={styles.header}>
<Ionicons name="trophy" size={24} color="#ff9800" /> <View style={[styles.trophyContainer, { backgroundColor: theme.colors.background }]}>
<Text style={styles.cycle}>Cycle {currentCycle} Winner </Text> <LucideIcon name="trophy" size={32} color={theme.colors.primary} />
<Ionicons name="trophy" size={24} color="#ff9800" />
</View> </View>
<Text style={[styles.cycle, { color: theme.colors.textSecondary }]}>Cycle {currentCycle} Winner</Text>
{/* Winner Name */} <Animated.Text style={[styles.name, { color: theme.colors.textPrimary, transform: [{ scale: pulseAnim }] }]}>
<Animated.Text style={[styles.name, { transform: [{ scale: pulseAnim }] }]}>
{selectedUser?.firstName} {selectedUser?.lastName} {selectedUser?.firstName} {selectedUser?.lastName}
</Animated.Text> </Animated.Text>
{/* Details */}
<View style={styles.row}>
<Ionicons name="call" size={18} color="#555" />
<Text style={styles.label}>Mobile:</Text>
<Text style={styles.value}>{selectedUser?.mobile}</Text>
</View> </View>
<View style={styles.row}> <View style={[styles.detailsCard, { backgroundColor: theme.colors.background, borderColor: theme.colors.border }]}>
<Ionicons name="mail" size={18} color="#555" /> {renderRow('call-outline', 'Mobile', selectedUser?.mobile)}
<Text style={styles.label}>Email:</Text> {renderRow('mail-outline', 'Email', selectedUser?.email || 'N/A')}
<Text style={styles.value}>{selectedUser?.email}</Text> {renderRow('people-outline', 'Participants', `${paidParticipants}/${totalParticipants}`)}
{renderRow(
allPaid ? 'checkmark-circle-outline' : 'alert-circle-outline',
'All Paid',
allPaid ? 'Yes' : 'No',
allPaid ? theme.colors.success : theme.colors.error
)}
{renderRow(
withdrawalDone ? 'cash-outline' : 'time-outline',
'Withdrawal',
withdrawalDone ? 'Completed' : 'Pending',
withdrawalDone ? theme.colors.success : theme.colors.warning
)}
</View> </View>
<View style={styles.row}> <Animated.Text style={[styles.congrats, { color: theme.colors.success, opacity: fadeAnim }]}>
<Ionicons name="people" size={18} color="#555" />
<Text style={styles.label}>Total Participants:</Text>
<Text style={styles.value}>{totalParticipants}</Text>
</View>
<View style={styles.row}>
<Ionicons name="cash" size={18} color="#555" />
<Text style={styles.label}>Paid Participants:</Text>
<Text style={styles.value}>{paidParticipants}</Text>
</View>
<View style={styles.row}>
<Ionicons name="hourglass" size={18} color="#555" />
<Text style={styles.label}>Pending Participants:</Text>
<Text style={styles.value}>{pendingParticipants}</Text>
</View>
<View style={styles.row}>
<Ionicons
name={allPaid ? 'checkmark-circle' : 'close-circle'}
size={18}
color={allPaid ? '#4caf50' : '#f44336'}
/>
<Text style={styles.label}>All Paid:</Text>
<Text style={styles.value}>{allPaid ? 'Yes' : 'No'}</Text>
</View>
<View style={styles.row}>
<Ionicons
name={withdrawalDone ? 'checkmark-done' : 'time'}
size={18}
color={withdrawalDone ? '#4caf50' : '#ff9800'}
/>
<Text style={styles.label}>Withdrawal Status:</Text>
<Text style={styles.value}>{withdrawalDone ? 'Completed' : 'Pending'}</Text>
</View>
<View style={styles.row}>
<Ionicons name="git-branch" size={18} color="#555" />
<Text style={styles.label}>Selection Method:</Text>
<Text style={styles.value}>{selectionMethod}</Text>
</View>
<View style={styles.row}>
<Ionicons name="stats-chart" size={18} color="#555" />
<Text style={styles.label}>Cycle Status:</Text>
<Text style={styles.value}>{cycleStatus}</Text>
</View>
<Animated.Text style={[styles.congrats, { opacity: fadeAnim }]}>
{selectionDone ? 'Congratulations!' : 'Selection Pending'} {selectionDone ? 'Congratulations!' : 'Selection Pending'}
</Animated.Text> </Animated.Text>
</Animated.View> </Animated.View>
</View> </View>
</CustomModal> </CustomModal>
) );
} };
export default WinnerUser
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
padding: 20, padding: 10,
}, },
header: { header: {
flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 8, marginBottom: 24,
marginBottom: 16, },
trophyContainer: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: 'center', justifyContent: 'center',
},
cycle: {
fontSize: 18,
fontWeight: '700',
color: '#ff9800',
marginHorizontal: 8,
textAlign: 'center',
},
name: {
fontSize: 22,
fontWeight: '800',
color: '#333',
marginBottom: 20,
textAlign: 'center',
},
row: {
flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: 12, marginBottom: 12,
}, },
label: { cycle: {
fontSize: 14, fontSize: responsiveFontSize(1.6),
fontWeight: '600', fontFamily: FontsFamily.nunitoBold,
color: '#555', textTransform: 'uppercase',
marginLeft: 6, letterSpacing: 1,
marginRight: 4, marginBottom: 4,
}, },
value: { name: {
fontSize: 16, fontSize: responsiveFontSize(2.8),
color: '#000', fontFamily: FontsFamily.nunitoBold,
},
congrats: {
fontSize: 20,
fontWeight: '700',
color: '#4caf50',
marginTop: 20,
textAlign: 'center', textAlign: 'center',
}, },
}) detailsCard: {
padding: 16,
borderRadius: 16,
borderWidth: 1,
},
row: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 10,
borderBottomWidth: 1,
},
rowLabelContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
label: {
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,
},
});
export default WinnerUser;
+103 -154
View File
@@ -1,28 +1,29 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect } from "react";
import { import {
StyleSheet, StyleSheet,
Text, Text,
View, View,
FlatList, FlatList,
TouchableOpacity, TouchableOpacity,
Modal,
PermissionsAndroid, PermissionsAndroid,
Platform, Platform,
NativeModules, NativeModules,
Linking, Linking,
Alert,
ToastAndroid, ToastAndroid,
} from "react-native"; } from 'react-native';
import AppView from "../../components/AppView"; import { responsiveFontSize } from 'react-native-responsive-dimensions';
import colors from "../../components/colors"; import AppView from '../../components/AppView';
import { useTheme } from '../../hooks/ui/useTheme';
import FontsFamily from '../../constents/FontsFamily';
import CustomModal from '../../components/common/CustomModal';
const { ContactsModule } = NativeModules; const { ContactsModule } = NativeModules;
const APP_URL = "https://play.google.com/store/apps/details?id=com.yourapp"; // replace with your real app url const APP_URL = 'https://play.google.com/store/apps/details?id=com.yourapp';
const WHATSAPP_URL = 'https://play.google.com/store/apps/details?id=com.whatsapp';
const YourContacts = () => { const YourContacts = () => {
const theme = useTheme();
const [contacts, setContacts] = useState<any[]>([]); const [contacts, setContacts] = useState<any[]>([]);
const [selectedContact, setSelectedContact] = useState<any>(null); const [selectedContact, setSelectedContact] = useState<any>(null);
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
@@ -33,18 +34,17 @@ const YourContacts = () => {
const handleFetchContacts = async () => { const handleFetchContacts = async () => {
try { try {
if (Platform.OS === "android") { if (Platform.OS === 'android') {
const granted = await PermissionsAndroid.request( const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_CONTACTS, PermissionsAndroid.PERMISSIONS.READ_CONTACTS,
{ {
title: "Contacts Permission", title: 'Contacts Permission',
message: "This app would like to view your contacts.", message: 'This app would like to view your contacts.',
buttonPositive: "OK", buttonPositive: 'OK',
} }
); );
if (granted !== PermissionsAndroid.RESULTS.GRANTED) { if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
console.log("Contacts permission denied");
return; return;
} }
} }
@@ -52,225 +52,174 @@ const YourContacts = () => {
const fetchedContacts = await ContactsModule.getContacts(); const fetchedContacts = await ContactsModule.getContacts();
setContacts(fetchedContacts); setContacts(fetchedContacts);
} catch (error) { } catch (error) {
console.error("Error fetching contacts:", error); console.error('Error fetching contacts:', error);
} }
}; };
const APP_URL = "https://play.google.com/store/apps/details?id=com.yourapp"; // your app link
const WHATSAPP_URL = "https://play.google.com/store/apps/details?id=com.whatsapp"; // fallback store link
const handleSend = async (contact: any) => { const handleSend = async (contact: any) => {
if (!contact) return; if (!contact) return;
const message = `Hey ${contact.name}, check out this awesome app! Download here: ${APP_URL}`; const message = `Hey ${contact.name}, check out this awesome app! Download here: ${APP_URL}`;
const url = `whatsapp://send?phone=${contact.phone}&text=${encodeURIComponent(message)}`;
// direct send to contact if you want
const url = `whatsapp://send?phone=${contact.phone}&text=${encodeURIComponent(
message
)}`;
try { try {
const supported = await Linking.canOpenURL("whatsapp://send"); const supported = await Linking.canOpenURL('whatsapp://send');
if (supported) { if (supported) {
// WhatsApp installed
await Linking.openURL(url); await Linking.openURL(url);
} else { } else {
// WhatsApp not installed → send user to Play Store/App Store if (Platform.OS === 'android') {
if (Platform.OS === "android") {
await Linking.openURL(WHATSAPP_URL); await Linking.openURL(WHATSAPP_URL);
} else if (Platform.OS === "ios") { } else if (Platform.OS === 'ios') {
await Linking.openURL("itms-apps://apps.apple.com/app/whatsapp-messenger/id310633997"); await Linking.openURL('itms-apps://apps.apple.com/app/whatsapp-messenger/id310633997');
} }
} }
} catch (err) { } catch (err) {
console.error("Error sending to WhatsApp:", err); ToastAndroid.show('Could not open WhatsApp', ToastAndroid.SHORT);
ToastAndroid.show("Something went wrong while trying to open WhatsApp", ToastAndroid.SHORT);
} }
setModalVisible(false); setModalVisible(false);
}; };
const renderContact = ({ item }: { item: any }) => ( const renderContact = ({ item }: { item: any }) => (
<View style={styles.contactRow}> <View style={[styles.contactRow, { borderBottomColor: theme.colors.borderLight }]}>
<View style={styles.avatar}> <View style={[styles.avatar, { backgroundColor: theme.colors.surface }]}>
<Text style={styles.avatarText}> <Text style={[styles.avatarText, { color: theme.colors.textPrimary }]}>
{item.name ? item.name.charAt(0).toUpperCase() : "?"} {item.name ? item.name.charAt(0).toUpperCase() : '?'}
</Text> </Text>
</View> </View>
<View style={styles.contactInfo}> <View style={styles.contactInfo}>
<Text style={styles.contactName}>{item.name}</Text> <Text style={[styles.contactName, { color: theme.colors.textPrimary }]}>{item.name}</Text>
<Text style={styles.contactPhone}>{item.phone}</Text> <Text style={[styles.contactPhone, { color: theme.colors.textSecondary }]}>{item.phone}</Text>
</View> </View>
<TouchableOpacity <TouchableOpacity
style={styles.inviteButton} style={[styles.inviteButton, { borderColor: theme.colors.primary }]}
onPress={() => { onPress={() => {
setSelectedContact(item); setSelectedContact(item);
setModalVisible(true); setModalVisible(true);
}} }}
> >
<Text style={styles.inviteText}>Invite</Text> <Text style={[styles.inviteText, { color: theme.colors.primary }]}>Invite</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );
return ( return (
<AppView> <AppView style={{ backgroundColor: theme.colors.background }}>
<View> <View style={styles.header}>
<Text style={styles.header}>Select Contact</Text> <Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}>Select Contact</Text>
<Text style={[styles.headerSubtitle, { color: theme.colors.textSecondary }]}>Invite your friends to join CircleCollect</Text>
</View> </View>
<FlatList <FlatList
data={contacts} data={contacts}
keyExtractor={(item, index) => index.toString()} keyExtractor={(item, index) => index.toString()}
renderItem={renderContact} renderItem={renderContact}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={{ color: theme.colors.textMuted }}>No contacts found</Text>
</View>
}
/> />
{/* Send Modal */} <CustomModal
<Modal
transparent={true}
visible={modalVisible} visible={modalVisible}
animationType="slide" onClose={() => setModalVisible(false)}
onRequestClose={() => setModalVisible(false)} onConfirm={() => handleSend(selectedContact)}
confirmText="Send Invite"
cancelText="Cancel"
confirmButton={theme.colors.primary}
> >
<View style={styles.modalOverlay}> <View style={styles.modalBody}>
<View style={styles.modalContent}> <Text style={[styles.modalTitle, { color: theme.colors.textPrimary }]}>Send Invite</Text>
<Text style={styles.modalTitle}>Send Invite</Text>
{selectedContact && ( {selectedContact && (
<Text style={styles.modalText}> <Text style={[styles.modalText, { color: theme.colors.textSecondary }]}>
Send invite to {selectedContact.name} ({selectedContact.phone}) Invite <Text style={{ color: theme.colors.textPrimary, fontWeight: 'bold' }}>{selectedContact.name}</Text> to the app via WhatsApp?
</Text> </Text>
)} )}
<View style={styles.modalActions}>
<TouchableOpacity
style={styles.cancelButton}
onPress={() => setModalVisible(false)}
>
<Text style={styles.cancelText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.sendButton}
onPress={() => handleSend(selectedContact)}
>
<Text style={styles.sendText}>Send</Text>
</TouchableOpacity>
</View> </View>
</View> </CustomModal>
</View>
</Modal>
</AppView> </AppView>
); );
}; };
export default YourContacts;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
paddingHorizontal: 10,
paddingTop: 20,
},
header: { header: {
fontSize: 20, padding: 20,
fontWeight: "bold", paddingBottom: 10,
marginBottom: 15, },
headerTitle: {
fontSize: responsiveFontSize(2.8),
fontFamily: FontsFamily.nunitoBold,
},
headerSubtitle: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 4,
},
listContent: {
paddingHorizontal: 20,
paddingBottom: 20,
}, },
contactRow: { contactRow: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
paddingVertical: 12, paddingVertical: 14,
borderBottomWidth: 0.5, borderBottomWidth: 1,
borderBottomColor: "#ddd",
}, },
avatar: { avatar: {
width: 45, width: 48,
height: 45, height: 48,
borderRadius: 25, borderRadius: 24,
backgroundColor: colors.pastelOrange, justifyContent: 'center',
justifyContent: "center", alignItems: 'center',
alignItems: "center", marginRight: 14,
marginRight: 12,
}, },
avatarText: { avatarText: {
color: "#fff", fontSize: responsiveFontSize(2.2),
fontSize: 18, fontFamily: FontsFamily.nunitoBold,
fontWeight: "bold",
}, },
contactInfo: { contactInfo: {
flex: 1, flex: 1,
}, },
contactName: { contactName: {
fontSize: 16, fontSize: responsiveFontSize(1.8),
fontWeight: "600", fontFamily: FontsFamily.nunitoBold,
}, },
contactPhone: { contactPhone: {
fontSize: 13, fontSize: responsiveFontSize(1.4),
color: "gray", fontFamily: FontsFamily.nunitoRegular,
marginTop: 2,
}, },
inviteButton: { inviteButton: {
paddingHorizontal: 14, paddingHorizontal: 16,
paddingVertical: 6, paddingVertical: 8,
borderRadius: 8, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
borderColor: colors.Settings,
}, },
inviteText: { inviteText: {
color: colors.Settings, fontSize: responsiveFontSize(1.6),
fontWeight: "bold", fontFamily: FontsFamily.nunitoBold,
}, },
modalOverlay: { modalBody: {
flex: 1, padding: 10,
backgroundColor: "rgba(0,0,0,0.5)", alignItems: 'center',
justifyContent: "center",
alignItems: "center",
},
modalContent: {
width: "80%",
backgroundColor: "#fff",
borderRadius: 10,
padding: 20,
alignItems: "center",
}, },
modalTitle: { modalTitle: {
fontSize: 18, fontSize: responsiveFontSize(2.2),
fontWeight: "bold", fontFamily: FontsFamily.nunitoBold,
marginBottom: 10, marginBottom: 12,
}, },
modalText: { modalText: {
fontSize: 15, fontSize: responsiveFontSize(1.8),
marginBottom: 20, fontFamily: FontsFamily.nunitoRegular,
textAlign: "center", textAlign: 'center',
lineHeight: 24,
}, },
modalActions: { emptyContainer: {
flexDirection: "row", alignItems: 'center',
justifyContent: "space-between", marginTop: 40,
width: "100%",
},
cancelButton: {
flex: 1,
marginRight: 10,
paddingVertical: 10,
borderRadius: 8,
backgroundColor: "#ddd",
alignItems: "center",
},
cancelText: {
fontWeight: "bold",
color: "#333",
},
sendButton: {
flex: 1,
marginLeft: 10,
paddingVertical: 10,
borderRadius: 8,
backgroundColor: colors.pastelOrange,
alignItems: "center",
},
sendText: {
fontWeight: "bold",
color: "#fff",
}, },
}); });
export default YourContacts;
+6 -18
View File
@@ -9,15 +9,11 @@ import {
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { import {
removeNotification, removeNotification,
clearAllNotifications,
markAsRead,
markAllAsRead,
} from '../../redux/notificationSlice'; } from '../../redux/notificationSlice';
import LinearGradient from 'react-native-linear-gradient'; import LinearGradient from 'react-native-linear-gradient';
import { Ionicons } from '@react-native-vector-icons/ionicons'; import LucideIcon from '../../components/common/LucideIcon';
import AppView from '../../components/AppView'; import AppView from '../../components/AppView';
import Header from '../../components/common/Header'; import Header from '../../components/common/Header';
import { useNavigation } from '@react-navigation/native';
import { useNotificationHandler } from '../hook/useNotificationHandler'; import { useNotificationHandler } from '../hook/useNotificationHandler';
import colors from '../../components/colors'; import colors from '../../components/colors';
@@ -28,7 +24,6 @@ type FilterType = 'all' | 'JOIN_REQUEST' | 'CONTRIBUTION' | 'CONTRIBUTION_ACK' |
const Notification = () => { const Notification = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const navigation = useNavigation();
const { handleMarkAsReadAndNavigate } = useNotificationHandler(); const { handleMarkAsReadAndNavigate } = useNotificationHandler();
const notifications = useSelector( const notifications = useSelector(
(state: any) => state.notifications.notifications (state: any) => state.notifications.notifications
@@ -41,10 +36,6 @@ const Notification = () => {
dispatch(removeNotification(messageId)); dispatch(removeNotification(messageId));
}; };
const handleClearAll = () => {
dispatch(clearAllNotifications());
};
const handleMarkAsRead = (messageId: string, screen: string, groupId: string, cycleNumber: string, contributionId: string) => { const handleMarkAsRead = (messageId: string, screen: string, groupId: string, cycleNumber: string, contributionId: string) => {
console.log("screen type : ", screen); console.log("screen type : ", screen);
handleMarkAsReadAndNavigate( handleMarkAsReadAndNavigate(
@@ -63,10 +54,6 @@ const Notification = () => {
); );
}; };
const handleMarkAllAsRead = () => {
dispatch(markAllAsRead());
};
const formatTime = (timestamp: number) => { const formatTime = (timestamp: number) => {
const date = new Date(timestamp); const date = new Date(timestamp);
const now = new Date(); const now = new Date();
@@ -181,7 +168,8 @@ const Notification = () => {
<AppView> <AppView>
{/* Header with actions */} {/* Header with actions */}
<Header <Header
title={`Notifications ${unreadCount > 0 ? `(${unreadCount})` : ''}`} arrowColor='#000' titleColor='#000'/> title={`Notifications ${unreadCount > 0 ? `(${unreadCount})` : ''}`}
/>
{/* <View style={styles.headerActions}> {/* <View style={styles.headerActions}>
{unreadCount > 0 && ( {unreadCount > 0 && (
<LinearGradient <LinearGradient
@@ -226,7 +214,7 @@ const Notification = () => {
<View style={styles.notificationContent}> <View style={styles.notificationContent}>
{/* Icon */} {/* Icon */}
<View style={styles.iconContainer}> <View style={styles.iconContainer}>
<Ionicons <LucideIcon
name={getNotificationIcon(item.data.msgType)} name={getNotificationIcon(item.data.msgType)}
size={24} size={24}
color={getNotificationColor(item.data.msgType)} color={getNotificationColor(item.data.msgType)}
@@ -272,7 +260,7 @@ const Notification = () => {
onPress={() => handleDelete(item.messageId)} onPress={() => handleDelete(item.messageId)}
style={styles.deleteButton} style={styles.deleteButton}
> >
<Ionicons name="trash-outline" size={20} color={colors.pastelPink} /> {/* Pastel Pink */} <LucideIcon name="trash-outline" size={20} color={colors.pastelPink} /> {/* Pastel Pink */}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -282,7 +270,7 @@ const Notification = () => {
}} }}
ListEmptyComponent={ ListEmptyComponent={
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<Ionicons name="notifications-off-outline" size={64} color="#D3D3D3" /> {/* Pastel Gray */} <LucideIcon name="notifications-off-outline" size={64} color="#D3D3D3" /> {/* Pastel Gray */}
<Text style={styles.emptyText}>No notifications yet</Text> <Text style={styles.emptyText}>No notifications yet</Text>
<Text style={styles.emptySubtext}> <Text style={styles.emptySubtext}>
When you get notifications, they'll appear here When you get notifications, they'll appear here
+754
View File
@@ -0,0 +1,754 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
ActivityIndicator,
Animated,
Image,
ToastAndroid,
Modal,
Easing,
} from 'react-native';
import { useNavigation, useRoute } from '@react-navigation/native';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
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 FontsFamily from '../../constents/FontsFamily';
import Header from '../../components/common/Header';
import { clampRGBA } from 'react-native-reanimated/lib/typescript/Colors';
interface PaymentMode {
id: number;
modeCode: string;
modeName: string;
provider: string;
countryCode: string;
currencyCode: string;
minAmount: number;
maxAmount: number;
isActive: boolean;
isMaintenance: boolean;
displayOrder: number;
iconUrl: string;
}
// --- Extracted ModeCard Component (hooks must be in a component, not a render function) ---
const getBrandColors = (modeCode: string): { bg: string; text: string } => {
switch (modeCode) {
case 'PAISAPAL': return { bg: '#2BAF2B', text: '#fff' }; // Pesapal green
case 'MTN': return { bg: '#FFCC00', text: '#003087' }; // MTN yellow + blue
case 'AIRTEL': return { bg: '#ED1C24', text: '#fff' }; // Airtel red
default: return { bg: '#6C63FF', text: '#fff' };
}
};
interface ModeCardProps {
item: PaymentMode;
index: number;
isSelected: boolean;
isDisabled: boolean;
onSelect: (id: number) => void;
}
const ModeCard: React.FC<ModeCardProps> = ({ item, index, isSelected, isDisabled, onSelect }) => {
const theme = useTheme();
const cardAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.timing(cardAnim, {
toValue: 1,
duration: 400,
delay: index * 100,
useNativeDriver: true,
}).start();
}, []);
const brandColors = getBrandColors(item.modeCode);
return (
<Animated.View style={{ opacity: cardAnim, transform: [{ translateY: cardAnim.interpolate({ inputRange: [0, 1], outputRange: [20, 0] }) }] }}>
<TouchableOpacity
activeOpacity={isDisabled ? 1 : 0.7}
onPress={() => { if (!isDisabled) onSelect(item.id); }}
style={[
styles.modeCard,
{
backgroundColor: isSelected ? theme.colors.primary + '12' : theme.colors.card,
borderColor: isSelected ? theme.colors.primary : theme.colors.border,
opacity: isDisabled ? 0.5 : 1,
},
]}
>
{/* Selection Indicator */}
<View style={[styles.radioOuter, { borderColor: isSelected ? theme.colors.primary : theme.colors.textMuted }]}>
{isSelected && <View style={[styles.radioInner, { backgroundColor: theme.colors.primary }]} />}
</View>
{/* Provider Brand Icon */}
<View style={[styles.modeIconContainer, { backgroundColor: brandColors.bg }]}>
<Text style={{ fontSize: responsiveFontSize(2.2), fontFamily: FontsFamily.nunitoExtraBold, color: brandColors.text }}>
{item.provider.charAt(0).toUpperCase()}
</Text>
</View>
{/* Info */}
<View style={styles.modeInfo}>
<Text style={[styles.modeName, { color: theme.colors.textPrimary }]}>{item.modeName}</Text>
<Text style={[styles.modeProvider, { color: theme.colors.textSecondary }]}>
{item.provider} • {item.currencyCode}
</Text>
<Text style={[styles.modeLimits, { color: theme.colors.textMuted }]}>
{item.currencyCode} {item.minAmount.toLocaleString()} – {item.maxAmount.toLocaleString()}
</Text>
</View>
{/* Badges */}
<View style={styles.badgeContainer}>
{item.isMaintenance && (
<View style={[styles.badge, { backgroundColor: theme.colors.warning + '22' }]}>
<Text style={[styles.badgeText, { color: theme.colors.warning }]}>Maintenance</Text>
</View>
)}
{!item.isActive && !item.isMaintenance && (
<View style={[styles.badge, { backgroundColor: theme.colors.error + '22' }]}>
<Text style={[styles.badgeText, { color: theme.colors.error }]}>Unavailable</Text>
</View>
)}
</View>
</TouchableOpacity>
</Animated.View>
);
};
// --- Main Screen ---
const PaymentModeScreen: React.FC = () => {
const theme = useTheme();
const navigation = useNavigation();
const route = useRoute<any>();
const { groupId, routeFrom, routeData = null } = route.params || {};
const user = useSelector((state: any) => state.user);
const [modes, setModes] = useState<PaymentMode[]>([]);
const [loading, setLoading] = useState(true);
const [selectedMode, setSelectedMode] = useState<number | null>(null);
const [paying, setPaying] = useState(false);
const [payable, setPayable] = useState<any>();
// --- Payment processing modal state ---
const [processingVisible, setProcessingVisible] = useState(false);
const [processingStatus, setProcessingStatus] = useState<'polling' | 'success' | 'failed' | 'timeout'>('polling');
const [processingMessage, setProcessingMessage] = useState('Payment request initiated. Awaiting approval...');
const [timeRemaining, setTimeRemaining] = useState(360); // 6 minutes in seconds
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
const spinAnim = useRef(new Animated.Value(0)).current;
const spinLoop = useRef<Animated.CompositeAnimation | null>(null);
// Animations
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(30)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }),
Animated.timing(slideAnim, { toValue: 0, duration: 500, useNativeDriver: true }),
]).start();
}, []);
useEffect(() => {
{ routeFrom === 'GroupDetails' && handleContribution(); }
console.log("data", payable);
}, []);
const handleContribution = async () => {
try {
const res = await apiCall(`${CONTRIBUTE}${groupId || route?.params?.groupId}/calculate-contribution`, 'GET') as any;
setPayable(res.data);
} catch (error) {
console.log("Error in handleContribution: ", error);
}
};
// Spin animation for processing modal
const startSpinAnimation = () => {
spinAnim.setValue(0);
spinLoop.current = Animated.loop(
Animated.timing(spinAnim, {
toValue: 1,
duration: 1500,
easing: Easing.linear,
useNativeDriver: true,
})
);
spinLoop.current.start();
};
const stopSpinAnimation = () => {
spinLoop.current?.stop();
};
const cleanupPolling = () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
stopSpinAnimation();
};
// Cleanup on unmount
useEffect(() => {
return () => cleanupPolling();
}, []);
const pollTransactionStatus = (pgOrderNo: string) => {
setProcessingVisible(true);
setProcessingStatus('polling');
setProcessingMessage('Payment request initiated. Awaiting approval...');
setTimeRemaining(360);
startSpinAnimation();
const startTime = Date.now();
const MAX_DURATION = 6 * 60 * 1000; // 6 minutes
const POLL_INTERVAL = 5000; // 5 seconds
// Countdown timer
countdownRef.current = setInterval(() => {
const elapsed = Date.now() - startTime;
const remaining = Math.max(0, Math.ceil((MAX_DURATION - elapsed) / 1000));
setTimeRemaining(remaining);
}, 1000);
// Polling interval
pollingRef.current = setInterval(async () => {
const elapsed = Date.now() - startTime;
// Timeout after 6 minutes
if (elapsed >= MAX_DURATION) {
cleanupPolling();
setProcessingStatus('timeout');
setProcessingMessage('Payment verification timed out. Please check your transaction history.');
return;
}
try {
const statusRes = await apiCall(
`${BASE_URL}payments/pg/check-status/${pgOrderNo}`,
'GET'
) as any;
console.log('Poll status response:', JSON.stringify(statusRes));
const txnStatus = statusRes?.data?.txnStatus;
if (txnStatus === 'SUCCESS') {
cleanupPolling();
setProcessingStatus('success');
setProcessingMessage('Payment completed successfully!');
setTimeout(() => {
setProcessingVisible(false);
setPaying(false);
ToastAndroid.show('Payment successful!', ToastAndroid.SHORT);
navigation.goBack();
}, 2000);
return;
}
if (txnStatus === 'FAILED' || txnStatus === 'REJECTED' || txnStatus === 'CANCELLED') {
cleanupPolling();
setProcessingStatus('failed');
setProcessingMessage(statusRes?.data?.message || 'Payment failed. Please try again.');
return;
}
// Still pending — update message
setProcessingMessage(statusRes?.message || 'Waiting for payment confirmation...');
} catch (err) {
console.log('Poll error:', err);
// Don't stop polling on network errors, keep trying
}
}, POLL_INTERVAL);
};
const handleCloseProcessingModal = () => {
cleanupPolling();
setProcessingVisible(false);
setPaying(false);
};
const fetchPaymentModes = useCallback(async () => {
try {
setLoading(true);
const res = await apiCall(PAYMENT_MODES_URL, 'GET') as any;
if (res?.status === 'success' && Array.isArray(res.data)) {
const sorted = res.data.sort((a: PaymentMode, b: PaymentMode) => a.displayOrder - b.displayOrder);
setModes(sorted);
} else {
ToastAndroid.show('Failed to load payment methods', ToastAndroid.SHORT);
}
} catch (error) {
ToastAndroid.show('Error loading payment methods', ToastAndroid.SHORT);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchPaymentModes();
}, [fetchPaymentModes]);
const handlePaymentForRouteFormGroupScreen = async () => {
try {
setPaying(true);
const payload = {
groupId: groupId || 0,
paymentId: selectedMode || 0,
mobileNumber: '',
};
console.log('payload', JSON.stringify(payload));
const res = await apiCall(
`${BASE_URL}payments/pg/entry-fee/${groupId}?groupId=${groupId}&paymentId=${selectedMode}&mobileNumber=`,
'POST',
payload
) as any;
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);
}
} catch (error) {
ToastAndroid.show('An error occurred during payment', ToastAndroid.SHORT);
setPaying(false);
}
}
const handlePaymentForRouteFormGroupDetails = async () => {
try {
setPaying(true);
const payload = {
groupId: groupId || 0,
amount: payable.totalPayable || 0,
paymentId: selectedMode || 0,
mobileNumber: '',
};
console.log('payload', JSON.stringify(payload));
const res = await apiCall(
`${BASE_URL}payments/contribute`,
'POST',
payload
) as any;
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);
}
} catch (error) {
ToastAndroid.show('An error occurred during payment', ToastAndroid.SHORT);
setPaying(false);
}
}
const handleConfirmPayment = async () => {
if (!selectedMode) {
ToastAndroid.show('Please select a payment method', ToastAndroid.SHORT);
return;
}
switch (routeFrom) {
case 'GroupDetails':
handlePaymentForRouteFormGroupDetails();
break;
case 'GroupScreen':
handlePaymentForRouteFormGroupScreen();
break;
default:
break;
}
};
return (
<AppView style={{ flex: 1, backgroundColor: theme.colors.background }}>
<Header title="Select Payment Method" />
<Animated.View style={[styles.container, { opacity: fadeAnim, transform: [{ translateY: slideAnim }] }]}>
{/* Amount Summary */}
{routeFrom === 'GroupDetails' && <View style={[styles.amountCard, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}>
<View style={styles.amountRow}>
<LucideIcon name="wallet-outline" size={20} color={theme.colors.primary} />
<Text style={[styles.amountLabel, { color: theme.colors.textSecondary }]}>Total Payable</Text>
</View>
<Text style={[styles.amountValue, { color: theme.colors.textPrimary }]}>
{payable?.currencyCode || 'UGX'} {Number(payable?.totalPayable || 0).toLocaleString()}
</Text>
</View>}
{routeFrom === 'GroupScreen' && <View style={[styles.amountCard, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}>
<View style={styles.amountRow}>
<LucideIcon name="wallet-outline" size={20} color={theme.colors.primary} />
<Text style={[styles.amountLabel, { color: theme.colors.textSecondary }]}>Entry Fee</Text>
</View>
<Text style={[styles.amountValue, { color: theme.colors.textPrimary }]}>
{payable?.currencyCode || 'UGX'} {Number(routeData?.entryFee || 0).toLocaleString()}
</Text>
</View>}
{/* Payment Methods */}
{loading ? (
<View style={styles.loaderContainer}>
<ActivityIndicator size="large" color={theme.colors.primary} />
<Text style={[styles.loaderText, { color: theme.colors.textMuted }]}>Loading payment methods...</Text>
</View>
) : modes.length === 0 ? (
<View style={styles.emptyContainer}>
<LucideIcon name="alert-circle-outline" size={48} color={theme.colors.textMuted} />
<Text style={[styles.emptyText, { color: theme.colors.textMuted }]}>No payment methods available</Text>
</View>
) : (
<FlatList
data={modes}
keyExtractor={(item) => String(item.id)}
renderItem={({ item, index }) => (
<ModeCard
item={item}
index={index}
isSelected={selectedMode === item.id}
isDisabled={!item.isActive || item.isMaintenance}
onSelect={setSelectedMode}
/>
)}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
/>
)}
</Animated.View>
{/* Bottom CTA */}
{!loading && modes.length > 0 && (
<View style={[styles.bottomBar, { backgroundColor: theme.colors.background, borderTopColor: theme.colors.border }]}>
<TouchableOpacity
onPress={handleConfirmPayment}
disabled={!selectedMode || paying}
activeOpacity={0.8}
style={[
styles.confirmButton,
{
backgroundColor: selectedMode ? theme.colors.primary : theme.colors.textMuted,
opacity: paying ? 0.7 : 1,
},
]}
>
{paying ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<>
<LucideIcon name="shield-checkmark-outline" size={20} color="#fff" style={{ marginRight: 8 }} />
<Text style={styles.confirmText}>Confirm & Pay</Text>
</>
)}
</TouchableOpacity>
</View>
)}
{/* Processing Modal — non-dismissable while polling */}
<Modal
visible={processingVisible}
transparent
animationType="fade"
onRequestClose={() => {
// Only allow closing if not actively polling
if (processingStatus !== 'polling') handleCloseProcessingModal();
}}
>
<View style={modalStyles.overlay}>
<View style={[modalStyles.card, { backgroundColor: theme.colors.card }]}>
{/* Icon / Spinner */}
{processingStatus === 'polling' ? (
<Animated.View style={{
transform: [{
rotate: spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
}),
}],
}}>
<LucideIcon name="sync-outline" size={52} color={theme.colors.primary} />
</Animated.View>
) : processingStatus === 'success' ? (
<View style={[modalStyles.iconCircle, { backgroundColor: '#2BAF2B22' }]}>
<LucideIcon name="checkmark-circle-outline" size={52} color="#2BAF2B" />
</View>
) : (
<View style={[modalStyles.iconCircle, { backgroundColor: theme.colors.error + '22' }]}>
<LucideIcon name="close-circle-outline" size={52} color={theme.colors.error} />
</View>
)}
{/* Title */}
<Text style={[modalStyles.title, { color: theme.colors.textPrimary }]}>
{processingStatus === 'polling' ? 'Processing Payment' :
processingStatus === 'success' ? 'Payment Successful' :
processingStatus === 'timeout' ? 'Verification Timed Out' : 'Payment Failed'}
</Text>
{/* Message */}
<Text style={[modalStyles.message, { color: theme.colors.textSecondary }]}>
{processingMessage}
</Text>
{/* Countdown timer while polling */}
{processingStatus === 'polling' && (
<View style={modalStyles.timerContainer}>
<LucideIcon name="time-outline" size={16} color={theme.colors.textMuted} />
<Text style={[modalStyles.timerText, { color: theme.colors.textMuted }]}>
{Math.floor(timeRemaining / 60)}:{String(timeRemaining % 60).padStart(2, '0')} remaining
</Text>
</View>
)}
{/* Close button — only visible when NOT polling */}
{processingStatus !== 'polling' && (
<TouchableOpacity
onPress={handleCloseProcessingModal}
style={[
modalStyles.closeButton,
{
backgroundColor: processingStatus === 'success'
? '#2BAF2B'
: theme.colors.primary,
},
]}
activeOpacity={0.8}
>
<Text style={modalStyles.closeButtonText}>
{processingStatus === 'success' ? 'Done' : 'Close'}
</Text>
</TouchableOpacity>
)}
</View>
</View>
</Modal>
</AppView>
);
};
export default PaymentModeScreen;
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: 16,
},
amountCard: {
padding: 16,
borderRadius: 16,
borderWidth: 1,
marginBottom: 20,
},
amountRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 6,
},
amountLabel: {
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoSemiBold,
},
amountValue: {
fontSize: responsiveFontSize(3),
fontFamily: FontsFamily.nunitoExtraBold,
},
listContent: {
paddingBottom: 100,
gap: 10,
},
modeCard: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderRadius: 16,
borderWidth: 1.5,
},
radioOuter: {
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
radioInner: {
width: 12,
height: 12,
borderRadius: 6,
},
modeIconContainer: {
width: 48,
height: 48,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
modeIconImage: {
width: 32,
height: 32,
borderRadius: 6,
},
modeInfo: {
flex: 1,
},
modeName: {
fontSize: responsiveFontSize(1.7),
fontFamily: FontsFamily.nunitoBold,
},
modeProvider: {
fontSize: responsiveFontSize(1.3),
fontFamily: FontsFamily.nunitoSemiBold,
marginTop: 2,
},
modeLimits: {
fontSize: responsiveFontSize(1.1),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 2,
},
badgeContainer: {
marginLeft: 8,
},
badge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 6,
},
badgeText: {
fontSize: responsiveFontSize(1),
fontFamily: FontsFamily.nunitoBold,
textTransform: 'uppercase',
},
loaderContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
gap: 12,
},
loaderText: {
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoSemiBold,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
gap: 12,
},
emptyText: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoSemiBold,
},
bottomBar: {
paddingHorizontal: 16,
paddingVertical: 12,
borderTopWidth: 1,
},
confirmButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 16,
borderRadius: 14,
},
confirmText: {
color: '#fff',
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoBold,
},
});
const modalStyles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 32,
},
card: {
width: '100%',
borderRadius: 20,
paddingVertical: 36,
paddingHorizontal: 24,
alignItems: 'center',
},
iconCircle: {
width: 80,
height: 80,
borderRadius: 40,
justifyContent: 'center',
alignItems: 'center',
},
title: {
fontSize: responsiveFontSize(2.2),
fontFamily: FontsFamily.nunitoExtraBold,
marginTop: 20,
textAlign: 'center',
},
message: {
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoSemiBold,
marginTop: 10,
textAlign: 'center',
lineHeight: 20,
},
timerContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginTop: 16,
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 20,
backgroundColor: 'rgba(128,128,128,0.1)',
},
timerText: {
fontSize: responsiveFontSize(1.2),
fontFamily: FontsFamily.nunitoSemiBold,
},
closeButton: {
marginTop: 24,
paddingVertical: 14,
paddingHorizontal: 40,
borderRadius: 12,
},
closeButtonText: {
color: '#fff',
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoBold,
},
});
+157 -123
View File
@@ -3,192 +3,226 @@ import {
View, View,
Text, Text,
StyleSheet, StyleSheet,
Alert,
ActivityIndicator,
ScrollView, ScrollView,
KeyboardAvoidingView,
Platform,
TouchableOpacity, TouchableOpacity,
ToastAndroid, ToastAndroid,
} from "react-native"; } from "react-native";
import * as Keychain from "react-native-keychain"; import LucideIcon from '../../components/common/LucideIcon';
import { CustomInput, CustomButton } from "../../components/common/Custom";
import colors from "../../components/colors";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
import { responsiveFontSize } from "react-native-responsive-dimensions";
import { CustomInput, CustomButton } from "../../components/common/Custom";
import { useTheme } from "../../hooks/ui/useTheme";
import { useCreate } from "../../hooks/core/useCreate";
import AppView from "../../components/AppView"; import AppView from "../../components/AppView";
import { Ionicons } from "@react-native-vector-icons/ionicons"; import FontsFamily from "../../constents/FontsFamily";
import { BASE_URL } from "../../utils/ApiUrls"; import { APPLY_ORGANIZER_URL } from "../../utils/ApiUrls";
type OrganizerApplyErrors = {
idName?: string;
idNumber?: string;
issueDate?: string;
};
const isValidIdType = (value: string) => /^[A-Za-z][A-Za-z\s-]{1,}$/.test(value.trim());
const isValidIdNumber = (value: string) => /^[A-Za-z0-9-]{6,24}$/.test(value.trim());
const formatIssueDateInput = (value: string) => {
const digits = value.replace(/\D/g, "").slice(0, 8);
if (digits.length <= 2) return digits;
if (digits.length <= 4) return `${digits.slice(0, 2)}-${digits.slice(2)}`;
return `${digits.slice(0, 2)}-${digits.slice(2, 4)}-${digits.slice(4)}`;
};
const isValidIssueDate = (value: string) => {
if (!/^\d{2}-\d{2}-\d{4}$/.test(value)) return false;
const [day, month, year] = value.split("-").map(Number);
const parsed = new Date(Date.UTC(year, month - 1, day));
const isRealDate =
parsed.getUTCFullYear() === year &&
parsed.getUTCMonth() + 1 === month &&
parsed.getUTCDate() === day;
if (!isRealDate) return false;
const today = new Date();
const todayUtc = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
return parsed.getTime() <= todayUtc;
};
const BecomeOrg = () => { const BecomeOrg = () => {
const theme = useTheme();
const navigation = useNavigation<any>(); const navigation = useNavigation<any>();
const [idName, setIdName] = useState(""); const [idName, setIdName] = useState("");
const [idNumber, setIdNumber] = useState(""); const [idNumber, setIdNumber] = useState("");
const [issueDate, setIssueDate] = useState(""); const [issueDate, setIssueDate] = useState("");
const [loading, setLoading] = useState(false); const [errors, setErrors] = useState<OrganizerApplyErrors>({});
const handleSubmit = async () => { const { mutate: applyOrganizer, isPending: loading } = useCreate<any, any>(APPLY_ORGANIZER_URL, {
if (!idName || !idNumber || !issueDate) { onSuccess: (res) => {
ToastAndroid.show("Please fill all fields", ToastAndroid.SHORT); console.log("api res",res)
return; if (res.status === "success") {
} ToastAndroid.show("Application submitted successfully!", ToastAndroid.SHORT);
navigation.navigate("Home");
try {
setLoading(true);
const credentials = await Keychain.getGenericPassword();
if (!credentials) {
ToastAndroid.show("You are not logged in. Please log in to continue.", ToastAndroid.SHORT);
setLoading(false);
// Optionally navigate to login screen
// navigation.navigate("Login");
return;
}
const token = credentials.password;
const response = await fetch(
`${BASE_URL}organizer/registration/apply`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
idName,
idNumber,
issueDate,
}),
}
);
const result = await response.json();
if (response.ok && result.status === "success") {
ToastAndroid.show("Organizer registration application submitted successfully!", ToastAndroid.SHORT);
setIdName("");
setIdNumber("");
setIssueDate("");
navigation.navigate("Home" as never);
} else { } else {
ToastAndroid.show(result.message || "Failed to submit application", ToastAndroid.SHORT); ToastAndroid.show(res.message || "Failed to submit application", ToastAndroid.SHORT);
} }
} catch (error) { },
console.error("❌ Registration error:", error); onError: (err: any) => {
ToastAndroid.show("An unexpected error occurred. Please try again.", ToastAndroid.SHORT); ToastAndroid.show(err.message || "An error occurred", ToastAndroid.SHORT);
} finally { }
setLoading(false); });
const handleChange = (field: keyof OrganizerApplyErrors, value: string) => {
if (field === "idName") setIdName(value);
if (field === "idNumber") setIdNumber(value);
if (field === "issueDate") setIssueDate(value);
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: "" }));
} }
}; };
const validateForm = () => {
const nextErrors: OrganizerApplyErrors = {};
if (!isValidIdType(idName)) {
nextErrors.idName = "Enter a valid ID type (min 2 letters).";
}
if (!isValidIdNumber(idNumber)) {
nextErrors.idNumber = "ID number must be 6-24 chars (A-Z, 0-9, -).";
}
if (!isValidIssueDate(issueDate)) {
nextErrors.issueDate = "Use valid date DD-MM-YYYY (not future).";
}
setErrors(nextErrors);
return Object.keys(nextErrors).length === 0;
};
const handleSubmit = () => {
if (!validateForm()) {
ToastAndroid.show("Please fix the highlighted fields.", ToastAndroid.SHORT);
return;
}
applyOrganizer({
idName: idName.trim(),
idNumber: idNumber.trim(),
issueDate: issueDate.trim(),
});
};
return ( return (
<AppView barStyle="dark-content"> <AppView keyboardVerticalOffset={60} dismissKeyboardOnTap={false}>
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backButton}> <TouchableOpacity onPress={() => navigation.goBack()} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color={'#000'} /> <LucideIcon name="arrow-back" size={24} color={theme.colors.textPrimary} />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.headerTitle}>Become an Organizer</Text> <Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}>Become Organizer</Text>
<View style={styles.placeholder} />
</View> </View>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === "ios" ? "padding" : undefined}
keyboardVerticalOffset={Platform.OS === "ios" ? 60 : 0}
>
<ScrollView <ScrollView
contentContainerStyle={styles.container} style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
showsVerticalScrollIndicator={false}
> >
<Text style={styles.title}>Submit Your ID for Verification</Text> <View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<Text style={styles.subtitle}> <Text style={[styles.title, { color: theme.colors.textPrimary }]}>ID Verification</Text>
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
Please provide your official identification details to become a verified organizer. Please provide your official identification details to become a verified organizer.
</Text> </Text>
<CustomInput <CustomInput
placeholder="ID Name (e.g., Aadhaar, PAN Card)" label="ID Type"
placeholder="e.g., Aadhaar, PAN Card"
value={idName} value={idName}
onChangeText={setIdName} onChangeText={(val) => handleChange("idName", val)}
icon="person-outline" icon="person-outline"
error={""} error={errors.idName}
/> />
<CustomInput <CustomInput
placeholder="ID Number" label="ID Number"
placeholder="Enter your ID number"
value={idNumber} value={idNumber}
onChangeText={setIdNumber} onChangeText={(val) => handleChange("idNumber", val.toUpperCase())}
autoCapitalize="characters"
icon="card-outline" icon="card-outline"
error="" error={errors.idNumber}
/> />
<CustomInput <CustomInput
placeholder="Issue Date (YYYY-MM-DD)" label="Issue Date"
placeholder="DD-MM-YYYY"
value={issueDate} value={issueDate}
onChangeText={setIssueDate} onChangeText={(val) => handleChange("issueDate", formatIssueDateInput(val))}
icon="calendar-outline" icon="calendar-outline"
error="" error={errors.issueDate}
/> />
<CustomButton <CustomButton
title={loading ? "Submitting..." : "Request Organizer Role"} title="Submit Application"
onPress={handleSubmit} onPress={handleSubmit}
loading={loading}
style={styles.submitBtn}
icon="send-outline" icon="send-outline"
buttonBackground={colors.pastelBlue}
style={{ opacity: loading ? 0.6 : 1, marginTop: 20 }}
/> />
</View>
{loading && (
<ActivityIndicator
style={{ marginTop: 15 }}
size="large"
color={colors.primary}
/>
)}
</ScrollView> </ScrollView>
</KeyboardAvoidingView>
</AppView> </AppView>
); );
}; };
export default BecomeOrg;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: {
flexGrow: 1,
padding: 20,
justifyContent: "center",
},
title: {
fontSize: 24,
fontWeight: "700",
marginBottom: 10,
textAlign: "center",
color: colors.textDark,
},
subtitle: {
fontSize: 16,
textAlign: "center",
color: colors.textDark,
marginBottom: 30,
},
header: { header: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
paddingHorizontal: 16, paddingHorizontal: 24,
paddingVertical: 12, paddingVertical: 16,
backgroundColor: '#FFF',
borderBottomWidth: 1,
borderBottomColor: '#E0E0E0',
}, },
backButton: { backButton: {
padding: 5, marginRight: 16,
padding: 4,
}, },
headerTitle: { headerTitle: {
fontSize: responsiveFontSize(2.4),
fontFamily: FontsFamily.nunitoBold,
},
scrollView: {
flex: 1, flex: 1,
fontSize: 20, },
fontWeight: "600", scrollContent: {
color: "#000", flexGrow: 1,
paddingHorizontal: 20,
paddingTop: 10,
justifyContent: "flex-start",
paddingBottom: 40,
},
card: {
padding: 24,
borderRadius: 24,
borderWidth: 1,
elevation: 2,
shadowColor: '#000',
shadowOpacity: 0.04,
shadowRadius: 12,
shadowOffset: { width: 0, height: 4 },
},
title: {
fontSize: responsiveFontSize(2.6),
fontFamily: FontsFamily.nunitoBold,
marginBottom: 8,
textAlign: "center", textAlign: "center",
}, },
placeholder: { subtitle: {
width: 24, // to balance the back button fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
textAlign: "center",
marginBottom: 30,
}, },
submitBtn: {
marginTop: 12,
}
}); });
export default BecomeOrg;
+121 -68
View File
@@ -3,24 +3,35 @@ import {
StyleSheet, StyleSheet,
View, View,
Text, Text,
Alert,
ActivityIndicator,
TouchableOpacity, TouchableOpacity,
ToastAndroid, ToastAndroid,
} from "react-native"; } from "react-native";
import { useNavigation, useRoute } from "@react-navigation/native"; import { useNavigation, useRoute } from "@react-navigation/native";
import LucideIcon from '../../components/common/LucideIcon';
import { responsiveFontSize } from "react-native-responsive-dimensions";
import { CustomButton, CustomInput } from "../../components/common/Custom"; import { CustomButton, CustomInput } from "../../components/common/Custom";
import colors from "../../components/colors"; import { useTheme } from "../../hooks/ui/useTheme";
import { useUpdate } from "../../hooks/core/useUpdate";
import AppView from "../../components/AppView"; import AppView from "../../components/AppView";
import { Ionicons } from "@react-native-vector-icons/ionicons"; import FontsFamily from "../../constents/FontsFamily";
import { UPDATE_BANK_URL } from "../../utils/ApiUrls"; import { UPDATE_BANK_URL } from "../../utils/ApiUrls";
import { apiCall } from "../../utils/ApiHelper";
type BankFormErrors = {
bankName?: string;
bankAccountNumber?: string;
branchCode?: string;
};
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 EditBankDetails = () => { const EditBankDetails = () => {
const theme = useTheme();
const navigation = useNavigation<any>(); const navigation = useNavigation<any>();
const route = useRoute(); const route = useRoute();
// Get initial data from route parameters passed from the Profile screen
const { const {
currentBankName, currentBankName,
currentAccountNumber, currentAccountNumber,
@@ -30,101 +41,126 @@ const EditBankDetails = () => {
currentBankName?: string; currentBankName?: string;
currentAccountNumber?: string; currentAccountNumber?: string;
currentBranchCode?: string; currentBranchCode?: string;
onUpdate:()=>void onUpdate: () => void;
}; };
// Initialize state with the passed-in details
const [bankName, setBankName] = useState(currentBankName || ""); const [bankName, setBankName] = useState(currentBankName || "");
const [bankAccountNumber, setBankAccountNumber] = useState(currentAccountNumber || ""); const [bankAccountNumber, setBankAccountNumber] = useState(currentAccountNumber || "");
const [branchCode, setBranchCode] = useState(currentBranchCode || ""); const [branchCode, setBranchCode] = useState(currentBranchCode || "");
const [loading, setLoading] = useState(false); const [errors, setErrors] = useState<BankFormErrors>({});
// The update function remains the same, using the local state const { mutate: updateBank, isPending: loading } = useUpdate<any, any>(UPDATE_BANK_URL, {
const handleUpdateBankDetails = async () => { onSuccess: () => {
if (!bankName || !bankAccountNumber || !branchCode) { ToastAndroid.show("Bank details updated successfully!", ToastAndroid.SHORT);
ToastAndroid.show("Please fill in all bank details.", ToastAndroid.SHORT); onUpdate();
navigation.goBack();
},
onError: (error: any) => {
ToastAndroid.show(error.message || "Failed to update bank details", ToastAndroid.SHORT);
}
});
const handleChange = (field: keyof BankFormErrors, value: string) => {
if (field === "bankName") setBankName(value);
if (field === "bankAccountNumber") setBankAccountNumber(value.replace(/\D/g, ""));
if (field === "branchCode") setBranchCode(value.toUpperCase());
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: "" }));
}
};
const validateForm = () => {
const nextErrors: BankFormErrors = {};
if (!isValidBankName(bankName)) {
nextErrors.bankName = "Enter a valid bank name (min 2 letters).";
}
if (!isValidAccountNumber(bankAccountNumber)) {
nextErrors.bankAccountNumber = "Account number must be 9-18 digits.";
}
if (!isValidIfsc(branchCode)) {
nextErrors.branchCode = "Enter a valid IFSC (e.g. HDFC0001234).";
}
setErrors(nextErrors);
return Object.keys(nextErrors).length === 0;
};
const handleUpdate = () => {
if (!validateForm()) {
ToastAndroid.show("Please fix the highlighted fields.", ToastAndroid.SHORT);
return; return;
} }
updateBank({
try { bankName: bankName.trim(),
setLoading(true); bankAccountNumber: bankAccountNumber.trim(),
const response = await apiCall(UPDATE_BANK_URL, 'PUT', { branchCode: branchCode.trim(),
bankName, });
bankAccountNumber,
branchCode,
}) as { status: string; message?: string };
console.log("Update Bank Response:", response);
if (response.status === "success") {
ToastAndroid.show(response.message || "Bank details updated successfully!", ToastAndroid.SHORT);
navigation.goBack();
onUpdate(); // Call the onUpdate function to refresh profile data
} else {
ToastAndroid.show(response.message || "Could not update bank details.", ToastAndroid.SHORT);
}
} catch (error: any) {
console.error("Error updating bank details:", error);
ToastAndroid.show(error.message || "An unexpected error occurred. Please try again.", ToastAndroid.SHORT);
} finally {
setLoading(false);
}
}; };
return ( return (
<AppView> <AppView>
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backButton}> <TouchableOpacity onPress={() => navigation.goBack()} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color={colors.Profile} /> <LucideIcon name="arrow-back" size={24} color={theme.colors.textPrimary} />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.headerTitle}>Edit Bank Details</Text> <Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}>Edit Bank Details</Text>
</View> </View>
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.sectionTitle}>Update Your Bank Information</Text> <View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Bank Information</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
Update your account details for receiving payments.
</Text>
<CustomInput <CustomInput
label="Bank Name" label="Bank Name"
placeholder="Enter your bank's name" placeholder="Enter your bank's name"
value={bankName} value={bankName}
onChangeText={setBankName} onChangeText={(val) => handleChange("bankName", val)}
icon="business" icon="business-outline"
error="" error={errors.bankName}
/> />
<CustomInput <CustomInput
label="Bank Account Number" label="Bank Account Number"
placeholder="Enter your account number" placeholder="Enter your account number"
value={bankAccountNumber} value={bankAccountNumber}
onChangeText={setBankAccountNumber} onChangeText={(val) => handleChange("bankAccountNumber", val)}
icon="card" icon="card-outline"
keyboardType="numeric" keyboardType="numeric"
error="" maxLength={18}
error={errors.bankAccountNumber}
/> />
<CustomInput <CustomInput
label="Branch Code" label="Branch Code (IFSC)"
placeholder="Enter the branch code (IFSC)" placeholder="Enter the branch code"
value={branchCode} value={branchCode}
onChangeText={setBranchCode} onChangeText={(val) => handleChange("branchCode", val)}
icon="barcode" autoCapitalize="characters"
error="" maxLength={11}
icon="barcode-outline"
error={errors.branchCode}
/> />
<View style={styles.buttonContainer}> <View style={styles.buttonContainer}>
<CustomButton <CustomButton
title={loading ? "Saving..." : "Save Changes"} title="Save Changes"
onPress={handleUpdateBankDetails} onPress={handleUpdate}
icon={loading ? undefined : "save"} loading={loading}
style={styles.saveButton}
buttonBackground={colors.pastelBlue}
/> />
<CustomButton <CustomButton
title="Cancel" title="Cancel"
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
type="outline"
style={styles.cancelButton} style={styles.cancelButton}
buttonBackground={colors.pastelBlue}
/> />
</View> </View>
</View> </View>
</View>
</AppView> </AppView>
); );
}; };
@@ -133,33 +169,50 @@ const styles = StyleSheet.create({
header: { header: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
padding: 16, paddingHorizontal: 24,
paddingVertical: 16,
}, },
backButton: { backButton: {
marginRight: 16, marginRight: 16,
padding: 4,
}, },
headerTitle: { headerTitle: {
fontSize: 22, fontSize: responsiveFontSize(2.4),
fontWeight: "600", fontFamily: FontsFamily.nunitoBold,
color: colors.Profile,
}, },
container: { container: {
flex: 1, flex: 1,
padding: 16, paddingHorizontal: 20,
paddingTop: 10,
},
card: {
padding: 24,
borderRadius: 24,
borderWidth: 1,
elevation: 2,
shadowColor: '#000',
shadowOpacity: 0.04,
shadowRadius: 12,
shadowOffset: { width: 0, height: 4 },
}, },
sectionTitle: { sectionTitle: {
fontSize: 18, fontSize: responsiveFontSize(2),
fontWeight: "700", fontFamily: FontsFamily.nunitoBold,
color: "#1e293b", marginBottom: 8,
marginBottom: 20, },
sectionSubtitle: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
marginBottom: 24,
}, },
buttonContainer: { buttonContainer: {
marginTop: 24, marginTop: 12,
},
saveButton: {
marginBottom: 4,
}, },
cancelButton: { cancelButton: {
marginTop: 12, marginTop: 4,
borderWidth: 1,
borderColor: "#cbd5e1",
}, },
}); });
File diff suppressed because it is too large Load Diff
+74 -44
View File
@@ -1,12 +1,11 @@
import React, { useState } from "react"; import React from "react";
import { View, Text, StyleSheet, Switch } from "react-native"; import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { responsiveFontSize } from "react-native-responsive-dimensions";
import LucideIcon from '../../components/common/LucideIcon';
type RootStackParamList = { import { useTheme } from "../../hooks/ui/useTheme";
BecomeOrg: undefined; import FontsFamily from "../../constents/FontsFamily";
Profile: undefined;
};
type SwitchNavigatorProps = { type SwitchNavigatorProps = {
role: string; role: string;
@@ -14,54 +13,85 @@ type SwitchNavigatorProps = {
}; };
const SwitchNavigator: React.FC<SwitchNavigatorProps> = ({ role, status }) => { const SwitchNavigator: React.FC<SwitchNavigatorProps> = ({ role, status }) => {
const navigation = const theme = useTheme();
useNavigation<NativeStackNavigationProp<RootStackParamList>>(); const navigation = useNavigation<any>();
const [isEnabled, setIsEnabled] = useState(false); const canApply = role === "USER" && (status === "NOT_APPLIED" || !status);
const prettyStatus = status ? status.replace(/_/g, " ") : "NOT APPLIED";
const toggleSwitch = () => { const primaryText = canApply ? "Become an Organizer" : "Organizer Application";
const newValue = !isEnabled; const secondaryText = canApply
setIsEnabled(newValue); ? "Tap to apply and unlock organizer tools"
: `Current status: ${prettyStatus}`;
if (newValue) {
navigation.navigate("BecomeOrg"); // Navigate when ON
} else {
navigation.navigate("Profile"); // Navigate when OFF
}
};
console.log("status :", status);
console.log("role :", role);
return ( return (
<View style={styles.container}> <TouchableOpacity
<Text style={styles.text}> activeOpacity={0.88}
{!isEnabled && role !== "USER" onPress={() => navigation.navigate("BecomeOrg")}
? "Become an Organizer" style={[styles.container, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}
: `Request Organizer Role ${status}`} >
</Text> <View style={[styles.leadingIcon, { backgroundColor: theme.colors.primarySoft }]}>
<Switch <LucideIcon
trackColor={{ false: "#767577", true: "#81b0ff" }} name={canApply ? "sparkles-outline" : "time-outline"}
thumbColor={isEnabled ? "#007AFF" : "#f4f3f4"} size={20}
ios_backgroundColor="#3e3e3e" color={theme.colors.primary}
onValueChange={toggleSwitch}
value={isEnabled}
/> />
</View> </View>
<View style={styles.textBlock}>
<Text style={[styles.primaryText, { color: theme.colors.textPrimary }]}>{primaryText}</Text>
<Text style={[styles.secondaryText, { color: theme.colors.textSecondary }]}>{secondaryText}</Text>
</View>
{ prettyStatus !== "PENDING" && <View style={[styles.arrowBox, { borderColor: theme.colors.border, backgroundColor: theme.colors.background }]}>
<LucideIcon name="chevron-forward" size={18} color={theme.colors.textSecondary} />
</View>}
</TouchableOpacity>
); );
}; };
export default SwitchNavigator;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flexDirection: "row", flexDirection: "row",
justifyContent: 'flex-end', alignItems: 'center',
paddingHorizontal: 16, paddingHorizontal: 14,
paddingVertical: 10, paddingVertical: 13,
gap: 10, borderRadius: 16,
borderWidth: 1,
elevation: 1,
shadowColor: '#000',
shadowOpacity: 0.02,
shadowRadius: 8,
shadowOffset: { width: 0, height: 2 },
}, },
text: { leadingIcon: {
fontSize: 18, width: 34,
flexShrink: 1, // Wrap text if too long height: 34,
borderRadius: 17,
justifyContent: "center",
alignItems: "center",
marginRight: 10,
},
textBlock: {
flex: 1,
},
primaryText: {
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoSemiBold,
},
secondaryText: {
fontSize: responsiveFontSize(1.35),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 2,
},
arrowBox: {
width: 30,
height: 30,
borderRadius: 15,
borderWidth: 1,
justifyContent: "center",
alignItems: "center",
marginLeft: 10,
}, },
}); });
export default SwitchNavigator;
+63 -11
View File
@@ -1,17 +1,69 @@
import { StyleSheet, Text, View } from 'react-native' import React from 'react';
import React from 'react' import { StyleSheet, Text, View, TouchableOpacity } from 'react-native';
import AppView from '../../components/AppView' import LucideIcon from '../../components/common/LucideIcon';
import { useNavigation } from "@react-navigation/native";
import { responsiveFontSize } from "react-native-responsive-dimensions";
import AppView from '../../components/AppView';
import { useTheme } from "../../hooks/ui/useTheme";
import FontsFamily from "../../constents/FontsFamily";
const Transaction = () => { const Transaction = () => {
return ( const theme = useTheme();
<AppView barStyle='dark-content'> const navigation = useNavigation();
<Text>Transaction</Text>
</AppView>
)
}
export default Transaction return (
<AppView>
<View style={styles.header}>
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backButton}>
<LucideIcon name="arrow-back" size={24} color={theme.colors.textPrimary} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}>Transactions</Text>
</View>
<View style={styles.container}>
<LucideIcon name="receipt-outline" size={64} color={theme.palette.greyMedium} />
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Coming Soon</Text>
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
Your transaction history will appear here.
</Text>
</View>
</AppView>
);
};
const styles = StyleSheet.create({ const styles = StyleSheet.create({
header: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 24,
paddingVertical: 16,
},
backButton: {
marginRight: 16,
padding: 4,
},
headerTitle: {
fontSize: responsiveFontSize(2.4),
fontFamily: FontsFamily.nunitoBold,
},
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 40,
},
title: {
fontSize: responsiveFontSize(2.4),
fontFamily: FontsFamily.nunitoBold,
marginTop: 20,
marginBottom: 8,
},
subtitle: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
textAlign: 'center',
}
});
}) export default Transaction;
+87 -47
View File
@@ -1,75 +1,115 @@
export type ThemeMode = 'light' | 'dark'; export type ThemeMode = 'light' | 'dark';
export const palette = {
black: '#000000',
greyDark: '#6F6F73',
greyMedium: '#9A9A9A',
greyLight: '#B0B0B0',
greyExtraLight: '#E6E6E6',
white: '#FFFFFF',
};
export const spacing = {
xs: 4,
sm: 6,
md: 10,
lg: 14,
xl: 18,
xxl: 24,
xxxl: 32,
};
export const borderRadius = {
sm: 4,
md: 8,
lg: 12,
xl: 16,
xxl: 24,
full: 100,
};
export interface ThemeColors { export interface ThemeColors {
primary: string; primary: string;
primarySoft: string;
onPrimary: string;
secondary: string; secondary: string;
background: string; background: string;
surface: string;
card: string; card: string;
textPrimary: string; textPrimary: string;
textSecondary: string; textSecondary: string;
success: string; textMuted: string;
border: string;
logincard?: string;
logincard1?: string;
reverseBackground: string; reverseBackground: string;
reverseText?: string; reverseText: string;
optCard?: string; border: string;
gray?: string; borderLight: string;
black?: string; white: string;
white?: string; black: string;
red?: string; error: string;
green?: string; success: string;
warning: string;
} }
export interface Theme { export interface Theme {
mode: ThemeMode; mode: ThemeMode;
colors: ThemeColors; colors: ThemeColors;
spacing: typeof spacing;
borderRadius: typeof borderRadius;
palette: typeof palette;
} }
export const light: Theme = { export const light: Theme = {
mode: 'light', mode: 'light',
spacing,
borderRadius,
palette,
colors: { colors: {
primary: '#F4721E', primary: '#1C1C1F',
secondary: '#FF9A62', primarySoft: 'rgba(28, 28, 31, 0.1)',
reverseBackground: '#000000', onPrimary: palette.white,
background: '#FFFFFF', secondary: '#5E5E64',
card: '#F3F4F6', background: palette.white,
textPrimary: '#000000', surface: '#F4F4F6',
reverseText: '#FFFFFF', card: palette.white,
textSecondary: '#666666', textPrimary: palette.black,
success: '#008e02', textSecondary: '#5E5E64',
border: '#E5E7EB', textMuted: '#7B7B82',
logincard: '#e0e0e0', reverseBackground: palette.black,
logincard1: '#66666eb3', reverseText: palette.white,
optCard: '#66666eb3', border: '#DADAE0',
gray:'#e0e0e0ff', borderLight: '#ECECF1',
black:'#000000', white: palette.white,
white:'#FFFFFF', black: palette.black,
red:'#FF0000', error: '#B94747',
green:'#00C853' success: '#2E8A57',
warning: '#9B6E19',
}, },
}; };
export const dark: Theme = { export const dark: Theme = {
mode: 'dark', mode: 'dark',
spacing,
borderRadius,
palette,
colors: { colors: {
primary: '#F4721E', primary: '#E4E4E8',
secondary: '#FF9A62', primarySoft: 'rgba(228, 228, 232, 0.18)',
reverseBackground: '#FFFFFF', onPrimary: '#101013',
background: '#000000', // Fixed: Dark mode = Black bg secondary: '#C1C1C8',
card: '#111214', background: '#0D0D11',
textPrimary: '#FFFFFF', surface: '#1A1A1F',
textSecondary: '#A1A1A1', card: '#141419',
reverseText: '#000000', textPrimary: '#F5F5F8',
success: '#00C853', textSecondary: '#C1C1C8',
border: '#222427', textMuted: '#9A9AA2',
logincard: '#000000', reverseBackground: palette.white,
logincard1: '#8d8d8f', reverseText: palette.black,
optCard: '#e0e0e0', border: '#2D2D35',
gray:'#66666eb3', borderLight: '#22222A',
black:'#000000', white: palette.white,
white:'#FFFFFF', black: palette.black,
red:'#FF0000', error: '#B86666',
green:'#00C853' success: '#3B8E62',
warning: '#A87B2F',
}, },
}; };
+2 -2
View File
@@ -16,8 +16,8 @@ export interface MyGroup {
currentParticipants: number; currentParticipants: number;
maxParticipants: number; maxParticipants: number;
contributionAmount: number; contributionAmount: number;
cycleType: "MONTHLY" | "QUARTERLY" | "YEARLY"; cycleType: "CUSTOM" | "DAILY" | "WEEKLY" | "MONTHLY" | "QUARTERLY" | "HALF_YEARLY" | "YEARLY";
status: "PENDING" | "ACTIVE" | "COMPLETED"; status: "PENDING" | "APPROVED" | "ACTIVE" | "DELAY" | "COMPLETED" | "REJECTED" | "CANCELLED";
} }
export interface JoinedGroup { export interface JoinedGroup {
+26 -7
View File
@@ -1,5 +1,5 @@
import axios, { AxiosRequestConfig } from "axios"; import axios, { AxiosRequestConfig } from "axios";
import { Alert, ToastAndroid } from "react-native"; import { ToastAndroid } from "react-native";
import * as Keychain from "react-native-keychain"; import * as Keychain from "react-native-keychain";
/** /**
@@ -16,13 +16,23 @@ export async function apiCall<T>(
config?: AxiosRequestConfig, config?: AxiosRequestConfig,
responseErrorMessage?:string responseErrorMessage?:string
): Promise<T | null > { ): Promise<T | null > {
const normalizedMethod = method.toUpperCase();
const requestPayload =
normalizedMethod === "GET" || normalizedMethod === "DELETE"
? config?.params ?? null
: data ?? null;
console.log(`[HOOK_API][${normalizedMethod}] URL:`, url);
console.log(`[HOOK_API][${normalizedMethod}] PAYLOAD:`, requestPayload);
try { try {
// Get saved credentials from Keychain // Get saved credentials from Keychain
const creds = await Keychain.getGenericPassword(); const creds = await Keychain.getGenericPassword();
console.log("Keychain Credentials Found:", creds); // Log if creds were found console.log("Keychain Credentials Found:", creds); // Log if creds were found
const isMultipart = typeof FormData !== "undefined" && data instanceof FormData;
const headers = { const headers = {
"Content-Type": "application/json", "Content-Type": isMultipart ? "multipart/form-data" : "application/json",
...(creds ? { Authorization: `Bearer ${creds.password}` } : {}), ...(creds ? { Authorization: `Bearer ${creds.password}` } : {}),
...config?.headers, ...config?.headers,
}; };
@@ -35,11 +45,22 @@ export async function apiCall<T>(
headers, headers,
}); });
// --- Response Logging --- console.log(`[HOOK_API][${normalizedMethod}] SUCCESS URL:`, url);
console.log(`--- API Response:${url}///// [${response}] `); console.log(`[HOOK_API][${normalizedMethod}] SUCCESS META:`, {
statusCode: response.status ?? null,
status: response.statusText ?? null,
message: (response.data as { message?: string })?.message ?? null,
});
console.log(`[HOOK_API][${normalizedMethod}] SUCCESS RESPONSE:`, response.data);
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
console.error(`[HOOK_API][${normalizedMethod}] ERROR URL:`, url);
console.error(
`[HOOK_API][${normalizedMethod}] ERROR RESPONSE:`,
error?.response?.data ?? error?.message ?? error
);
if (error.response) { if (error.response) {
// Server responded with a status other than 2xx // Server responded with a status other than 2xx
// console.error( // console.error(
@@ -60,8 +81,6 @@ export async function apiCall<T>(
// console.error("API Error:", error); // console.error("API Error:", error);
} }
console.log(`API : ${url} API Error:`, error); console.log(`API : ${url} API Error:`, error);
return error.response.status return null;
// Re-throw the error so that the calling function can handle it if needed
throw error;
} }
} }
+3
View File
@@ -26,3 +26,6 @@ export const STORE_FCM_TOKEN = `${BASE_URL}token/store?fcmToken=`
export const REMOVE_FCM_TOKEN = `${BASE_URL}token/remove?fcmToken=` export const REMOVE_FCM_TOKEN = `${BASE_URL}token/remove?fcmToken=`
export const DASHBOARD_URL = `${BASE_URL}fund/dashboard` export const DASHBOARD_URL = `${BASE_URL}fund/dashboard`
export const PAY_ALL_URL = `${BASE_URL}payments/payments/bulk` export const PAY_ALL_URL = `${BASE_URL}payments/payments/bulk`
export const APPLY_ORGANIZER_URL = `${BASE_URL}organizer/registration/apply`;
export const USER_MOBILE_MONEY_URL = `${BASE_URL}user/register/mobile/money`
export const PAYMENT_MODES_URL = `${BASE_URL}payments/getAll/payment-mode`