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,
"java.compile.nullAnalysis.mode": "automatic"
"java.compile.nullAnalysis.mode": "automatic",
"java.configuration.updateBuildConfiguration": "interactive"
}
+19 -8
View File
@@ -1,7 +1,8 @@
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 { PaperProvider } from 'react-native-paper';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import AppNavigator from './src/navigation/AppNavigator';
import notifee from '@notifee/react-native';
@@ -15,8 +16,16 @@ import {
} 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() {
@@ -46,11 +55,13 @@ function App() {
return (
<SafeAreaProvider>
<ThemeProvider>
<PaperProvider >
<AppNavigator />
</PaperProvider>
</ThemeProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<PaperProvider >
<AppNavigator />
</PaperProvider>
</ThemeProvider>
</QueryClientProvider>
</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
+2 -1
View File
@@ -117,4 +117,5 @@ dependencies {
implementation jscFlavor
}
}
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/stack": "^7.4.8",
"@reduxjs/toolkit": "^2.9.0",
"@tanstack/react-query": "^5.90.21",
"@types/react-native-material-ripple": "^0.9.6",
"axios": "^1.12.2",
"install": "^0.13.0",
"lucide-react-native": "^0.563.0",
"npm": "^11.6.0",
"react": "19.1.0",
"react-native": "0.81.1",
@@ -61,9 +63,9 @@
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0",
"@react-native-community/cli": "20.0.0",
"@react-native-community/cli-platform-android": "20.0.0",
"@react-native-community/cli-platform-ios": "20.0.0",
"@react-native-community/cli": "^14.0.1",
"@react-native-community/cli-platform-android": "^15.1.0",
"@react-native-community/cli-platform-ios": "^14.0.1",
"@react-native/babel-preset": "0.81.1",
"@react-native/eslint-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;
+80 -20
View File
@@ -1,40 +1,97 @@
import React from "react";
import { StatusBar, StyleSheet, View, ViewStyle } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import React from 'react';
import {
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 = {
children: React.ReactNode;
backgroundColor?: string;
barStyle?: "default" | "light-content" | "dark-content";
barStyle?: 'default' | 'light-content' | 'dark-content';
paddingHorizontal?: number;
paddingTop?: number;
style?: ViewStyle;
keyboardSafe?: boolean;
keyboardVerticalOffset?: number;
dismissKeyboardOnTap?: boolean;
};
export default function AppView({
children,
backgroundColor = "#f5f5f5",
barStyle = "dark-content",
paddingHorizontal = 12, // default
paddingTop = 8, // default
backgroundColor,
barStyle,
paddingHorizontal,
paddingTop,
style,
keyboardSafe = true,
keyboardVerticalOffset = 0,
dismissKeyboardOnTap = true,
}: 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 (
<SafeAreaView style={[styles.safeArea, { backgroundColor }]}>
<SafeAreaView style={[styles.safeArea, { backgroundColor: finalBackgroundColor }]}>
<StatusBar
translucent={false}
backgroundColor={backgroundColor}
barStyle={barStyle}
backgroundColor={finalBackgroundColor}
barStyle={finalBarStyle}
/>
<View
style={[
styles.container,
{ paddingHorizontal, paddingTop },
style, // user custom overrides
]}
>
{children}
</View>
{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
style={[
styles.container,
{ paddingHorizontal: finalPaddingHorizontal, paddingTop: finalPaddingTop },
style,
]}
>
{children}
</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>
);
}
@@ -43,6 +100,9 @@ const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
keyboardContainer: {
flex: 1,
},
container: {
flex: 1,
},
+2 -2
View File
@@ -8,7 +8,7 @@ import {
Platform,
} from "react-native";
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 { responsiveFontSize } from "react-native-responsive-dimensions";
import { useTheme } from "../theme/ThemeProvider";
@@ -121,7 +121,7 @@ const CustomAlert: React.FC<CustomAlertProps> = ({
<View style={styles.contentContainer}>
<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>
<Text style={[styles.alertTitle, { color: theme.colors.textPrimary }]}>{title}</Text>
+170 -200
View File
@@ -1,10 +1,12 @@
import React, { FC } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ToastAndroid } from 'react-native';
import colors from './colors';
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 { 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 {
id: string;
@@ -13,8 +15,8 @@ interface JoinedGroup {
visibility: 'PUBLIC' | 'PRIVATE';
createdByUserId: number;
createdByName: string;
startDate: [number, number, number] | null;
endDate: [number, number, number] | null;
startDate: string | null;
endDate: string | null;
minParticipants: number;
maxParticipants: number;
currentParticipants: number;
@@ -24,138 +26,141 @@ interface JoinedGroup {
}
interface JoinedGroupCardProps {
item: JoinedGroup | any;
onViewPress: () => void;
onPayPress: () => void; // function for Pay button
hasPaid?: boolean; // whether user has already paid
item: JoinedGroup;
hasPaid?: boolean;
}
const formatDate = (dateStr: string | null): string => {
if (!dateStr) return 'N/A';
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}`;
};
const getStatusStyle = (status: JoinedGroup['status']) => {
switch (status) {
case 'ACTIVE':
return {
backgroundColor: '#D1FAE5',
textColor: '#065F46',
};
case 'PENDING':
return {
backgroundColor: '#FEF3C7',
textColor: '#92400E',
};
case 'COMPLETED':
return {
backgroundColor: '#E5E7EB',
textColor: '#4B5563',
};
default:
return {
backgroundColor: '#E5E7EB',
textColor: '#4B5563',
};
}
};
export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item, onViewPress, hasPaid }) => {
const progress = (item.currentParticipants / item.maxParticipants) * 100;
const statusStyle = getStatusStyle(item.status);
export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item }) => {
const theme = useTheme();
const navigation = useNavigation<any>();
const [loading, setLoading] = React.useState(false);
const [payable, setPayable] = React.useState<any>();
const { data: payableRes } = useFetch<any>(`${CONTRIBUTE}${item.id}/calculate-contribution`);
const payable = payableRes?.data;
const { mutate: contribute, isPending: loading } = useCreate<any, any>(
`payments/contribute/${item?.id}?amount=${payable?.totalPayable}`,
{
onSuccess: () => {
ToastAndroid.show('Payment successful!', ToastAndroid.SHORT);
},
onError: () => {
ToastAndroid.show('Payment failed. Please try again.', ToastAndroid.SHORT);
},
}
);
const 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 getStatusColors = (status: JoinedGroup['status']) => {
switch (status) {
case 'ACTIVE':
return { bg: theme.palette.greyExtraLight, text: theme.palette.black };
case 'COMPLETED':
return { bg: theme.palette.greyLight, text: theme.palette.greyDark };
default:
return { bg: theme.palette.greyExtraLight, text: theme.palette.greyDark };
}
};
const handleContribution = async () => {
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();
}, []);
const statusColors = getStatusColors(item.status);
return (
<TouchableOpacity style={styles.card} onPress={() => navigation.navigate("GroupDetails", { item, color: colors.black })}>
{/* Header */}
<TouchableOpacity
style={[styles.card, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}
onPress={() => navigation.navigate('GroupDetails', { item })}
activeOpacity={0.7}
>
<View style={styles.header}>
<Text style={styles.groupName}>{item.groupName}</Text>
<View style={[styles.statusBadge, { backgroundColor: statusStyle.backgroundColor }]}>
<Text style={[styles.statusText, { color: statusStyle.textColor }]}>{item.status}</Text>
<View style={styles.titleContainer}>
<Text style={[styles.groupName, { color: theme.colors.textPrimary }]}>{item.groupName}</Text>
<Text style={[styles.createdBy, { color: theme.colors.textSecondary }]}>
by {item.createdByName}
</Text>
</View>
<View style={[styles.statusBadge, { backgroundColor: statusColors.bg }]}>
<Text style={[styles.statusText, { color: statusColors.text }]}>{item.status}</Text>
</View>
</View>
<Text style={styles.createdBy}>Created by {item.createdByName}</Text>
{/* Financial Info */}
<View style={styles.infoRow}>
{/* Contribution Amount — always visible */}
<View style={[styles.infoRow, { borderColor: theme.colors.border }]}>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>Contribution</Text>
<Text style={styles.infoValue}>₹{item.contributionAmount.toLocaleString('en-IN')}</Text>
<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}>Cycle</Text>
<Text style={styles.infoValue}>{item.cycleType}</Text>
<Text style={[styles.infoLabel, { color: theme.colors.textMuted }]}>Type</Text>
<Text style={[styles.infoValue, { color: theme.colors.textPrimary }]}>{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>
</>
{/* 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>
)}
{/* Footer with Dates & Buttons */}
<View style={styles.footer}>
<Text style={styles.dateText}>
{formatDate(item.startDate)} to {formatDate(item.endDate)}
<Text style={[styles.dateText, { color: theme.colors.textMuted }]}>
{formatDate(item.startDate)} — {formatDate(item.endDate)}
</Text>
{/* Add Pay / Paid button here */}
{/* {hasPaid ? (
<View style={styles.paidButton}>
<Text style={styles.paidButtonText}>Paid</Text>
{payable?.hasPaid ? (
<View style={[styles.paidBadge, { backgroundColor: theme.colors.success + '18' }]}>
<Text style={[styles.paidText, { color: theme.colors.success }]}>✓ PAID</Text>
</View>
) : (
<TouchableOpacity style={styles.payButton} onPress={onPayPress}>
<Text style={styles.payButtonText}>{loading ? 'Loading...' : ' Pay'}</Text>
</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>
}
item.status !== 'COMPLETED' && (
<TouchableOpacity
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>
)
)}
</View>
</TouchableOpacity>
);
@@ -163,144 +168,109 @@ export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item, onViewPress, h
const styles = StyleSheet.create({
card: {
backgroundColor: colors.pastelOrange,
borderRadius: 16,
padding: 20,
marginBottom: 16,
shadowColor: '#374151',
shadowOpacity: 0.08,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
elevation: 5,
marginHorizontal: 12
padding: 14,
marginBottom: 12,
marginHorizontal: 12,
borderWidth: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 4,
marginBottom: 10,
},
titleContainer: {
flex: 1,
},
groupName: {
fontSize: responsiveFontSize(2),
fontWeight: 'bold',
color: '#1F2937',
flexShrink: 1,
fontFamily: FontsFamily.nunitoExtraBold,
marginBottom: 2,
},
createdBy: {
fontSize: responsiveFontSize(1.4),
color: '#6B7280',
marginBottom: 16,
fontFamily: FontsFamily.nunitoRegular,
},
statusBadge: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 12,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 6,
},
statusText: {
fontSize: responsiveFontSize(1.2),
fontWeight: '600',
textTransform: 'uppercase',
fontFamily: FontsFamily.nunitoBold,
},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 16,
paddingVertical: 10,
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: colors.pastelOrange,
marginBottom: 16,
backgroundColor: colors.white,
borderRadius: 15,
marginBottom: 10,
},
infoItem: {
padding: 5,
paddingHorizontal: 10,
backgroundColor: colors.white,
alignItems: 'flex-start',
flex: 1,
},
infoLabel: {
fontSize: responsiveFontSize(1.4),
color: '#6B7280',
marginBottom: 4,
fontSize: responsiveFontSize(1.3),
fontFamily: FontsFamily.nunitoRegular,
marginBottom: 2,
},
infoValue: {
fontSize: responsiveFontSize(1.6),
fontWeight: '600',
color: '#111827',
textTransform: 'capitalize',
fontFamily: FontsFamily.nunitoBold,
},
progressContainer: {
marginBottom: 16,
payableSection: {
marginBottom: 12,
gap: 6,
},
progressLabels: {
payableRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 8,
alignItems: 'center',
},
participantText: {
fontSize: responsiveFontSize(1.4),
color: '#6B7280',
payableLabel: {
fontSize: responsiveFontSize(1.35),
fontFamily: FontsFamily.nunitoSemiBold,
},
participantCount: {
fontSize: responsiveFontSize(1.4),
fontWeight: '600',
color: '#111827',
},
progressBarBackground: {
height: 8,
backgroundColor: '#E5E7EB',
borderRadius: 4,
overflow: 'hidden',
},
progressBarFill: {
height: '100%',
backgroundColor: colors.pastelOrange,
borderRadius: 4,
payableValue: {
fontSize: responsiveFontSize(1.5),
fontFamily: FontsFamily.nunitoBold,
},
footer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 4,
},
dateText: {
fontSize: responsiveFontSize(1.4),
color: '#4B5563',
fontWeight: '500',
fontSize: responsiveFontSize(1.3),
fontFamily: FontsFamily.nunitoMedium,
},
// Pay button
payButton: {
elevation: 5,
backgroundColor: colors.white,
paddingHorizontal: 25,
paddingVertical: 10,
borderRadius: 10,
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
minWidth: 80,
alignItems: 'center',
},
payButtonText: {
color: colors.black,
fontSize: responsiveFontSize(1.4),
fontWeight: '600',
fontSize: responsiveFontSize(1.5),
fontFamily: FontsFamily.nunitoBold,
},
// Paid button
paidButton: {
backgroundColor: '#D1FAE5',
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 10,
paidBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 6,
},
paidButtonText: {
color: '#065F46',
fontSize: responsiveFontSize(1.4),
fontWeight: '600',
},
viewButton: {
backgroundColor: colors.pastelOrange,
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 10,
},
viewButtonText: {
color: '#000000ff',
fontSize: responsiveFontSize(1.4),
fontWeight: '600',
paidText: {
fontSize: responsiveFontSize(1.3),
fontFamily: FontsFamily.nunitoBold,
letterSpacing: 0.5,
},
});
+93 -87
View File
@@ -1,26 +1,21 @@
// components/MyGroupCard.tsx
import React from "react";
import {
View,
Text,
StyleSheet,
TouchableOpacity,
} from "react-native";
import { Ionicons } from "@react-native-vector-icons/ionicons";
import colors from "./colors";
import { responsiveFontSize } from "react-native-responsive-dimensions";
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import LucideIcon from './common/LucideIcon';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useTheme } from '../hooks/ui/useTheme';
import FontsFamily from '../constents/FontsFamily';
interface MyGroup {
id: string;
groupName: string;
description: string;
visibility: "PUBLIC" | "PRIVATE";
visibility: 'PUBLIC' | 'PRIVATE';
createdByName: string;
currentParticipants: number;
maxParticipants: number;
contributionAmount: number;
cycleType: "MONTHLY" | "QUARTERLY" | "YEARLY";
status: "PENDING" | "ACTIVE" | "COMPLETED";
cycleType: 'CUSTOM' | 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'HALF_YEARLY' | 'YEARLY';
status: 'PENDING' | 'APPROVED' | 'ACTIVE' | 'DELAY' | 'COMPLETED' | 'REJECTED' | 'CANCELLED';
}
interface Props {
@@ -29,55 +24,74 @@ interface Props {
}
export const MyGroupCard: React.FC<Props> = ({ item, onManage }) => {
const getStatusColor = () => {
const theme = useTheme();
const getStatusColors = () => {
switch (item.status) {
case "ACTIVE":
return colors.pastelGreen;
case "PENDING":
return colors.pastelOrange; // amber
case "COMPLETED":
return colors.pastelBlue;
case 'ACTIVE':
return { bg: theme.palette.greyExtraLight, text: theme.palette.black };
case 'APPROVED':
return { bg: theme.colors.success + '22', text: theme.colors.success };
case 'DELAY':
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:
return "#adb5bd";
return { bg: theme.palette.greyExtraLight, text: theme.palette.greyDark };
}
};
const statusColors = getStatusColors();
return (
<View style={styles.card}>
{/* Left Content */}
<View style={[styles.card, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}>
<View style={{ flex: 1 }}>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle} numberOfLines={1}>
<Text style={[styles.cardTitle, { color: theme.colors.textPrimary }]} numberOfLines={1}>
{item.groupName}
</Text>
<View style={[styles.statusBadge, { backgroundColor: getStatusColor() }]}>
<Text style={styles.statusBadgeText}>{item.status}</Text>
<View style={[styles.statusBadge, { backgroundColor: statusColors.bg }]}>
<Text style={[styles.statusBadgeText, { color: statusColors.text }]}>{item.status}</Text>
</View>
</View>
<Text style={styles.cardSubtitle} numberOfLines={2}>
<Text style={[styles.cardSubtitle, { color: theme.colors.textSecondary }]} numberOfLines={2}>
{item.description}
</Text>
<View style={styles.cardMetaContainer}>
<Text style={styles.cardMetaText}>
<Ionicons name="person-outline" size={14} /> {item.currentParticipants}/{item.maxParticipants}
</Text>
<Text style={styles.cardMetaText}>
<Ionicons name="cash-outline" size={14} /> ₹{item.contributionAmount}
</Text>
<Text style={styles.cardMetaText}>
<Ionicons name="time-outline" size={14} /> {item.cycleType}
</Text>
<View style={styles.metaItem}>
<LucideIcon name="person-outline" size={14} color={theme.colors.textSecondary} />
<Text style={[styles.cardMetaText, { color: theme.colors.textSecondary }]}>
{item.currentParticipants}/{item.maxParticipants}
</Text>
</View>
<View style={styles.metaItem}>
<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>
<Text style={styles.createdBy}>Created by {item.createdByName}</Text>
</View>
{/* Manage Button */}
<TouchableOpacity style={styles.manageButton} onPress={() => onManage(item)}>
<Ionicons name="settings-outline" size={24} color={colors.textDark} />
<Text style={styles.manageButtonText}>Manage</Text>
<TouchableOpacity
style={[styles.manageButton, { backgroundColor: theme.colors.surface }]}
onPress={() => onManage(item)}
>
<LucideIcon name="settings-outline" size={24} color={theme.colors.textPrimary} />
<Text style={[styles.manageButtonText, { color: theme.colors.textPrimary }]}>Manage</Text>
</TouchableOpacity>
</View>
);
@@ -85,80 +99,72 @@ export const MyGroupCard: React.FC<Props> = ({ item, onManage }) => {
const styles = StyleSheet.create({
card: {
backgroundColor: "#fff",
borderRadius: 12,
padding: 16,
marginBottom: 12,
flexDirection: "row",
alignItems: "center",
elevation: 2,
shadowColor: "#000",
padding: 14,
marginBottom: 10,
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 4,
marginHorizontal:12
marginHorizontal: 12,
elevation: 2,
},
cardHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 6,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
cardTitle: {
fontSize: responsiveFontSize(1.8),
fontWeight: "bold",
color: "#343a40",
fontFamily: FontsFamily.nunitoExtraBold,
flex: 1,
marginRight: 10,
},
cardSubtitle: {
fontSize: responsiveFontSize(1.4),
color: "#6c757d",
marginBottom: 8,
fontFamily: FontsFamily.nunitoRegular,
marginBottom: 10,
},
cardMetaContainer: {
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
marginBottom: 6,
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
},
manageButtonText: {
fontSize: responsiveFontSize(1.2),
color: colors.textDark,
metaItem: {
flexDirection: 'row',
alignItems: 'center',
marginRight: 16,
marginBottom: 4,
},
cardMetaText: {
fontSize: responsiveFontSize(1.3),
color: "#495057",
fontWeight: "500",
marginRight: 16,
flexDirection: "row",
alignItems: "center",
fontFamily: FontsFamily.nunitoSemiBold,
marginLeft: 4,
},
statusBadge: {
paddingHorizontal: 10,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 5,
elevation: 1,
borderRadius: 4,
},
statusBadgeText: {
color: colors.textDark,
fontSize: responsiveFontSize(1),
fontWeight: "bold",
textTransform: "uppercase",
fontFamily: FontsFamily.nunitoBold,
textTransform: 'uppercase',
},
manageButton: {
width: 100,
borderRadius:10,
flexDirection:"row",
alignItems: "center",
justifyContent: "space-around",
padding: 6,
width: 84,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10,
marginLeft: 10,
elevation:2,
backgroundColor: colors.pastelOrange,
},
createdBy: {
manageButtonText: {
fontSize: responsiveFontSize(1.2),
color: "#868e96",
fontFamily: FontsFamily.nunitoBold,
marginTop: 4,
},
});
+68 -86
View File
@@ -1,11 +1,10 @@
// src/components/RequestCard.tsx
import React, { FC } from 'react';
import { View, Text, Image, TouchableOpacity, StyleSheet, Platform } from 'react-native';
import { Ionicons } from "@react-native-vector-icons/ionicons"; // Assuming Expo for icons
import colors from './colors'; // Your color palette
import { JoinRequest } from '../types'; // Your type definition
import LucideIcon from './common/LucideIcon';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useTheme } from '../hooks/ui/useTheme';
import { JoinRequest } from '../types';
import FontsFamily from '../constents/FontsFamily';
interface RequestCardProps {
item: JoinRequest;
@@ -14,46 +13,57 @@ interface RequestCardProps {
}
export const RequestCard: FC<RequestCardProps> = ({ item, onApprove, onDecline }) => {
const theme = useTheme();
return (
<View style={styles.card}>
{/* User Information Section */}
<View
style={[
styles.card,
{
backgroundColor: theme.colors.card,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.xl,
},
]}
>
<View style={styles.userInfoSection}>
<Image
source={require('../assets/images/placeholder.png')}
style={styles.avatar}
/>
<Image source={require('../assets/images/placeholder.png')} style={styles.avatar} />
<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}>
<Ionicons name="call-outline" size={14} color={colors.textLight} />
<Text style={styles.userMeta}>{item.mobile}</Text>
<LucideIcon name="call-outline" size={14} color={theme.colors.textSecondary} />
<Text style={[styles.userMeta, { color: theme.colors.textSecondary }]}>{item.mobile}</Text>
</View>
</View>
</View>
{/* Group Information Section */}
<View style={styles.groupInfoSection}>
<Text style={styles.requestText}>Wants to join group</Text>
<Text style={styles.groupName} numberOfLines={2}>{item.groupName}</Text>
<View style={[styles.groupInfoSection, { backgroundColor: theme.colors.surface }]}>
<Text style={[styles.requestText, { color: theme.colors.textSecondary }]}>
Wants to join group
</Text>
<Text style={[styles.groupName, { color: theme.colors.textPrimary }]} numberOfLines={2}>
{item.groupName}
</Text>
</View>
{/* Action Buttons Section */}
<View style={styles.actionsSection}>
<TouchableOpacity
style={[styles.actionButton, styles.declineButton]}
<TouchableOpacity
style={[styles.actionButton, { backgroundColor: theme.colors.surface }]}
onPress={() => onDecline(item)}
activeOpacity={0.7}
>
<Ionicons name="close" size={20} color={colors.textDark} />
<Text style={styles.actionButtonText}>Decline</Text>
<LucideIcon name="close" size={20} color={theme.colors.textPrimary} />
<Text style={[styles.actionButtonText, { color: theme.colors.textPrimary }]}>Decline</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.approveButton]}
<TouchableOpacity
style={[styles.actionButton, { backgroundColor: theme.colors.primary }]}
onPress={() => onApprove(item)}
activeOpacity={0.8}
>
<Ionicons name="checkmark" size={20} color={colors.black} />
<Text style={[styles.actionButtonText, { color: colors.black }]}>Approve</Text>
<LucideIcon name="checkmark" size={20} color={theme.colors.onPrimary} />
<Text style={[styles.actionButtonText, { color: theme.colors.onPrimary }]}>Approve</Text>
</TouchableOpacity>
</View>
</View>
@@ -62,110 +72,82 @@ export const RequestCard: FC<RequestCardProps> = ({ item, onApprove, onDecline }
const styles = StyleSheet.create({
card: {
marginHorizontal: 16,
marginVertical: 12,
backgroundColor: colors.white,
borderRadius: 24,
padding: 20,
marginHorizontal: 12,
marginVertical: 8,
padding: 16,
borderWidth: 1,
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.08,
shadowRadius: 12,
shadowOpacity: 0.05,
shadowRadius: 10,
},
android: {
elevation: 8,
shadowColor: '#00000020', // Softer shadow color for Android
elevation: 3,
},
}),
borderWidth: 1,
borderColor: '#f0f0f0',
},
userInfoSection: {
flexDirection: 'row',
alignItems: 'center',
},
avatar: {
width: 60,
height: 60,
borderRadius: 30,
marginRight: 16,
borderWidth: 2,
borderColor: colors.pastelOrange,
width: 52,
height: 52,
borderRadius: 26,
marginRight: 14,
backgroundColor: '#E6E6E6',
},
userInfo: {
flex: 1,
},
userName: {
fontSize: responsiveFontSize(1.9),
fontWeight: 'bold',
color: colors.textDark,
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoExtraBold,
},
userMetaContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 5,
marginTop: 4,
},
userMeta: {
fontSize: responsiveFontSize(1.4),
color: colors.textLight,
marginLeft: 8,
fontFamily: FontsFamily.nunitoRegular,
marginLeft: 6,
},
groupInfoSection: {
marginTop: 18,
paddingVertical: 16,
paddingHorizontal: 16,
backgroundColor: '#F7F8FA',
borderRadius: 16,
marginTop: 14,
padding: 14,
borderRadius: 12,
alignItems: 'center',
},
requestText: {
fontSize: responsiveFontSize(1.4),
color: colors.textLight,
fontSize: responsiveFontSize(1.3),
fontFamily: FontsFamily.nunitoRegular,
},
groupName: {
fontSize: responsiveFontSize(1.7),
fontWeight: '600',
color: colors.textDark,
marginTop: 6,
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoBold,
marginTop: 4,
textAlign: 'center',
},
actionsSection: {
flexDirection: 'row',
marginTop: 20,
gap: 12,
marginTop: 16,
gap: 10,
},
actionButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 16,
borderRadius: 16,
},
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,
},
}),
paddingVertical: 12,
borderRadius: 12,
},
actionButtonText: {
fontSize: responsiveFontSize(1.6),
fontWeight: 'bold',
fontSize: responsiveFontSize(1.5),
fontFamily: FontsFamily.nunitoBold,
marginLeft: 8,
color: colors.textDark,
},
});
+52 -58
View File
@@ -1,42 +1,43 @@
import React, { FC } from "react";
import { View, Text, StyleSheet } from "react-native";
import { Ionicons } from "@react-native-vector-icons/ionicons";
import colors from "./colors";
import LucideIcon from './common/LucideIcon';
import { UserJoinRequest } from "../types";
import { formatDate } from "../utils/HelperFunction";
import { responsiveFontSize } from "react-native-responsive-dimensions";
import { useTheme } from "../hooks/ui/useTheme";
import FontsFamily from "../constents/FontsFamily";
interface UserRequestedProps {
item: UserJoinRequest;
}
const getStatusStyle = (status: UserJoinRequest["status"]) => {
const getStatusStyle = (status: UserJoinRequest["status"], theme: ReturnType<typeof useTheme>) => {
switch (status) {
case "APPROVED":
return {
backgroundColor: "#D1FAE5",
textColor: "#065F46",
backgroundColor: theme.colors.success + "22",
textColor: theme.colors.success,
label: "Approved",
icon: "checkmark-circle-outline",
};
case "PENDING":
return {
backgroundColor: "#FEF3C7",
textColor: "#92400E",
backgroundColor: theme.colors.warning + "22",
textColor: theme.colors.warning,
label: "Pending",
icon: "time-outline",
};
case "REJECT":
return {
backgroundColor: "#FEE2E2",
textColor: "#B91C1C",
backgroundColor: theme.colors.error + "22",
textColor: theme.colors.error,
label: "Rejected",
icon: "close-circle-outline",
};
default:
return {
backgroundColor: "#E5E7EB",
textColor: "#4B5563",
backgroundColor: theme.colors.borderLight,
textColor: theme.colors.textSecondary,
label: "Unknown",
icon: "help-outline",
};
@@ -44,41 +45,40 @@ const getStatusStyle = (status: UserJoinRequest["status"]) => {
};
export const UserRequested: FC<UserRequestedProps> = ({ item }) => {
const { backgroundColor, textColor, label, icon } = getStatusStyle(item.status);
// Optionally, initials avatar (remove/comment if you want):
const initials = item.createdBy?.trim().split(" ").map((w) => w[0])?.join("").toUpperCase();
const theme = useTheme();
const { backgroundColor, textColor, label, icon } = getStatusStyle(item.status, theme);
const initials = item.createdBy?.trim().split(" ").map((w) => w[0])?.join("").toUpperCase() || "?";
return (
<View style={styles.card}>
<View style={styles.topRow}>
<View style={[styles.card, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}>
<View style={[styles.topRow, { borderBottomColor: theme.colors.border }]}>
<View style={styles.avatarContainer}>
<View style={styles.avatarCircle}>
<Text style={styles.avatarText}>{initials}</Text>
<View style={[styles.avatarCircle, { backgroundColor: theme.colors.primary }]}>
<Text style={[styles.avatarText, { color: theme.colors.onPrimary }]}>{initials}</Text>
</View>
</View>
<View style={styles.userInfoSection}>
<Text style={styles.groupName}>{item.groupName}</Text>
<Text style={styles.userMeta}>
<Ionicons name="time-outline" size={13} color={colors.textLight} />{" "}
<Text style={[styles.groupName, { color: theme.colors.textPrimary }]}>{item.groupName}</Text>
<Text style={[styles.userMeta, { color: theme.colors.textSecondary }]}>
<LucideIcon name="time-outline" size={13} color={theme.colors.textMuted} />{" "}
{formatDate(item.requestDate)}
</Text>
</View>
<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>
</View>
</View>
<View style={styles.groupInfoSection}>
<Text style={styles.requestText}>
<Ionicons name="person-outline" size={13} color={colors.textLight} />
<Text style={[styles.requestText, { color: theme.colors.textSecondary }]}>
<LucideIcon name="person-outline" size={13} color={theme.colors.textMuted} />
{" "}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>
{item.reason ? (
<Text style={styles.reasonText} numberOfLines={2}>
<Ionicons name="chatbubbles-outline" size={13} color={colors.textLight} /> {item.reason}
<Text style={[styles.reasonText, { color: theme.colors.textMuted }]} numberOfLines={2}>
<LucideIcon name="chatbubbles-outline" size={13} color={theme.colors.textMuted} /> {item.reason}
</Text>
) : null}
</View>
@@ -88,79 +88,73 @@ export const UserRequested: FC<UserRequestedProps> = ({ item }) => {
const styles = StyleSheet.create({
card: {
backgroundColor: colors.white,
borderRadius: 16,
marginVertical: 14, // More space
marginHorizontal: 10,
elevation: 4,
borderRadius: 14,
marginVertical: 8,
marginHorizontal: 12,
elevation: 2,
borderWidth: 1,
shadowColor: "#000",
shadowOpacity: 0.12,
shadowRadius: 8,
shadowOpacity: 0.08,
shadowRadius: 6,
shadowOffset: { width: 0, height: 2 },
padding: 16,
padding: 14,
},
topRow: {
flexDirection: "row",
alignItems: "flex-start",
borderBottomWidth: 1,
borderBottomColor: "#e5e7eb",
paddingBottom: 10,
marginBottom: 6
paddingBottom: 8,
marginBottom: 6,
},
avatarContainer: {
marginRight: 10,
marginRight: 8,
marginTop: 2,
},
avatarCircle: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.pastelBlue,
justifyContent: "center",
alignItems: "center"
},
avatarText: {
fontWeight: "700",
fontFamily: FontsFamily.nunitoBold,
fontSize: responsiveFontSize(1.6),
color: colors.white,
},
userInfoSection: {
flex: 1,
paddingRight: 8,
paddingRight: 6,
},
userName: {
fontWeight: "bold",
color: colors.textDark,
fontFamily: FontsFamily.nunitoBold,
fontSize: responsiveFontSize(1.5),
},
userMeta: {
fontSize: responsiveFontSize(1.2),
color: colors.textLight,
fontFamily: FontsFamily.nunitoRegular,
marginTop: 3,
marginBottom: -3,
},
groupInfoSection: {
paddingTop: 10,
paddingBottom: 6,
paddingTop: 8,
paddingBottom: 2,
},
requestText: {
color: colors.textDark,
fontSize: responsiveFontSize(1.5),
marginBottom: 2,
lineHeight: 22,
lineHeight: 20,
fontFamily: FontsFamily.nunitoRegular,
},
groupName: {
fontWeight: "600",
color: colors.Settings,
fontFamily: FontsFamily.nunitoSemiBold,
fontSize: responsiveFontSize(1.8),
marginBottom: 2,
},
reasonText: {
fontSize: responsiveFontSize(1.2),
color: colors.textLight,
marginTop: 6,
paddingLeft: 1,
lineHeight: 18,
lineHeight: 16,
fontStyle: "italic",
},
statusBadge: {
@@ -170,15 +164,15 @@ const styles = StyleSheet.create({
paddingHorizontal: 10,
paddingVertical: 5,
marginLeft: 4,
minWidth: 90,
minWidth: 84,
justifyContent: "center",
alignSelf: "flex-start",
marginTop: 2
},
statusText: {
fontSize: responsiveFontSize(1.4),
fontWeight: "700",
fontFamily: FontsFamily.nunitoBold,
marginLeft: 6,
letterSpacing: 0.5
letterSpacing: 0.3
},
});
+66 -35
View File
@@ -1,44 +1,62 @@
import React, { use, useRef } from "react";
import { View, Text, StyleSheet, Animated, TouchableWithoutFeedback } from "react-native";
import LinearGradient from "react-native-linear-gradient";
import { Ionicons } from "@react-native-vector-icons/ionicons";
import colors from "../colors";
import { responsiveFontSize } from "react-native-responsive-dimensions";
import { useTheme } from "../../theme/ThemeProvider";
import FontsFamily from "../../constents/FontsFamily";
import React, { useRef } from 'react';
import { View, Text, StyleSheet, Animated, TouchableWithoutFeedback } from 'react-native';
import LucideIcon from './LucideIcon';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useTheme } from '../../hooks/ui/useTheme';
import FontsFamily from '../../constents/FontsFamily';
type CardProps = {
title: string;
subtitle?: string;
iconName?: string;
onPress?: () => void;
backOne?: string;
backTwo?: string;
};
const AnimatedCard: React.FC<CardProps> = ({
title,
subtitle,
iconName = "ellipse-outline",
iconName = 'ellipse-outline',
onPress,
backOne = colors.primary,
backTwo = colors.pastelPink,
}) => {
const theme = useTheme();
const scale = useRef(new Animated.Value(1)).current;
const{ theme } = useTheme()
const handlePress = () => {
Animated.sequence([
Animated.spring(scale, { toValue: 0.95, useNativeDriver: true }),
]).start(() => onPress && onPress());
const handlePressIn = () => {
Animated.spring(scale, { toValue: 0.95, useNativeDriver: true }).start();
};
const handlePressOut = () => {
Animated.spring(scale, { toValue: 1, useNativeDriver: true }).start();
};
return (
<TouchableWithoutFeedback onPress={handlePress}>
<Animated.View style={[styles.card, { transform: [{ scale }] }]}>
<View style={[styles.gradient, { backgroundColor: theme.colors.reverseBackground }]}>
<Ionicons name={iconName} size={38} color={theme.colors.reverseText} style={styles.icon} />
<Text style={[styles.title, { color: theme.colors.reverseText }]}>{title}</Text>
{subtitle && <Text style={[styles.subtitle, { color: theme.colors.reverseText }]}>{subtitle}</Text>}
<TouchableWithoutFeedback
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
>
<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>
</Animated.View>
</TouchableWithoutFeedback>
@@ -48,20 +66,33 @@ const AnimatedCard: React.FC<CardProps> = ({
const styles = StyleSheet.create({
card: {
flex: 1,
height: 160,
margin: 8,
borderRadius: 20,
overflow: "hidden",
elevation: 5,
height: 150,
margin: 6,
borderWidth: 1,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 4,
},
gradient: {
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
justifyContent: '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;
+1 -1
View File
@@ -62,4 +62,4 @@ const styles = StyleSheet.create({
text: {
textAlign: 'center',
},
});
});
+158 -58
View File
@@ -1,46 +1,76 @@
import React from "react";
import {
StyleSheet,
View,
Text,
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";
import React from 'react';
import { StyleSheet, View, Text, TextInput, TouchableOpacity, TextInputProps } from 'react-native';
import LucideIcon, { LucideIconName } from './LucideIcon';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useTheme } from '../../hooks/ui/useTheme';
// --- Custom Input ---
type CustomInputProps = TextInputProps & {
label?: string;
icon?: IoniconsIconName;
error?:string
icon?: string;
error?: string;
value?: string | number;
};
const CustomInput: React.FC<CustomInputProps> = ({
label,
value,
icon,
style,
error,
...rest
}) => {
const withOpacity = (hex: string, alpha: number) => {
const normalized = hex.replace('#', '');
if (normalized.length !== 6) return hex;
const r = parseInt(normalized.slice(0, 2), 16);
const g = parseInt(normalized.slice(2, 4), 16);
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 (
<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}>
{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
style={[styles.input, icon && { paddingLeft: 36 }, style]}
placeholderTextColor={colors.textLight}
value={value ? String(value) : undefined}
{...(error ? {borderColor:'red', borderWidth:1} : {})}
style={[
styles.input,
{
backgroundColor: theme.colors.background,
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}
/>
</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>
);
};
@@ -49,32 +79,72 @@ const CustomInput: React.FC<CustomInputProps> = ({
type CustomButtonProps = {
title: string;
onPress: () => void;
type?: "primary" | "secondary" | "danger";
icon?: IoniconsIconName;
type?: 'primary' | 'secondary' | 'outline';
icon?: string;
style?: object;
buttonBackground?: string;
textColor?: string;
loading?: boolean;
disabled?: boolean;
};
const CustomButton: React.FC<CustomButtonProps> = ({
title,
onPress,
type = "primary",
type = 'primary',
icon,
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 (
<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}
disabled={loading || disabled}
activeOpacity={0.8}
>
<View style={styles.buttonContent}>
{icon && <Ionicons name={icon} size={20} color={textColor} style={{ marginRight: 8 }} />}
<Text style={[styles.buttonText, { color: textColor }]}>{title}</Text>
{icon && !loading && (
<LucideIcon name={icon} size={20} color={textColor} style={{ marginRight: 8 }} />
)}
<Text style={[styles.buttonText, { color: textColor }]}>
{loading ? 'Processing...' : title}
</Text>
</View>
</TouchableOpacity>
);
@@ -82,42 +152,72 @@ const CustomButton: React.FC<CustomButtonProps> = ({
// --- Styles ---
const styles = StyleSheet.create({
inputContainer: { marginBottom: 12,elevation:5 },
inputLabel: { fontSize: responsiveFontSize(1.5), fontWeight: "500", color: colors.textDark, marginBottom: 6 },
inputContainer: {
marginBottom: 14,
},
inputLabel: {
fontSize: responsiveFontSize(1.6),
fontWeight: '600',
marginBottom: 8,
},
inputWrapper: {
flexDirection: "row",
alignItems: "center",
position: "relative",
flexDirection: 'row',
alignItems: 'center',
position: 'relative',
},
inputIcon: {
position: "absolute",
left: 10,
position: 'absolute',
left: 12,
zIndex: 1,
},
input: {
flex: 1,
borderRadius: 15,
padding: 12,
paddingLeft: 12,
backgroundColor: colors.white,
color: colors.textDark,
fontSize: responsiveFontSize(1.5),
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: responsiveFontSize(1.6),
},
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: {
elevation:5,
paddingVertical: 12,
paddingVertical: 14,
borderRadius: 12,
alignItems: "center",
alignItems: 'center',
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: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
buttonText: {
fontSize: responsiveFontSize(1.6),
fontWeight: "600",
fontSize: responsiveFontSize(1.8),
fontWeight: '700',
},
});
+92 -71
View File
@@ -1,4 +1,4 @@
import React, { useRef, useEffect } from "react";
import React, { useRef, useEffect } from 'react';
import {
View,
Text,
@@ -7,11 +7,16 @@ import {
TouchableOpacity,
Animated,
Dimensions,
} from "react-native";
import colors from "../colors";
import { responsiveFontSize } from "react-native-responsive-dimensions";
KeyboardAvoidingView,
Platform,
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 = {
visible: boolean;
@@ -21,9 +26,9 @@ type CustomModalProps = {
confirmText?: string;
cancelText?: string;
onConfirm?: () => void;
confirmButton?:string
confirmTextColor?:string
elevation?:number
confirmButton?: string;
confirmTextColor?: string;
elevation?: number;
};
const CustomModal: React.FC<CustomModalProps> = ({
@@ -31,14 +36,15 @@ const CustomModal: React.FC<CustomModalProps> = ({
onClose,
title,
children,
confirmText = "Confirm",
cancelText = "Cancel",
confirmText = 'Confirm',
cancelText = 'Cancel',
onConfirm,
confirmTextColor,
elevation,
confirmButton = colors.primary
elevation = 5,
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;
useEffect(() => {
@@ -69,45 +75,65 @@ const CustomModal: React.FC<CustomModalProps> = ({
}),
]).start();
}
}, [visible]);
}, [fadeAnim, slideAnim, visible]);
return (
<Modal transparent visible={visible} animationType="none">
{/* Overlay */}
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
{/* Modal Box */}
<Animated.View
style={[
styles.modalBox,
{ transform: [{ translateY: slideAnim }] },
]}
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardContainer}
>
{/* Header */}
{
title &&
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
<View style={styles.keyboardContainer}>
<Animated.View
style={[
styles.modalBox,
{
backgroundColor: theme.colors.surface,
borderTopLeftRadius: theme.borderRadius.xl,
borderTopRightRadius: theme.borderRadius.xl,
transform: [{ translateY: slideAnim }]
},
]}
>
{title && (
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
)}
<View style={styles.content}>{children}</View>
<Text style={styles.title}>{title}</Text>
}
{/* Body */}
<View style={styles.content}>{children}</View>
<View style={styles.actions}>
<TouchableOpacity
style={[styles.button, { backgroundColor: theme.colors.background, elevation: elevation }]}
onPress={onClose}
>
<Text style={[styles.cancelText, { color: theme.colors.textSecondary }]}>{cancelText}</Text>
</TouchableOpacity>
{/* Footer Actions */}
<View style={styles.actions}>
<TouchableOpacity
style={[styles.button, styles.cancelButton,{elevation:elevation}]}
onPress={onClose}
>
<Text style={styles.cancelText}>{cancelText}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, {backgroundColor:confirmButton ,elevation:elevation}]}
onPress={onConfirm}
>
<Text style={[styles.confirmText,{color:confirmTextColor}]}>{confirmText}</Text>
</TouchableOpacity>
</View>
</Animated.View>
<TouchableOpacity
style={[
styles.button,
{
backgroundColor: confirmButton || theme.colors.primary,
elevation: elevation
}
]}
onPress={onConfirm}
>
<Text
style={[
styles.confirmText,
{ color: confirmTextColor || theme.colors.onPrimary }
]}
>
{confirmText}
</Text>
</TouchableOpacity>
</View>
</Animated.View>
</View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</Animated.View>
</Modal>
);
@@ -118,46 +144,41 @@ export default CustomModal;
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.4)",
justifyContent: "flex-end",
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
},
keyboardContainer: {
flex: 1,
justifyContent: 'flex-end',
},
modalBox: {
backgroundColor: "#fff",
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: 20,
padding: 24,
elevation: 10,
},
title: {
fontSize: responsiveFontSize(1.8),
fontWeight: "700",
color: "#1f2937",
marginBottom: 12,
fontSize: responsiveFontSize(2),
fontFamily: FontsFamily.nunitoBold,
marginBottom: 16,
},
content: {
marginBottom: 20,
marginBottom: 24,
},
actions: {
flexDirection: "row",
justifyContent: "flex-end",
marginTop: 10,
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 12,
},
button: {
paddingVertical: 10,
paddingHorizontal: 18,
borderRadius: 10,
marginLeft: 10,
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 12,
},
cancelButton: {
backgroundColor: "#e5e7eb",
},
cancelText: {
color: "#374151",
fontWeight: "500",
fontFamily: FontsFamily.nunitoSemiBold,
fontSize: responsiveFontSize(1.6),
},
confirmText: {
color: "#fff",
fontWeight: "600",
fontFamily: FontsFamily.nunitoBold,
fontSize: responsiveFontSize(1.6),
},
});
+31 -17
View File
@@ -1,9 +1,10 @@
import React from "react";
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
import { useNavigation } from "@react-navigation/native";
import {Ionicons} from "@react-native-vector-icons/ionicons";
import colors from "../colors";
import LucideIcon from './LucideIcon';
import { responsiveFontSize } from "react-native-responsive-dimensions";
import { useTheme } from "../../hooks/ui/useTheme";
import FontsFamily from "../../constents/FontsFamily";
type HeaderProps = {
title: string;
@@ -19,10 +20,14 @@ const Header: React.FC<HeaderProps> = ({
showBack = true,
onBackPress,
rightAction,
titleColor = '#000',
arrowColor = '#000'
titleColor,
arrowColor
}) => {
const navigation = useNavigation();
const theme = useTheme();
const resolvedTitleColor = titleColor || theme.colors.textPrimary;
const resolvedArrowColor = arrowColor || theme.colors.textPrimary;
const handleBack = () => {
if (onBackPress) {
@@ -35,12 +40,15 @@ const Header: React.FC<HeaderProps> = ({
return (
<View style={styles.header}>
{showBack && (
<TouchableOpacity onPress={handleBack} style={{padding:6}}>
<Ionicons name="arrow-back" size={24} color={arrowColor} />
<TouchableOpacity onPress={handleBack} style={styles.sideAction}>
<LucideIcon name="arrow-back" size={22} color={resolvedArrowColor} />
</TouchableOpacity>
)}
<Text style={[styles.headerTitle, {color:titleColor}]}>{title}</Text>
<View>{rightAction}</View>
)}
{!showBack && <View style={styles.sideAction} />}
<Text numberOfLines={1} style={[styles.headerTitle, {color:resolvedTitleColor}]}>{title}</Text>
<View style={styles.sideAction}>{rightAction}</View>
</View>
);
};
@@ -50,15 +58,21 @@ export default Header;
const styles = StyleSheet.create({
header: {
flexDirection: "row",
// alignItems: "center",
// justifyContent: "space-between",
paddingVertical: 12,
paddingHorizontal:12
alignItems: "center",
justifyContent: "space-between",
paddingVertical: 10,
paddingHorizontal: 12,
},
sideAction: {
width: 36,
height: 36,
alignItems: "center",
justifyContent: "center",
},
headerTitle: {
fontSize: responsiveFontSize(1.8),
fontWeight: "600",
// textTransform: "capitalize",
flex: 1,
textAlign: "center",
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;
};
+5 -2
View File
@@ -24,6 +24,7 @@ import Notification from "../screens/notification/Notification";
import ManagementScreen from "../screens/management/ManagementScreen";
import { navigationRef } from "../utils/navigationService";
import NotificationRedirection from "../screens/NotificationRedirection";
import PaymentModeScreen from "../screens/payment/PaymentModeScreen";
// import screen from "../components/test";
export type RootStackParamList = {
@@ -40,13 +41,14 @@ export type RootStackParamList = {
BecomeOrg: undefined;
YourContacts: 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 };
Invite: { groupId: string | number; code: string | number };
ManagementTabs: { groupId: string | number; }
TransactionList: undefined
RandomSelectUserScreen: { groupId: string | number, cycleNumber: number };
Notification: undefined;
PaymentMode: { groupId: string | number; amount: number; payableData?: any };
NotificationRedirection: { data: any };
Management:
| { actionTab: "My Groups" }
@@ -119,8 +121,9 @@ export default function AppNavigator() {
<Stack.Screen name="Notification" component={Notification} options={{ headerShown: false }} />
<Stack.Screen name="Management" component={ManagementScreen} options={{ headerShown: false }} />
<Stack.Screen name="NotificationRedirection" component={NotificationRedirection} options={{ headerShown: false }} />
<Stack.Screen name="PaymentMode" component={PaymentModeScreen} options={{ headerShown: false }} />
</Stack.Navigator>
</NavigationContainer>
);
}
;
;
+61 -138
View File
@@ -1,50 +1,22 @@
import React, { useState, useEffect } from "react";
import React from "react";
import {
BottomTabNavigationProp,
createBottomTabNavigator,
} from "@react-navigation/bottom-tabs";
import {
View,
Text,
StyleSheet,
Platform,
AccessibilityInfo,
} from "react-native";
import { Ionicons, IoniconsIconName } from "@react-native-vector-icons/ionicons";
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 { Home, User, Settings, Users } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { responsiveFontSize } from "react-native-responsive-dimensions";
const TAB_CONFIG = {
activeColor: "#000",
inactiveColor: "#8e8e93",
backgroundColor: "#fff",
height: 70,
iconSize: 28,
};
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",
};
import HomeScreen from "../screens/home/Home";
import Profile from "../screens/profile/Profile";
import GroupScreen from "../screens/GroupScreen";
import ManagementScreen from "../screens/management/ManagementScreen";
import { useTheme } from "../hooks/ui/useTheme";
import FontsFamily from "../constents/FontsFamily";
export type RootTabParamList = {
Home: undefined;
@@ -59,87 +31,72 @@ export type TabNavigationProp = BottomTabNavigationProp<RootTabParamList>;
const Tab = createBottomTabNavigator<RootTabParamList>();
const BottomTabs: React.FC = () => {
const { theme } = useTheme();
const theme = useTheme();
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 (
<Tab.Navigator
screenOptions={({ route }) => ({
headerShown: false,
tabBarStyle: [
[styles.tabBar,{ backgroundColor: theme.colors.reverseBackground }],
{
bottom: insets.bottom + 10
},
],
// Make each tab item occupy the full bar height and center its content
tabBarShowLabel: true,
tabBarStyle: {
position: "absolute",
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,
},
tabBarItemStyle: {
height: "100%",
height: 64,
justifyContent: "center",
alignItems: "center",
padding: 0,
margin: 0,
paddingVertical: 6,
},
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 }) =>
!focused ? (
<Text style={[styles.label, { color }]}>{route.name}</Text>
) : null,
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];
if (route.name === "Home") IconComponent = Home;
else if (route.name === "Profile") IconComponent = User;
else if (route.name === "Management") IconComponent = Settings;
else if (route.name === "Group") IconComponent = Users;
return (
<View
style={[
styles.iconContainer,
focused && [
styles.activeIconContainer,
{ backgroundColor: activeBackgroundColor },
],
]}
>
<Ionicons
name={iconName}
size={TAB_CONFIG.iconSize}
color={focused ? activeIconColor : TAB_CONFIG.inactiveColor}
<View style={[
styles.iconWrapper,
focused && { backgroundColor: theme.colors.primarySoft }
]}>
<IconComponent
size={iconSize}
color={iconColor}
strokeWidth={focused ? 2.5 : 2}
/>
</View>
);
},
})}
>
<Tab.Screen name="Home" component={Home} />
<Tab.Screen name="Home" component={HomeScreen} />
<Tab.Screen name="Profile" component={Profile} />
<Tab.Screen name="Group" component={GroupScreen} />
<Tab.Screen name="Management" component={ManagementScreen} />
@@ -148,47 +105,13 @@ const BottomTabs: React.FC = () => {
};
const styles = StyleSheet.create({
screen: {
flex: 1,
iconWrapper: {
width: 40,
height: 28,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#f5f7fa",
},
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,
borderRadius: 10,
marginBottom: 2,
},
});
View File
+91 -100
View File
@@ -1,15 +1,16 @@
import React, { useEffect, useRef } from "react";
import React, { useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
Animated,
Easing,
} from "react-native";
import { Ionicons } from "@react-native-vector-icons/ionicons";
import colors from "../components/colors";
import CustomModal from "../components/common/CustomModal";
import { responsiveFontSize } from "react-native-responsive-dimensions";
} from 'react-native';
import LucideIcon from '../components/common/LucideIcon';
import CustomModal from '../components/common/CustomModal';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useTheme } from '../hooks/ui/useTheme';
import FontsFamily from '../constents/FontsFamily';
interface ContributionProps {
data: any;
@@ -24,137 +25,127 @@ const ContributionDetails: React.FC<ContributionProps> = ({
onClose,
onConfirm,
}) => {
const theme = useTheme();
// Animations
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(50)).current;
const scaleAnim = useRef(new Animated.Value(0.9)).current;
const slideAnim = useRef(new Animated.Value(20)).current;
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 600,
duration: 400,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 600,
easing: Easing.out(Easing.exp),
useNativeDriver: true,
}),
Animated.spring(scaleAnim, {
toValue: 1,
friction: 6,
duration: 400,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
]).start();
} else {
fadeAnim.setValue(0);
slideAnim.setValue(50);
scaleAnim.setValue(0.9);
slideAnim.setValue(20);
}
}, [visible]);
return (
<CustomModal visible={visible} onClose={onClose} onConfirm={onConfirm} confirmButton={colors.pastelOrange} confirmTextColor={colors.black} elevation={5}>
{/* Header */}
<Animated.View
style={[
styles.header,
{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] },
]}
>
<Ionicons name="wallet-outline" size={32} color={colors.black} />
<Text style={styles.headerTitle}>Contribution Summary</Text>
</Animated.View>
<CustomModal
visible={visible}
onClose={onClose}
onConfirm={onConfirm}
confirmText="Contribute Now"
title="Contribution Summary"
>
<Animated.View style={{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] }}>
{/* Info Card */}
<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 */}
<Animated.View
style={[
styles.card,
{ transform: [{ scale: scaleAnim }], opacity: fadeAnim },
]}
>
<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>}
<View style={styles.infoGrid}>
<InfoItem label="Base Amount" value={`₹${data?.baseAmount}`} />
<InfoItem label="Due Date" value={data?.dueDate} />
<InfoItem label="Cycle End" value={data?.cycleEnd} />
<InfoItem label="Late Fee" value={`₹${data?.effectiveLateFee || 0}`} />
</View>
</View>
<Text style={styles.label}>Due Date</Text>
<Text style={styles.value}>{data?.dueDate}</Text>
<Text style={styles.label}>Cycle End</Text>
<Text style={styles.value}>{data?.cycleEnd}</Text>
</Animated.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>
{/* Status Card */}
<View style={[styles.miniCard, { backgroundColor: theme.colors.background, borderColor: theme.colors.border }]}>
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Cycle {data?.cycleNumber}</Text>
{data?.alreadyPaid && (
<Text style={[styles.statusText, { color: theme.colors.primary }]}>Payment Already Completed</Text>
)}
</View>
</Animated.View>
</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;
const styles = StyleSheet.create({
header: {
flexDirection: "row",
alignItems: "center",
marginBottom: 24,
},
headerTitle: {
fontSize: responsiveFontSize(2.2),
fontWeight: "700",
marginLeft: 10,
},
card: {
backgroundColor: "#fff",
padding: 16,
padding: 20,
borderRadius: 16,
marginBottom: 16,
shadowColor: "#000",
shadowOpacity: 0.08,
shadowOffset: { width: 0, height: 3 },
shadowRadius: 6,
elevation: 3,
borderWidth: 1,
marginBottom: 12,
},
headerRow: {
flexDirection: 'row',
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: {
fontSize: responsiveFontSize(1.3),
color: "#6b7280",
marginTop: 6,
fontSize: responsiveFontSize(1.2),
fontFamily: FontsFamily.nunitoMedium,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
value: {
fontSize: responsiveFontSize(1.5),
fontWeight: "500",
color: "#374151",
marginBottom: 6,
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoBold,
marginTop: 2,
},
valueHighlight: {
fontSize: responsiveFontSize(1.8),
fontWeight: "700",
color: colors.black,
marginBottom: 10,
statusText: {
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoBold,
},
});
+344 -243
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState } from 'react';
import {
View,
Text,
@@ -7,37 +7,64 @@ import {
KeyboardAvoidingView,
Platform,
TouchableOpacity,
Alert,
Image,
ToastAndroid,
} from "react-native";
import { useNavigation } from "@react-navigation/native";
import { CustomInput } from "../components/common/Custom";
import CustomButton from "../components/CustomButton";
import colors from "../components/colors";
import { launchImageLibrary } from "react-native-image-picker";
import Ionicons from "@react-native-vector-icons/ionicons";
import { UPLOAD_GROUP_IMAGE } from "../utils/ApiUrls";
import { responsiveFontSize } from "react-native-responsive-dimensions";
} from 'react-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 FontsFamily from '../constents/FontsFamily';
import AppView from '../components/AppView';
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() {
const navigation = useNavigation<any>();
const [form, setForm] = useState({
groupName: "",
description: "",
visibility: "PRIVATE",
withdrawalMethod: "RANDOM",
minParticipants: "",
maxParticipants: "",
contributionAmount: "",
cycleType: "MONTHLY",
customCycleDays: "",
lateFeeMode: "DAILY",
lateFeeValue: "",
const theme = useTheme();
const [form, setForm] = useState<GroupCreationFormState>({
groupName: '',
description: '',
visibility: 'PUBLIC',
withdrawalMethod: 'SEQUENTIAL',
minParticipants: '',
maxParticipants: '',
contributionAmount: '',
cycleType: 'MONTHLY',
customCycleDays: '',
lateFeeMode: 'DAILY',
lateFeeValue: '',
entryFee: '0',
});
const [errors, setErrors] = useState<any>({});
const [selectedImage, setSelectedImage] = useState<any>();
const handleChange = (key: string, value: string) => {
setForm({ ...form, [key]: value });
@@ -46,277 +73,351 @@ export default function GroupCreationForm() {
}
};
// ---- Custom logic for Payment Cycle ----
const cycleTypes = [
"CUSTOM",
"DAILY",
"WEEKLY",
"MONTHLY",
"QUARTERLY",
"HALF_YEARLY",
"YEARLY",
];
const distributionTypes = ["SEQUENTIAL", "RANDOM"];
const lateFeeModes = ["DAILY", "MONTHLY"];
const distributionTypes = ['SEQUENTIAL', 'RANDOM'];
const cycleTypes = ['CUSTOM', 'DAILY', 'WEEKLY', 'MONTHLY', 'QUARTERLY', 'HALF_YEARLY', 'YEARLY'];
const lateFeeModes = ['DAILY', 'MONTHLY'];
// The core: handle payment cycle options
const handleCycleChange = (val: string) => {
let newForm = { ...form, cycleType: val };
if (val === "DAILY") {
newForm = { ...newForm, cycleType: "CUSTOM", customCycleDays: "1" };
} else if (val === "WEEKLY") {
newForm = { ...newForm, cycleType: "CUSTOM", customCycleDays: "7" };
} else if (
val !== "CUSTOM"
) {
newForm = { ...newForm, customCycleDays: "" }; // Clear custom days
if (val === 'DAILY') {
newForm = { ...newForm, cycleType: 'CUSTOM', customCycleDays: '1' };
} else if (val === 'WEEKLY') {
newForm = { ...newForm, cycleType: 'CUSTOM', customCycleDays: '7' };
} else if (val !== 'CUSTOM') {
newForm = { ...newForm, customCycleDays: '' };
}
setForm(newForm);
if (errors["customCycleDays"]) {
if (errors.customCycleDays) {
setErrors({ ...errors, customCycleDays: null });
}
};
const validateForm = () => {
const newErrors: any = {};
if (!form.groupName.trim()) newErrors.groupName = "Group name is required.";
if (!form.description.trim()) newErrors.description = "Description is required.";
if (!form.minParticipants || Number(form.minParticipants) <= 0) newErrors.minParticipants = "Min participants must be at least 1.";
if (!form.maxParticipants || Number(form.maxParticipants) < Number(form.minParticipants)) newErrors.maxParticipants = "Max must be greater than or equal to min.";
if (!form.contributionAmount || Number(form.contributionAmount) <= 0) newErrors.contributionAmount = "Amount must be greater than 0.";
if (form.cycleType === "CUSTOM" && (!form.customCycleDays || Number(form.customCycleDays) <= 0)) newErrors.customCycleDays = "Custom days must be greater than 0.";
if (!form.lateFeeMode && (!form.lateFeeValue || Number(form.lateFeeValue) <= 0)) newErrors.lateFeeValue = "Late fee value is required.";
const entryFeeValue = form.entryFee.trim() === '' ? 0 : Number(form.entryFee);
if (!form.groupName.trim()) newErrors.groupName = 'Group name is required.';
if (!form.description.trim()) newErrors.description = 'Description is required.';
if (!form.minParticipants || Number(form.minParticipants) <= 0)
newErrors.minParticipants = 'Min participants must be at least 1.';
if (!form.maxParticipants || Number(form.maxParticipants) < Number(form.minParticipants))
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);
return Object.keys(newErrors).length === 0;
};
const handleReview = () => {
if (validateForm()) {
const payload = {
...form,
contributionAmount: Number(form.contributionAmount),
customCycleDays: Number(form.customCycleDays) || 0,
lateFeeValue: Number(form.lateFeeValue) || 0,
maxParticipants: Number(form.maxParticipants),
const entryFeeValue = form.entryFee.trim() === '' ? 0 : Number(form.entryFee);
const payload: GroupCreationPayload = {
groupName: form.groupName.trim(),
description: form.description.trim(),
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 {
ToastAndroid.show('Please fill out all required fields correctly.', ToastAndroid.SHORT);
ToastAndroid.show('Please fill required fields correctly.', ToastAndroid.SHORT);
}
};
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: "#f5f6fa", paddingTop: Platform.OS === "android" ? 40 : 0 }}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
<View style={styles.titleContainer}>
<Text style={styles.title}>Create New Group</Text>
</View>
<CustomInput
label="Group Name"
placeholder="e.g., 'Family Savings'"
value={form.groupName}
onChangeText={(val) => handleChange("groupName", val)}
icon="people"
error={errors.groupName}
/>
<CustomInput
label="Description"
placeholder="e.g., 'Monthly fund for family needs'"
value={form.description}
onChangeText={(val) => handleChange("description", val)}
multiline
icon="document-text"
error={errors.description}
/>
<Section title="Group Settings">
<SettingRow label="Visibility">
<ToggleButtons
options={["PRIVATE", "PUBLIC"]}
selectedValue={form.visibility}
onSelect={(val) => handleChange("visibility", val)}
/>
</SettingRow>
<SettingRow label="Fund Distribution">
<ToggleButtons
options={distributionTypes}
selectedValue={form.withdrawalMethod}
onSelect={(val) => handleChange("withdrawalMethod", val)}
/>
</SettingRow>
</Section>
<Section title="Participant Rules">
<View style={styles.participantsRow}>
<View style={styles.participantInput}>
<CustomInput
label="Min"
placeholder="e.g., 5"
value={form.minParticipants}
onChangeText={(val) => handleChange("minParticipants", val)}
keyboardType="numeric"
icon="person-add"
error={errors.minParticipants}
/>
</View>
<View style={styles.participantInput}>
<CustomInput
label="Max"
placeholder="e.g., 10"
value={form.maxParticipants}
onChangeText={(val) => handleChange("maxParticipants", val)}
keyboardType="numeric"
icon="people-circle"
error={errors.maxParticipants}
/>
</View>
<AppView keyboardSafe={false}>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<View style={styles.header}>
<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>
</Section>
<Section title="Financial Details">
<CustomInput
label="Contribution Amount (INR)"
placeholder="e.g., 5000"
value={form.contributionAmount}
onChangeText={(val) => handleChange("contributionAmount", val)}
keyboardType="numeric"
icon="cash"
error={errors.contributionAmount}
/>
<SettingRow label="Payment Cycle">
<ToggleButtons
options={cycleTypes}
selectedValue={
form.cycleType === "CUSTOM" && form.customCycleDays === "1"
? "DAILY"
: form.cycleType === "CUSTOM" && form.customCycleDays === "7"
? "WEEKLY"
: form.cycleType
}
onSelect={handleCycleChange}
isWrap={true}
<View style={[styles.section, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<CustomInput
label="Circle Name"
placeholder="e.g., 'Team Growth'"
value={form.groupName}
onChangeText={(val) => handleChange('groupName', val)}
icon="people-outline"
error={errors.groupName}
/>
</SettingRow>
{form.cycleType === "CUSTOM" &&
form.customCycleDays !== "1" &&
form.customCycleDays !== "7" && (
<CustomInput
label="Custom Cycle (Days)"
placeholder="Enter number of days"
value={form.customCycleDays}
onChangeText={(val) => handleChange("customCycleDays", val)}
keyboardType="numeric"
icon="calendar"
error={errors.customCycleDays}
<CustomInput
label="Description"
placeholder="What is this circle for?"
value={form.description}
onChangeText={(val) => handleChange('description', val)}
multiline
style={styles.textArea}
icon="document-text-outline"
error={errors.description}
/>
</View>
<Section title="Settings">
<SettingRow label="Visibility">
<ToggleButtons
options={['PRIVATE', 'PUBLIC']}
selectedValue={form.visibility}
onSelect={(val) => handleChange('visibility', val)}
/>
)}
</Section>
<Section title="Late Fee Policy">
<SettingRow label="Fee Mode">
<ToggleButtons
options={lateFeeModes}
selectedValue={form.lateFeeMode}
onSelect={(val) => handleChange("lateFeeMode", val)}
</SettingRow>
<SettingRow label="Fund Distribution">
<ToggleButtons
options={distributionTypes}
selectedValue={form.withdrawalMethod}
onSelect={(val) => handleChange('withdrawalMethod', val)}
/>
</SettingRow>
</Section>
<Section title="Participant Rules">
<View style={styles.row}>
<View style={styles.flex1}>
<CustomInput
label="Min"
placeholder="e.g., 5"
value={form.minParticipants}
onChangeText={(val) => handleChange('minParticipants', val)}
keyboardType="numeric"
icon="person-outline"
error={errors.minParticipants}
/>
</View>
<View style={styles.flex1}>
<CustomInput
label="Max"
placeholder="e.g., 20"
value={form.maxParticipants}
onChangeText={(val) => handleChange('maxParticipants', val)}
keyboardType="numeric"
icon="people-outline"
error={errors.maxParticipants}
/>
</View>
</View>
</Section>
<Section title="Financial Details">
<CustomInput
label="Contribution Amount (₹)"
placeholder="e.g., 5000.00"
value={form.contributionAmount}
onChangeText={(val) => handleChange('contributionAmount', val)}
keyboardType="numeric"
icon="cash-outline"
error={errors.contributionAmount}
/>
</SettingRow>
<CustomInput
label="Late Fee Value (INR)"
placeholder="e.g., 50"
value={form.lateFeeValue}
onChangeText={(val) => handleChange("lateFeeValue", val)}
keyboardType="numeric"
icon="alert-circle"
error={errors.lateFeeValue}
<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">
<ToggleButtons
options={cycleTypes}
selectedValue={
form.cycleType === 'CUSTOM' && form.customCycleDays === '1'
? 'DAILY'
: form.cycleType === 'CUSTOM' && form.customCycleDays === '7'
? 'WEEKLY'
: form.cycleType
}
onSelect={handleCycleChange}
isWrap={true}
/>
</SettingRow>
{form.cycleType === 'CUSTOM' &&
form.customCycleDays !== '1' &&
form.customCycleDays !== '7' && (
<CustomInput
label="Custom Days"
placeholder="Days between payments"
value={form.customCycleDays}
onChangeText={(val) => handleChange('customCycleDays', val)}
keyboardType="numeric"
icon="calendar-outline"
error={errors.customCycleDays}
/>
)}
</Section>
<Section title="Late Fee Policy">
<SettingRow label="Fee Mode">
<ToggleButtons
options={lateFeeModes}
selectedValue={form.lateFeeMode}
onSelect={(val) => handleChange('lateFeeMode', val)}
/>
</SettingRow>
<CustomInput
label="Fee Amount (₹)"
placeholder="e.g., 50"
value={form.lateFeeValue}
onChangeText={(val) => handleChange('lateFeeValue', val)}
keyboardType="numeric"
icon="alert-circle-outline"
error={errors.lateFeeValue}
/>
</Section>
<CustomButton
title="Review Details"
onPress={handleReview}
style={styles.submitBtn}
/>
</Section>
<CustomButton title="Review Group" onPress={handleReview} style={{ marginTop: 20 }} />
</ScrollView>
</KeyboardAvoidingView>
</ScrollView>
</KeyboardAvoidingView>
</AppView>
);
}
// --- Helper Components for a cleaner layout ---
const Section: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
{children}
</View>
);
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => {
const theme = useTheme();
return (
<View style={[styles.section, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>{title}</Text>
{children}
</View>
);
};
const SettingRow: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
<View style={styles.settingRow}>
<Text style={styles.label}>{label}</Text>
{children}
</View>
);
const SettingRow = ({ label, children }: { label: string; children: React.ReactNode }) => {
const theme = useTheme();
return (
<View style={styles.settingRow}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>{label}</Text>
{children}
</View>
);
};
const ToggleButtons: React.FC<{
const ToggleButtons = ({
options,
selectedValue,
onSelect,
isWrap = false,
}: {
options: string[];
selectedValue: string;
onSelect: (value: string) => void;
isWrap?: boolean;
}> = ({ options, selectedValue, onSelect, isWrap = false }) => (
<View style={[styles.toggleContainer, isWrap && { flexWrap: "wrap" }]}>
{options.map((option) => (
<TouchableOpacity
key={option}
onPress={() => onSelect(option)}
style={[
styles.toggleButton,
selectedValue === option && styles.selectedButton,
isWrap && { flex: 0, paddingHorizontal: 15 },
]}
>
<Text style={[styles.toggleButtonText, selectedValue === option && styles.selectedButtonText]}>
{option.charAt(0) + option.slice(1).toLowerCase()}
</Text>
</TouchableOpacity>
))}
</View>
);
}) => {
const theme = useTheme();
return (
<View style={[styles.toggleContainer, isWrap && { flexWrap: 'wrap' }]}>
{options.map((option) => (
<TouchableOpacity
key={option}
onPress={() => onSelect(option)}
activeOpacity={0.7}
style={[
styles.toggleButton,
{ borderColor: theme.colors.border },
selectedValue === option && { backgroundColor: theme.colors.primary, borderColor: theme.colors.primary },
isWrap && { flex: 0, paddingHorizontal: 16 },
]}
>
<Text
style={[
styles.toggleText,
{ color: theme.colors.textPrimary },
selectedValue === option && { color: theme.colors.onPrimary, fontFamily: FontsFamily.nunitoBold },
]}
>
{option}
</Text>
</TouchableOpacity>
))}
</View>
);
};
const styles = StyleSheet.create({
container: { flexGrow: 1, padding: 16, backgroundColor: "#f5f6fa" },
titleContainer: { marginBottom: 20, paddingHorizontal: 4 },
title: { fontSize: responsiveFontSize(2.8), fontWeight: "bold", color: "#1F2937" },
scrollContent: {
paddingBottom: 40,
},
header: {
marginBottom: 24,
},
title: {
fontSize: responsiveFontSize(2.8),
fontFamily: FontsFamily.nunitoBold,
},
subtitle: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 4,
},
section: {
backgroundColor: "#fff",
borderRadius: 12,
borderRadius: 16,
borderWidth: 1,
padding: 16,
marginBottom: 16,
elevation: 1,
shadowColor: "#4A5568",
shadowOpacity: 0.06,
shadowRadius: 10,
},
sectionTitle: {
fontSize: responsiveFontSize(1.8),
fontWeight: "600",
color: "#374151",
fontFamily: FontsFamily.nunitoBold,
marginBottom: 16,
borderBottomWidth: 1,
borderBottomColor: "#F3F4F6",
paddingBottom: 10,
},
settingRow: {
marginBottom: 16,
},
label: { fontSize: responsiveFontSize(1.4), fontWeight: "600", marginBottom: 8, color: "#4B5563" },
toggleContainer: { flexDirection: "row", gap: 10 },
label: {
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoSemiBold,
marginBottom: 8,
},
toggleContainer: {
flexDirection: 'row',
gap: 8,
},
toggleButton: {
flex: 1,
paddingVertical: 12,
paddingVertical: 10,
borderWidth: 1,
borderColor: "#D1D5DB",
borderRadius: 8,
alignItems: "center",
backgroundColor: "#F9FAFB",
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
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 },
});
+241 -139
View File
@@ -1,20 +1,37 @@
import React, { useState } from "react";
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Modal, ActivityIndicator } from "react-native";
import { Ionicons } from "@react-native-vector-icons/ionicons";
import AppView from "../components/AppView";
import { apiCall } from "../utils/ApiHelper";
import { GROUP_CREATION_URL } from "../utils/ApiUrls";
import colors from "../components/colors";
import { responsiveFontSize } from "react-native-responsive-dimensions";
import React, { useState } from 'react';
import { View, Text, StyleSheet, ScrollView, Modal } from 'react-native';
import LucideIcon from '../components/common/LucideIcon';
import AppView from '../components/AppView';
import { GROUP_CREATION_URL } from '../utils/ApiUrls';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useTheme } from '../hooks/ui/useTheme';
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 ---
const getCycleLabel = (cycleType: string, customCycleDays: string | number) => {
if (cycleType === "CUSTOM") {
if (String(customCycleDays) === "1") return "Daily";
if (String(customCycleDays) === "7") return "Weekly";
if (cycleType === 'CUSTOM') {
if (String(customCycleDays) === '1') return 'Daily';
if (String(customCycleDays) === '7') return 'Weekly';
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 ---
@@ -31,34 +48,37 @@ const ThemedDialog = ({
onClose: () => void;
onNavigate: () => void;
}) => {
const theme = useTheme();
if (!visible) return null;
const config = isSuccess
? {
icon: "checkmark-circle" as const,
color: "#2ecc71",
title: "Success!",
buttonText: "Go Home",
action: onNavigate,
}
icon: 'checkmark-circle' as const,
color: theme.colors.primary,
title: 'Request Submitted',
buttonText: 'Back to Home',
action: onNavigate,
}
: {
icon: "close-circle" as const,
color: "#e74c3c",
title: "Oops!",
buttonText: "Try Again",
action: onClose,
};
icon: 'close-circle' as const,
color: theme.colors.error,
title: 'Submission Failed',
buttonText: 'Review and Retry',
action: onClose,
};
return (
<Modal animationType="fade" transparent={true} visible={visible}>
<View style={styles.modalBackdrop}>
<View style={styles.dialogBox}>
<Ionicons name={config.icon} size={60} color={config.color} />
<Text style={styles.dialogTitle}>{config.title}</Text>
<Text style={styles.dialogMessage}>{message}</Text>
<TouchableOpacity style={styles.dialogButton} onPress={config.action}>
<Text style={styles.dialogButtonText}>{config.buttonText}</Text>
</TouchableOpacity>
<View style={[styles.dialogBox, { backgroundColor: theme.colors.surface, borderRadius: theme.borderRadius.xl }]}>
<LucideIcon name={config.icon} size={64} color={config.color} />
<Text style={[styles.dialogTitle, { color: theme.colors.textPrimary }]}>{config.title}</Text>
<Text style={[styles.dialogMessage, { color: theme.colors.textSecondary }]}>{message}</Text>
<CustomButton
title={config.buttonText}
onPress={config.action}
style={styles.dialogButton}
/>
</View>
</View>
</Modal>
@@ -66,116 +86,127 @@ const ThemedDialog = ({
};
export default function GroupReview({ route, navigation }: any) {
const { form } = route.params;
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const { form } = route.params as { form: GroupCreationPayload };
const theme = useTheme();
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 = () => {
setModalVisible(false);
navigation.reset({ index: 0, routes: [{ name: "Home" }] });
navigation.reset({ index: 0, routes: [{ name: 'Home' }] });
};
const handleSubmit = async () => {
setIsLoading(true);
try {
const res = (await apiCall(GROUP_CREATION_URL, "POST", form)) as {
statusCode?: number;
message?: string;
};
if (res?.statusCode === 201) {
const handleSubmit = () => {
const payload: GroupCreationPayload = {
groupName: String(form.groupName || '').trim(),
description: String(form.description || '').trim(),
minParticipants: Number(form.minParticipants) || 0,
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,
};
createGroup(payload, {
onSuccess: (res) => {
setIsSuccess(true);
setFeedbackMessage(res.message || "Your group has been created successfully.");
} 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);
}
setFeedbackMessage(res.message || 'Your circle request was submitted for admin approval.');
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 }> = ({
icon,
title,
children,
}) => (
const TimelineSection = ({ icon, title, children }: { icon: any; title: string; children: React.ReactNode }) => (
<View style={styles.section}>
<View style={styles.timeline}>
<View style={styles.timelineIcon}>
<Ionicons name={icon} size={22} color="#fff" />
<View style={[styles.timelineIcon, { backgroundColor: theme.colors.primary }]}>
<LucideIcon name={icon} size={20} color={theme.colors.onPrimary} />
</View>
<View style={styles.timelineLine} />
<View style={[styles.timelineLine, { backgroundColor: theme.colors.border }]} />
</View>
<View style={styles.content}>
<Text style={styles.sectionTitle}>{title}</Text>
<View style={styles.card}>{children}</View>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>{title}</Text>
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>{children}</View>
</View>
</View>
);
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 (
<View style={styles.row}>
<Text style={styles.label}>{label}</Text>
<Text style={styles.value}>{value}</Text>
<View style={[styles.row, { borderBottomColor: theme.colors.background }]}>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>{label}</Text>
<Text style={[styles.value, { color: theme.colors.textPrimary }]}>{value}</Text>
</View>
);
};
return (
<AppView>
<ScrollView contentContainerStyle={styles.container}>
<AppView dismissKeyboardOnTap={false} paddingHorizontal={0}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={styles.header}>
<Text style={styles.title}>{form.groupName || "Group Review"}</Text>
<Text style={styles.subtitle}>Confirm the details to launch your group.</Text>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{form.groupName || 'Circle Review'}</Text>
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
Review all details before submitting for admin approval.
</Text>
</View>
<TimelineSection icon="information-circle-outline" title="Basic Information">
{renderRow("Name", form.groupName)}
{renderRow('Name', form.groupName)}
{form.description ? (
<View style={styles.descriptionContainer}>
<Text style={styles.label}>Description</Text>
<Text style={styles.descriptionText}>{form.description}</Text>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Description</Text>
<Text style={[styles.descriptionText, { color: theme.colors.textPrimary }]}>{form.description}</Text>
</View>
) : null}
</TimelineSection>
<TimelineSection icon="shield-checkmark-outline" title="Rules & Privacy">
{renderRow("Visibility", form.visibility)}
{renderRow("Distribution", form.withdrawalMethod)}
{renderRow("Min Members", form.minParticipants)}
{renderRow("Max Members", form.maxParticipants)}
<TimelineSection icon="shield-checkmark-outline" title="Governance">
{renderRow('Visibility', form.visibility)}
{renderRow('Distribution', form.withdrawalMethod)}
{renderRow('Min Members', form.minParticipants)}
{renderRow('Max Members', form.maxParticipants)}
</TimelineSection>
<TimelineSection icon="wallet-outline" title="Financial Details">
{renderRow("Contribution", `₹${form.contributionAmount}`)}
{renderRow("Cycle", getCycleLabel(form.cycleType, form.customCycleDays))}
{/* Show days only if neither Daily nor Weekly */}
{form.cycleType === "CUSTOM" && !["1", "7"].includes(String(form.customCycleDays))
&& renderRow("Cycle Days", `${form.customCycleDays} days`)}
<TimelineSection icon="wallet-outline" title="Financials">
{renderRow('Contribution', `₹${form.contributionAmount}`)}
{renderRow('Entry Fee', `₹${form.entryFee}`)}
{renderRow('Cycle', getCycleLabel(form.cycleType, form.customCycleDays))}
{form.cycleType === 'CUSTOM' && !['1', '7'].includes(String(form.customCycleDays)) &&
renderRow('Cycle Days', `${form.customCycleDays} days`)}
</TimelineSection>
<TimelineSection icon="alert-circle-outline" title="Late Fee Policy">
{renderRow("Fee Mode", form.lateFeeMode)}
{renderRow("Fee Value", `₹${form.lateFeeValue}`)}
<TimelineSection icon="alert-circle-outline" title="Policies">
{renderRow('Fee Mode', form.lateFeeMode)}
{renderRow('Fee Value', `₹${form.lateFeeValue}`)}
</TimelineSection>
<TouchableOpacity onPress={handleSubmit} style={styles.submitBtn} disabled={isLoading}>
{isLoading ? (
<ActivityIndicator color="#333" />
) : (
<>
<Ionicons name="rocket-outline" size={22} color="#333" />
<Text style={styles.btnText}>Create Group</Text>
</>
)}
</TouchableOpacity>
<CustomButton
title="Submit"
onPress={handleSubmit}
loading={isLoading}
style={styles.submitBtn}
/>
</ScrollView>
<ThemedDialog
@@ -190,50 +221,121 @@ export default function GroupReview({ route, navigation }: any) {
}
const styles = StyleSheet.create({
container: { padding: 20, backgroundColor: "#f5f6fa" },
header: { marginBottom: 30 },
title: { fontSize: responsiveFontSize(2.8), fontWeight: "bold", color: "#333" },
subtitle: { fontSize: responsiveFontSize(1.6), color: "#666", marginTop: 4 },
section: { flexDirection: "row", marginBottom: 10 },
timeline: { alignItems: "center", width: 40 },
scrollView: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
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: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.pastelGreen,
justifyContent: "center",
alignItems: "center",
elevation: 3,
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
zIndex: 1,
},
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: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 12,
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: "#f5f5f5",
},
label: { fontSize: responsiveFontSize(1.5), color: "#666", fontWeight: "500" },
value: { fontSize: responsiveFontSize(1.5), color: "#333", fontWeight: "600", textTransform: "capitalize" },
descriptionContainer: { paddingVertical: 10 },
descriptionText: { fontSize: responsiveFontSize(1.5), color: "#333", marginTop: 4, lineHeight: 22 },
label: {
fontSize: responsiveFontSize(1.5),
fontFamily: FontsFamily.nunitoMedium,
},
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: {
backgroundColor: colors.pastelGreen,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
paddingVertical: 16,
borderRadius: 12,
marginTop: 20,
marginTop: 16,
},
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
justifyContent: 'center',
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 },
});
+373 -331
View File
@@ -1,396 +1,438 @@
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useMemo, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
TextInput,
Dimensions,
ActivityIndicator,
Image,
ScrollView,
Alert,
RefreshControl,
ToastAndroid,
} from "react-native";
import { FAB } from "react-native-paper";
import { Ionicons } from "@react-native-vector-icons/ionicons";
import { MaterialIcons } from "@react-native-vector-icons/material-icons";
import { useNavigation, useFocusEffect } from "@react-navigation/native";
import { StackNavigationProp } from "@react-navigation/stack";
import { RootStackParamList } from "../navigation/AppNavigator";
import AppView from "../components/AppView";
import { apiCall } from "../utils/ApiHelper";
import { BASE_URL, GET_JOINED_GROUP_URL, JOIN_GROUP_URL, PUBLIC_GROUPS_URL, USER_STATUS } from "../utils/ApiUrls";
import colors from "../components/colors";
import { ApiResponse } from "./home/UserProfileStatus";
import * as Keychain from "react-native-keychain";
import CustomAlert from "../components/CostomAlart";
import { responsiveFontSize } from "react-native-responsive-dimensions";
} from 'react-native';
import { FAB } from 'react-native-paper';
import LucideIcon from '../components/common/LucideIcon';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useSelector } from 'react-redux';
import AppView from '../components/AppView';
import { useTheme } from '../hooks/ui/useTheme';
import { useFetch } from '../hooks/core/useFetch';
import { useCreate } from '../hooks/core/useCreate';
import { PUBLIC_GROUPS_URL, GET_JOINED_GROUP_URL, JOIN_GROUP_URL, USER_STATUS } from '../utils/ApiUrls';
import { CustomInput, CustomButton } from '../components/common/Custom';
import SkeletonLoader from '../components/common/SkeletonLoader';
import FontsFamily from '../constents/FontsFamily';
const { width } = Dimensions.get('window');
// --- Types ---
interface Group {
id: string;
id: string | number;
groupName: string;
description: string;
visibility?: string;
status?: string;
maxParticipants: number;
currentParticipants: number;
contributionAmount: number;
cycleType: string;
createdByName: string;
imageBlob: string
imageBlob: string;
joined?: boolean;
joinRequestPending?: boolean;
entryFee?: number;
entryFeeAppliedToOrganizer?: boolean;
customCycleDays?: number;
imageUrl?: string;
}
// --- UI Components ---
const GroupCard = ({ item, onJoin, onView, isGrid }: { item: Group; onJoin:()=>void; onView: (group: Group) => void; isGrid?: boolean; }) => {
const isFull = item.currentParticipants >= item.maxParticipants;
const cardStyle = isGrid ? styles.gridCard : styles.listCard;
const [loading, setLoading] = useState<boolean>(false)
const navigation = useNavigation()
const PUBLIC_GROUPS_PARAMS = {
page: 0,
size: 10,
};
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 (
<View style={cardStyle}>
{/* <TouchableOpacity onPress={()=> navigation.navigate("GroupDetails" as never)}> */}
<View style={{ flex: 1, flexDirection: 'row', justifyContent: "space-between" }}>
<View>
<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.card,
{ backgroundColor: theme.colors.surface, borderColor: theme.colors.border },
styles.listCard
]}>
<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>
<Image
source={{ uri: item?.imageBlob !== null ? `data:image/jpeg;base64,${item?.imageBlob}` : `https://i.pravatar.cc/150?u=${item.id}` }}
style={{ width: 50, borderWidth: 1, height: 50, borderColor: colors.pastelGreen, borderRadius: 25, }}
source={{ uri: item.imageUrl ? item.imageUrl : `https://ui-avatars.com/api/?name=${item.groupName}&background=random` }}
style={[styles.groupAvatar, { borderColor: theme.colors.border }]}
/>
</View>
<View style={styles.groupMeta}>
<Text style={styles.groupMetaText}>{item.currentParticipants}/{item.maxParticipants} Members</Text>
<Text style={styles.groupMetaText}>{item.cycleType}</Text>
</View>
{/* </TouchableOpacity> */}
<View style={styles.buttonContainer}>
{/* <TouchableOpacity style={styles.viewButton} onPress={() => onView(item)}>
<Text style={styles.viewButtonText}>View</Text>
</TouchableOpacity> */}
<TouchableOpacity
style={[styles.joinButton, isFull && styles.joinButtonDisabled]}
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 style={[styles.cardDivider, { backgroundColor: theme.colors.border }]} />
<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 style={styles.metaItem}>
<LucideIcon name="repeat-outline" size={14} color={theme.colors.textMuted} />
<Text style={[styles.metaText, { color: theme.colors.textSecondary }]}>
{item.cycleType === 'CUSTOM' && item.customCycleDays
? (item.customCycleDays === 1 ? 'Daily' : item.customCycleDays === 7 ? 'Weekly' : `${item.customCycleDays} Days`)
: item.cycleType}
</Text>
</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>
);
};
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() {
const [activeTab, setActiveTab] = useState("Public");
const [searchQuery, setSearchQuery] = useState("");
const [isGridView, setIsGridView] = useState(false);
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>();
const theme = useTheme();
const navigation = useNavigation<any>();
const insets = useSafeAreaInsets();
const userRole = useSelector((state: any) => state.user?.role);
const [activeTab, setActiveTab] = useState<'Public' | 'Joined'>('Public');
const [myGroups, setMyGroups] = useState<Group[]>([]);
const [joinedGroups, setJoinedGroups] = useState<Group[]>([]);
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 [searchQuery, setSearchQuery] = useState('');
const [joiningGroupId, setJoiningGroupId] = useState<string | number | null>(null);
// Data Fetching
const { data: publicGroupsRes, isLoading: publicLoading, refetch: refetchPublic } = useFetch<any>(
PUBLIC_GROUPS_URL,
PUBLIC_GROUPS_PARAMS
);
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 role = String(userStatusRes?.data?.role || userRole || '').trim().toUpperCase();
const isOrganizer = role === 'ORGANIZER';
const publicGroups = useMemo(() => {
const groups = getGroupsFromResponse(publicGroupsRes);
return groups.filter((g) => {
const visibility = String(g.visibility || "").toUpperCase();
const status = String(g.status || "").toUpperCase();
return visibility === "PUBLIC" && status === "APPROVED";
});
}, [publicGroupsRes]);
const fetchGroups = async () => {
if (loading) return;
setLoading(true);
setError(null);
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 joinedGroups = useMemo(() => getGroupsFromResponse(joinedGroupsRes), [joinedGroupsRes]);
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[]) => {
const filteredData = useMemo(() => {
const data = activeTab === 'Public' ? publicGroups : joinedGroups;
if (!searchQuery.trim()) return data;
return data.filter(g =>
g.groupName.toLowerCase().includes(searchQuery.toLowerCase())
);
};
return data.filter((g: Group) => String(g.groupName || "").toLowerCase().includes(searchQuery.toLowerCase()));
}, [activeTab, joinedGroups, publicGroups, searchQuery]);
const handleJoinGroup = async (groupId: string, userId: string) => {
console.log("Joining group:", groupId, "User:", userId);
try {
const url = `${JOIN_GROUP_URL}groups/${groupId}/join/10`;
console.log("Joining group URL:", url);
const res = await apiCall(url, "POST") as any;
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(() => {
setRefreshing(true);
const promises = [refetchPublic(), refetchJoined()];
Promise.all(promises).finally(() => setRefreshing(false));
}, [refetchPublic, refetchJoined]);
console.log("JOIN_GROUP_URL response:", res);
if (res.statusCode === 200) {
setAlertInfo({
visible: true,
title: 'Success',
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 {
ToastAndroid.show(res.message || "Failed to join group.", ToastAndroid.SHORT);
}
} catch (error) {
console.error("Join Group Error:", error);
}
};
const userStatus = async () => {
try {
const credentials = await Keychain.getGenericPassword();
if (!credentials) {
ToastAndroid.show('You are not logged in', ToastAndroid.SHORT);
return;
}
const token = credentials.password;
setLoading(true);
setError(null);
const response = await fetch(USER_STATUS, {
method: "GET",
headers: {
Accept: "*/*",
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(
useCallback(() => {
userStatus();
onRefresh();
}, [])
);
const handleViewGroup = (group: Group) => {
// navigation.navigate('GroupDetails', { group });
ToastAndroid.show(`Viewing details for ${group.groupName}`, ToastAndroid.SHORT);
};
const onRefresh = () => {
setRefreshing(true)
fetchGroups()
setTimeout(() => {
setRefreshing(false)
}, 20000);
}
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 handleJoin = (groupId: string | number) => {
console.log("Hitting here");
setJoiningGroupId(groupId);
joinGroup(
{ resource: `${JOIN_GROUP_URL}groups/${String(groupId)}/join/10`, payload: {} } as any,
{
onSuccess: () => {
setJoiningGroupId(null);
ToastAndroid.show('Join request sent successfully!', ToastAndroid.SHORT);
refetchPublic();
},
onError: (err) => {
setJoiningGroupId(null);
ToastAndroid.show(err.message || 'Failed to send request', ToastAndroid.SHORT);
},
}
);
};
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 (
<AppView paddingHorizontal={0}>
{/* Fixed Header */}
<View style={styles.headerContainer}>
<Text style={styles.title}>Explore Groups</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 style={styles.topContainer}>
<View style={styles.header}>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Explore Circles</Text>
</View>
<CustomInput
placeholder="Search circles by name..."
value={searchQuery}
onChangeText={setSearchQuery}
icon="search-outline"
/>
{!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>
{/* Scrollable Content */}
<ScrollView style={styles.container}>
<View style={styles.tabHeader}>
<View style={styles.segmentedControl}>
{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}
<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>
);
}
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 },
title: { fontSize: responsiveFontSize(2.2), fontWeight: "600", color: colors.Group },
searchContainer: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#fff', borderRadius: 20, paddingHorizontal: 10, elevation: 5, marginVertical: 20 , borderWidth:2,borderColor:colors.pastelGreen },
searchIcon: { marginRight: 8 },
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 },
featuredList: { paddingLeft: 16, marginBottom: 20 },
featuredCard: { width: width * 0.45, marginRight: 15, backgroundColor: '#fff', borderRadius: 12, elevation: 3, overflow: 'hidden' },
featuredImage: { width: '100%', height: 100 },
featuredContent: { padding: 10 },
featuredName: { fontSize: responsiveFontSize(1.4), fontWeight: 'bold', color: '#333' },
featuredCreator: { fontSize: responsiveFontSize(1.2), color: '#777', marginTop: 2 },
tabHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, marginBottom: 16 },
segmentedControl: { flex: 1, flexDirection: 'row', borderRadius: 10, padding: 4, marginRight: 10 },
segmentButton: { flex: 1, paddingVertical: 10, borderRadius: 8, alignItems: 'center' },
segmentButtonActive: { backgroundColor: colors.pastelGreen, elevation: 5, shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 5 , borderRadius:15 },
segmentText: { fontSize: responsiveFontSize(1.4), fontWeight: '600', color: '#555' },
segmentTextActive: { color: '#333' },
layoutToggleButton: { padding: 8 },
fab: { position: "absolute", right: 20, bottom: 90, width: 60, height: 60, justifyContent: "center", alignItems: "center", backgroundColor: colors.pastelGreen, borderRadius: 30, elevation: 4 },
listCard: { backgroundColor: colors.pastelGreen, padding: 16, borderRadius: 20, marginBottom: 12,marginHorizontal:15, elevation: 5 },
gridCard: { backgroundColor: colors.pastelGreen, padding: 12, borderRadius: 20, marginBottom: 12,marginHorizontal:15, elevation: 5, width: (width - 48) / 2 },
groupName: { fontSize: responsiveFontSize(1.6), fontWeight: 'bold', color: '#333', width: '50%' },
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 },
groupMetaText: { fontSize: responsiveFontSize(1.2), color: '#888', fontWeight: '500', textTransform: 'capitalize' },
emptyText: { textAlign: 'center', marginTop: 40, fontSize: responsiveFontSize(1.6) , color: '#888' },
emptyFeaturedText: { marginLeft: 20, color: '#888' },
buttonContainer: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 8 },
viewButton: { flex: 1, backgroundColor: '#e9ecef', paddingVertical: 10, borderRadius: 8, alignItems: 'center', marginRight: 5 },
viewButtonText: { color: '#333', fontWeight: 'bold', fontSize: responsiveFontSize(1.4) },
joinButton: { flex: 1, backgroundColor: colors.white, paddingVertical: 10, borderRadius: 8, alignItems: 'center', marginLeft: 5 },
joinButtonDisabled: { backgroundColor: '#bdc3c7' },
joinButtonText: { color: '#333', fontWeight: 'bold', fontSize: responsiveFontSize(1.4) },
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
groupInfo: {
flex: 1,
marginRight: 10,
},
groupName: {
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoBold,
marginBottom: 2,
},
groupDescription: {
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoRegular,
},
groupAvatar: {
width: 48,
height: 48,
borderRadius: 12,
borderWidth: 1,
},
cardDivider: {
height: 1,
marginVertical: 10,
},
cardMeta: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 12,
},
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',
},
});
+266 -141
View File
@@ -1,21 +1,24 @@
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React, { useState, useEffect, useCallback, FC } from 'react';
import React, { useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Alert,
ActivityIndicator,
ToastAndroid,
} from 'react-native';
import { RootStackParamList } from '../navigation/AppNavigator'; // Adjust path
import { apiCall } from '../utils/ApiHelper'; // Adjust path
import AppView from '../components/AppView'; // Adjust path
import colors from '../components/colors'; // Adjust path
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/AppNavigator';
import { useFetch } from '../hooks/core/useFetch';
import { useCreate } from '../hooks/core/useCreate';
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 LucideIcon from '../components/common/LucideIcon';
import * as Keychain from 'react-native-keychain';
import FontsFamily from '../constents/FontsFamily';
// --- Type Definitions ---
interface Participant {
@@ -31,179 +34,301 @@ interface GroupDetails {
groupName: string;
description: string;
contributionAmount: number;
minParticipants: 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';
organizerFirstName: string;
organizerLastName: string | null;
organizerMobile: string;
organizerEmail: string;
totalParticipants: number;
participants: Participant[];
}
type InviteScreenProps = NativeStackScreenProps<RootStackParamList, 'Invite'>; // Assuming 'Invite' is in your stack
type InviteScreenProps = NativeStackScreenProps<RootStackParamList, 'Invite'>;
// --- Main Screen Component ---
const InviteScreen: FC<InviteScreenProps> = ({ route }) => {
const { groupId,code} = route.params;
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]);
const InviteScreen: React.FC<InviteScreenProps> = ({ route, navigation }) => {
const { groupId, code } = route.params;
const theme = useTheme();
useEffect(() => {
fetchGroupDetails();
}, [fetchGroupDetails]);
const checkAuth = async () => {
try {
const credentials = await Keychain.getGenericPassword();
if (!credentials) {
navigation.replace('Login');
}
} catch (error) {
console.error('Auth check error:', error);
navigation.replace('Login');
}
};
checkAuth();
}, [navigation]);
const handleJoinGroup = async () => {
const { data: detailsRes, isLoading, error } = useFetch<any>(
`${BASE_URL}fund/group/details?groupId=${groupId}`
);
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;
setJoining(true);
try {
// Replace with your actual API call to join the group
// For now, it just shows an alert.
await apiCall(`${BASE_URL}fund/groups/join/invite/${code}`, 'POST');
ToastAndroid.show(`You have requested to join "${groupDetails.groupName}".`, ToastAndroid.SHORT);
fetchGroupDetails(); // Refresh details after joining
} catch (e: any) {
ToastAndroid.show(e.message || 'An unexpected error occurred.', ToastAndroid.SHORT);
} finally {
setJoining(false);
}
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 (loading) return <View style={styles.centered}><ActivityIndicator size="large" color={colors.primary} /></View>;
if (error) return <View style={styles.centered}><Text style={styles.errorText}>{error}</Text></View>;
if (!groupDetails) return null;
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 isJoinable = groupDetails.status === 'PENDING' || groupDetails.status === 'APPROVED';
return (
<AppView>
<ScrollView contentContainerStyle={styles.contentContainer}>
<Text style={styles.headerTitle}>You're Invited!</Text>
<Text style={styles.headerSubtitle}>Join the group <Text style={{ fontWeight: 'bold' }}>{groupDetails.groupName}</Text></Text>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<View style={styles.header}>
<View style={[styles.inviteBadge, { backgroundColor: theme.colors.primary }]}>
<LucideIcon name="mail-open-outline" size={24} color={theme.colors.onPrimary} />
</View>
<Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}>You're Invited!</Text>
<Text style={[styles.headerSubtitle, { color: theme.colors.textSecondary }]}>
Join <Text style={{ fontFamily: FontsFamily.nunitoBold }}>{groupDetails.groupName}</Text> and start saving together.
</Text>
</View>
{/* Group Info Card */}
<View style={styles.card}>
<Text style={styles.cardTitle}>Group Details </Text>
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<Text style={[styles.cardTitle, { color: theme.colors.textPrimary }]}>Circle Overview</Text>
<InfoRow label="Organizer" value={organizerName} />
<InfoRow label="Contribution" value={`₹${groupDetails.contributionAmount.toLocaleString('en-IN')}`} />
<InfoRow label="Payment Cycle" value={groupDetails.cycleType} />
<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="Code" value={code} />
<InfoRow label="Invite Code" value={code} />
</View>
{/* Participants Card */}
<View style={styles.card}>
<Text style={styles.cardTitle}>Participants ({groupDetails.totalParticipants} / {groupDetails.maxParticipants})</Text>
{groupDetails.participants.length > 0 ? (
groupDetails.participants.map(p => (
<View key={p.id} style={styles.participantRow}>
<Text style={styles.participantName}>{`${p.firstName} ${p.lastName || ''}`.trim()}</Text>
{p.role === 'ORGANIZER' && <View style={styles.organizerBadge}><Text style={styles.organizerBadgeText}>Organizer</Text></View>}
</View>
))
) : (
<Text style={styles.noParticipantsText}>Be the first to join!</Text>
)}
</View>
</ScrollView>
{/* Floating Join Button */}
<View style={styles.floatingButtonContainer}>
<TouchableOpacity
style={[styles.joinButton, groupDetails.status !== 'PENDING' && groupDetails.status !== 'APPROVED' ? styles.disabledButton : {}]}
onPress={handleJoinGroup}
disabled={joining || (groupDetails.status !== 'PENDING' && groupDetails.status !== 'APPROVED')}
>
{joining ? (
<ActivityIndicator color="#FFFFFF" />
<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.map((p) => (
<View key={p.id} style={[styles.participantRow, { borderBottomColor: theme.colors.background }]}>
<View style={styles.participantInfo}>
<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>
))
) : (
<Text style={styles.joinButtonText}>
{groupDetails.status === 'PENDING' || groupDetails.status === 'APPROVED' ? 'Join Group' : `Group is ${groupDetails.status}`}
</Text>
<Text style={[styles.emptyText, { color: theme.colors.textSecondary }]}>Be the first to join!</Text>
)}
</TouchableOpacity>
</View>
</View>
<CustomButton
title={isJoinable ? 'Accept Invitation' : `Circle is ${groupDetails.status}`}
onPress={handleJoin}
loading={isJoining}
disabled={!isJoinable || isJoining}
style={styles.joinButton}
/>
</ScrollView>
</AppView>
);
};
// Helper component for displaying rows
const InfoRow = ({ label, value }: { label: string; value: string | number }) => (
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>{label}</Text>
<Text style={styles.infoValue}>{value}</Text>
</View>
);
const InfoRow = ({ label, value }: { label: string; value: string | number }) => {
const theme = useTheme();
return (
<View style={styles.infoRow}>
<Text style={[styles.infoLabel, { color: theme.colors.textSecondary }]}>{label}</Text>
<Text style={[styles.infoValue, { color: theme.colors.textPrimary }]}>{value}</Text>
</View>
);
};
const styles = StyleSheet.create({
centered: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F3F4F6' },
errorText: { color: '#EF4444', fontSize: responsiveFontSize(1.2), textAlign: 'center', padding: 20 },
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',
scrollContent: {
paddingBottom: 40,
},
joinButton: {
backgroundColor: colors.primary,
borderRadius: 14,
paddingVertical: 16,
paddingHorizontal: 32,
elevation: 5,
header: {
alignItems: 'center',
marginBottom: 32,
marginTop: 16,
},
inviteBadge: {
width: 60,
height: 60,
borderRadius: 30,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 4,
width: '80%',
shadowOpacity: 0.1,
shadowRadius: 8,
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',
},
joinButtonText: {
color: '#FFFFFF',
fontSize: responsiveFontSize(1.8),
fontWeight: 'bold',
avatarText: {
fontSize: 14,
fontFamily: FontsFamily.nunitoBold,
},
disabledButton: {
backgroundColor: '#9CA3AF', // Gray color when disabled
participantName: {
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;
+114 -263
View File
@@ -1,328 +1,179 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
TextInput,
StyleSheet,
TouchableOpacity,
Animated,
KeyboardAvoidingView,
Platform,
Animated,
StatusBar,
ActivityIndicator,
Dimensions,
} from 'react-native';
import { LOGIN_URL } from '../utils/ApiUrls';
import axios from 'axios';
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 LinearGradient from 'react-native-linear-gradient';
import Ionicons from '@react-native-vector-icons/ionicons';
import MaterialRipple from 'react-native-material-ripple';
import CustomAlert from '../components/CostomAlart';
import colors from '../components/colors';
import {
responsiveHeight,
responsiveWidth,
responsiveFontSize
} from "react-native-responsive-dimensions";
import { useTheme } from '../theme/ThemeProvider';
import { useTheme } from '../hooks/ui/useTheme';
import { useFetch } from '../hooks/core/useFetch';
import { LOGIN_URL } from '../utils/ApiUrls';
import { CustomInput, CustomButton } from '../components/common/Custom';
import AppView from '../components/AppView';
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() {
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>();
const theme = useTheme();
const [mobile, setMobile] = useState('');
const [loading, setLoading] = useState(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 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 [error, setError] = useState('');
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(20)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(fade, { toValue: 1, duration: 800, useNativeDriver: true }),
Animated.spring(slide, { toValue: 0, friction: 5, tension: 40, useNativeDriver: true }),
Animated.timing(fadeAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
Animated.spring(slideAnim, {
toValue: 0,
tension: 20,
useNativeDriver: true,
}),
]).start();
}, []);
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 });
};
}, [fadeAnim, slideAnim]);
const { refetch: sendOtp, isFetching: loading } = useFetch<any>(
LOGIN_URL,
{ mobile },
{
enabled: false,
retry: false,
}
);
const handleContinue = async () => {
if (mobile.length === 10) {
setLoading(true);
try {
const res = await axios.get(LOGIN_URL, { params: { mobile } });
console.log("login res -",res)
if (res.data.statusCode === 200) {
navigation.navigate('OtpVarify', { mobile });
} else {
showAlert('Login Failed', res.data.message || 'Could not send OTP.', [{ text: 'Try Again', onPress: hideAlert }], 'error');
}
} catch (error: any) {
showAlert('An Error Occurred', 'Failed to send OTP. Please check your connection and try again.', [{ text: 'OK', onPress: hideAlert }], 'error');
} finally {
setLoading(false);
if (!/^\d{10}$/.test(mobile)) {
setError('Please enter a 10-digit mobile number');
return;
}
setError('');
try {
const { data } = await sendOtp();
if (data?.statusCode === 200) {
navigation.navigate('OtpVarify', { mobile });
} else {
setError(data?.message || 'Failed to send OTP. Please try again.');
}
} else {
showAlert('Invalid Number', 'Please enter a valid 10-digit mobile number.', [{ text: 'Got it', onPress: hideAlert }], 'info');
} catch (err) {
setError('Something went wrong. Please check your connection.');
}
};
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 (
<View style={[ styles.container, {backgroundColor: theme.colors.background }]}>
<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>
<AppView keyboardSafe={false} style={styles.container}>
<KeyboardAvoidingView
style={styles.keyboardAvoidingContainer}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<View style={styles.contentContainer}>
<View style={[ styles.topSection, {marginTop:60}]}>
<Animated.View style={{ opacity: fade, transform: [{ translateY: slide }] }}>
<Text style={[styles.brand, { color: theme.colors.textPrimary }]}>CircleCollect</Text>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Welcome Back!</Text>
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
Let's get you signed in to your community.
</Text>
</Animated.View>
<Animated.View
style={[
styles.content,
{
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 }]}>
Enter your mobile number to sign in to your community.
</Text>
</View>
<View style={styles.formSection}>
<CustomInput
label="Mobile Number"
placeholder="e.g. 9876543210"
value={mobile}
onChangeText={(text) => {
setMobile(text.replace(/\D/g, ''));
if (error) setError('');
}}
keyboardType="phone-pad"
maxLength={10}
icon="call-outline"
error={error}
/>
<Animated.View style={{ opacity: fade, transform: [{ translateY: slide }] ,marginTop: 150}}>
<View style={[styles.inputWrapper, { backgroundColor: theme.colors.reverseBackground }]}>
<Ionicons name="call-outline" size={24} color={theme.colors.reverseText} style={styles.inputIcon} />
<TextInput
style={[ styles.input, { color: theme.colors.reverseText, fontFamily: FontsFamily.nunitoRegular }]}
placeholder="Enter your mobile number"
placeholderTextColor={theme.colors.reverseText}
value={mobile}
onChangeText={setMobile}
keyboardType="phone-pad"
maxLength={10}
selectionColor={colors.textLight}
/>
</View>
</Animated.View>
<Animated.View style={[styles.bottomSection, { transform: [{ scale: ctaScale }] }]}>
<MaterialRipple
<CustomButton
title="Continue"
onPress={handleContinue}
disabled={loading}
rippleColor={colors.white}
loading={loading}
icon="arrow-forward-outline"
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.
/>
</View>
<View style={styles.footerSection}>
<Text style={[styles.footerText, { color: theme.colors.textMuted }]}>
Your information is secure and private.
</Text>
</Animated.View>
</View>
</View>
</Animated.View>
</KeyboardAvoidingView>
<CustomAlert
visible={alertInfo.visible}
title={alertInfo.title}
message={alertInfo.message}
buttons={alertInfo.buttons}
alertType={alertInfo.alertType}
/>
</View>
</AppView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gridContainer: {
...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: {
keyboardView: {
flex: 1,
},
contentContainer: {
flex: 1,
justifyContent: 'space-between',
padding: 24,
},
topSection: {
flex: 0.8,
justifyContent: 'center',
},
content: {
paddingHorizontal: 12,
},
headerSection: {
alignItems: 'center',
marginBottom: 48,
},
brand: {
fontSize: responsiveFontSize(3),
color: colors.primary,
textAlign: 'center',
letterSpacing: 2,
marginBottom: 16,
textShadowColor: 'rgba(0, 0, 0, 0.1)',
textShadowOffset: { width: 1, height: 1 },
textShadowRadius: 2,
fontSize: responsiveFontSize(2.2),
fontFamily: FontsFamily.nunitoExtraBold,
letterSpacing: 1.5,
textTransform: 'uppercase',
marginBottom: 16,
},
title: {
fontSize: responsiveFontSize(5),
fontFamily: FontsFamily.nunitoBold,
textAlign: 'center',
marginBottom: 14,
fontSize: responsiveFontSize(3.5),
fontFamily: FontsFamily.nunitoBold,
marginBottom: 8,
},
subtitle: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
fontFamily: FontsFamily.nunitoRegular,
textAlign: 'center',
lineHeight: 26,
maxWidth: 320,
paddingHorizontal: 24,
lineHeight: 24,
},
inputWrapper: {
flexDirection: 'row',
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,
formSection: {
marginBottom: 32,
},
button: {
width: '100%',
borderRadius: 30,
elevation: 14,
marginTop: 20,
marginTop: 16,
},
buttonGradient: {
borderRadius:42,
paddingVertical: 14,
flexDirection: 'row',
justifyContent: 'center',
footerSection: {
alignItems: 'center',
},
buttonContent: {
flexDirection: 'row',
alignItems: 'center',
},
buttonText: {
color: colors.white,
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoBold,
},
footerText: {
fontSize: responsiveFontSize(1.4),
color: 'rgba(0,0,0,0.5)',
marginTop: 28,
textAlign: 'center',
maxWidth: 300,
fontSize: responsiveFontSize(1.3),
fontFamily: FontsFamily.nunitoRegular,
textAlign: 'center',
},
});
});
+1 -1
View File
@@ -92,4 +92,4 @@ const styles = StyleSheet.create({
},
});
export default NotificationRedirection;
export default NotificationRedirection;
+145 -118
View File
@@ -1,154 +1,181 @@
import React, { useState } from "react";
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
Pressable,
StyleSheet,
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
ToastAndroid,
} from "react-native";
import axios from "axios";
import { ORGRAGISTRATION } from "../utils/ApiUrls";
import * as Keychain from "react-native-keychain";
import { responsiveFontSize } from "react-native-responsive-dimensions";
View,
Text,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
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 = {
primary: "#111",
accent: "#FF7A00",
background: "#F9F9F9",
text: "#1A1A1A",
subText: "#6B6B6B",
outline: "#E5E5E5",
type OrganizerForm = {
bankAccountNumber: string;
bankName: string;
branchCode: string;
};
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() {
const [bankAccountNumber, setBankAccountNumber] = useState("");
const [bankName, setBankName] = useState("");
const [branchCode, setBranchCode] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
if (!bankAccountNumber || !bankName || !branchCode) {
ToastAndroid.show('All fields are required.', ToastAndroid.SHORT);
return;
}
const theme = useTheme();
const navigation = useNavigation<any>();
try {
setLoading(true);
const [form, setForm] = useState<OrganizerForm>({
bankAccountNumber: '',
bankName: '',
branchCode: '',
});
const [errors, setErrors] = useState<OrganizerFormErrors>({});
// 🔑 get token from Keychain
const creds = await Keychain.getGenericPassword();
const token = creds ? creds.password : null;
const { mutate: register, isPending: isLoading } = useCreate<any, any>(ORGRAGISTRATION);
if (!token) {
ToastAndroid.show('No token found. Please log in again.', ToastAndroid.SHORT);
setLoading(false);
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]: '' }));
}
};
const validateForm = () => {
const nextErrors: OrganizerFormErrors = {};
if (!isValidBankName(form.bankName)) {
nextErrors.bankName = 'Enter a valid bank name (min 2 letters).';
}
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;
}
const payload = {
bankAccountNumber,
bankName,
branchCode,
};
const res = await axios.post(
ORGRAGISTRATION, // your API endpoint
payload,
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`, // 👈 token added here
},
}
);
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);
}
};
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);
},
});
};
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: theme.background }}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>Organizer Registration 📝</Text>
<AppView keyboardSafe={false}>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
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
placeholder="Bank Account Number"
style={styles.input}
value={bankAccountNumber}
onChangeText={setBankAccountNumber}
keyboardType="numeric"
/>
<View style={[styles.form, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<CustomInput
label="Bank Name"
placeholder="Enter your bank name"
value={form.bankName}
onChangeText={(val) => handleChange('bankName', val)}
icon="business-outline"
error={errors.bankName}
/>
<TextInput
placeholder="Bank Name"
style={styles.input}
value={bankName}
onChangeText={setBankName}
/>
<CustomInput
label="Account Number"
placeholder="Enter bank account number"
value={form.bankAccountNumber}
onChangeText={(val) => handleChange('bankAccountNumber', val)}
keyboardType="numeric"
maxLength={18}
icon="card-outline"
error={errors.bankAccountNumber}
/>
<TextInput
placeholder="Branch Code"
style={styles.input}
value={branchCode}
onChangeText={setBranchCode}
/>
<CustomInput
label="Branch Code / IFSC"
placeholder="Enter branch code"
value={form.branchCode}
onChangeText={(val) => handleChange('branchCode', val)}
autoCapitalize="characters"
maxLength={11}
icon="code-working-outline"
error={errors.branchCode}
/>
</View>
<Pressable style={styles.button} onPress={handleSubmit} disabled={loading}>
<Text style={styles.buttonText}>{loading ? "Submitting..." : "Submit"}</Text>
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
<CustomButton
title="Complete Registration"
onPress={handleSubmit}
loading={isLoading}
disabled={isLoading}
style={styles.button}
/>
</ScrollView>
</KeyboardAvoidingView>
</AppView>
);
}
const styles = StyleSheet.create({
container: {
flexGrow: 1,
justifyContent: "center",
padding: 20,
paddingBottom: 40,
},
header: {
marginBottom: 32,
marginTop: 16,
},
title: {
fontSize: responsiveFontSize(2.8),
fontWeight: "700",
color: theme.text,
marginBottom: 20,
textAlign: "center",
fontSize: responsiveFontSize(3.2),
fontFamily: FontsFamily.nunitoBold,
},
input: {
subtitle: {
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 8,
lineHeight: 24,
},
form: {
borderRadius: 20,
borderWidth: 1,
borderColor: theme.outline,
borderRadius: 12,
padding: 14,
marginBottom: 15,
fontSize: responsiveFontSize(1.6),
backgroundColor: "#fff",
padding: 24,
marginBottom: 24,
},
button: {
backgroundColor: theme.primary,
paddingVertical: 16,
borderRadius: 12,
alignItems: "center",
},
buttonText: {
color: "#fff",
fontWeight: "600",
fontSize: responsiveFontSize(1.6),
marginTop: 'auto',
},
});
+199 -341
View File
@@ -1,396 +1,254 @@
import { CommonActions, RouteProp, useNavigation, useRoute } from "@react-navigation/native";
import axios from "axios";
import React, { useEffect, useRef, useState } from "react";
import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
Pressable,
Animated,
Platform,
NativeSyntheticEvent,
TextInputKeyPressEventData,
KeyboardAvoidingView,
StatusBar,
Dimensions,
} from "react-native";
import { RootStackParamList } from "../navigation/AppNavigator";
import * as Keychain from "react-native-keychain";
import { LOGIN_URL, VARIFY_OTP_URL } from "../utils/ApiUrls";
import colors from "../components/colors";
import CustomAlert from "../components/CostomAlart";
import { getFcmToken } from "../utils/firebase/firebaseServices";
import { useTheme } from "../theme/ThemeProvider";
import FontsFamily from "../constents/FontsFamily";
ToastAndroid,
TouchableOpacity,
} from 'react-native';
import { useNavigation, useRoute, RouteProp, CommonActions } from '@react-navigation/native';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import * as Keychain from 'react-native-keychain';
import { RootStackParamList } from '../navigation/AppNavigator';
import { useTheme } from '../hooks/ui/useTheme';
import { useCreate } from '../hooks/core/useCreate';
import { LOGIN_URL, VARIFY_OTP_URL } from '../utils/ApiUrls';
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 { width } = Dimensions.get('window');
const BLOCK_SIZE = width / 3;
type OtpVerifyRouteProp = RouteProp<RootStackParamList, "OtpVarify">;
interface AlertButton {
text: string;
onPress: () => void;
style?: "cancel" | "destructive" | "default";
}
type OtpVerifyRouteProp = RouteProp<RootStackParamList, 'OtpVarify'>;
export default function OtpVerificationScreen() {
const [code, setCode] = useState<string[]>(Array(CELL_COUNT).fill(""));
const [timer, setTimer] = useState(30);
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 theme = useTheme();
const navigation = useNavigation<any>();
const route = useRoute<OtpVerifyRouteProp>();
const { mobile } = route.params;
const navigation = useNavigation<any>();
// Refs for auto-closing logic
const successActionRef = useRef<(() => void) | null>(null);
const successTimerRef = useRef<number | null>(null);
const [code, setCode] = useState<string[]>(Array(CELL_COUNT).fill(''));
const [timer, setTimer] = useState(30);
const inputs = useRef<Array<TextInput | null>>([]);
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 fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(20)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(fade, { toValue: 1, duration: 800, useNativeDriver: true }),
Animated.spring(slide, { toValue: 0, friction: 5, tension: 40, useNativeDriver: true }),
Animated.timing(fadeAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
Animated.spring(slideAnim, {
toValue: 0,
tension: 20,
useNativeDriver: true,
}),
]).start();
const interval = setInterval(() => {
setTimer((prev) => (prev > 0 ? prev - 1 : 0));
}, 1000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (timer <= 0) return;
const interval = setInterval(() => setTimer((prev) => prev - 1), 1000);
return () => clearInterval(interval);
}, [timer]);
const { mutate: verifyOtp, isPending: verifying } = useCreate<any, any>(VARIFY_OTP_URL, {
onSuccess: async (res) => {
if (res?.statusCode === 200) {
const { jwt, userId } = res?.data || {};
await Keychain.setGenericPassword('auth', jwt, {
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED,
});
await getFcmToken(userId);
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" },
});
if (res.data?.statusCode === 200) {
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(
CommonActions.reset({
index: 0,
routes: [{ name: 'Home' }],
})
);
};
triggerAlert(
'Success!',
'OTP verified successfully. Redirecting...',
[{ text: 'OK', onPress: successAction }],
'success',
successAction
ToastAndroid.show('Verified successfully', ToastAndroid.SHORT);
navigation.dispatch(
CommonActions.reset({
index: 0,
routes: [{ name: 'Home' }],
})
);
} else {
ToastAndroid.show(res?.message || 'Invalid OTP', ToastAndroid.SHORT);
}
} 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'
);
}
};
},
onError: () => {
ToastAndroid.show('Verification failed. Please try again.', ToastAndroid.SHORT);
},
});
const resendOtp = async () => {
try {
setIsResending(true);
const { mutate: resendOtp, isPending: resending } = useCreate<any, any>(LOGIN_URL, {
onSuccess: () => {
setTimer(30);
await axios.get(LOGIN_URL, { params: { mobile } })
setCode(Array(CELL_COUNT).fill(""));
setCode(Array(CELL_COUNT).fill(''));
inputs.current[0]?.focus();
ToastAndroid.show('OTP Resent', ToastAndroid.SHORT);
},
onError: () => {
ToastAndroid.show('Failed to resend OTP', ToastAndroid.SHORT);
},
});
let hasActionRun = false;
const successAction = () => {
if (hasActionRun) return;
hasActionRun = true;
hideAlert();
};
const handleTextChange = (text: string, index: number) => {
const newCode = [...code];
newCode[index] = text.slice(-1);
setCode(newCode);
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);
if (text && index < CELL_COUNT - 1) {
inputs.current[index + 1]?.focus();
}
if (newCode.every((cell) => cell !== '')) {
verifyOtp({ mobile, otp: newCode.join('') });
}
};
const submitIfComplete = (arr: string[]) => {
if (arr.join("").length === CELL_COUNT) {
onSubmit(arr.join(""), mobile);
const handleKeyPress = (e: any, index: number) => {
if (e.nativeEvent.key === 'Backspace' && !code[index] && index > 0) {
inputs.current[index - 1]?.focus();
}
};
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 {
inputs.current[CELL_COUNT - 1]?.blur();
}
submitIfComplete(next);
};
const handleKeyPress = (idx: number, e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
if (e.nativeEvent.key === "Backspace" && !code[idx] && idx > 0) {
inputs.current[idx - 1]?.focus();
}
};
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 (
<View style={[styles.container, {backgroundColor: theme.colors.background }]}>
<StatusBar barStyle={theme.mode === "dark" ? "light-content" : "dark-content"} backgroundColor={colors.pastelOrange} />
<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 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>
<AppView keyboardSafe={false} style={styles.container}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<Animated.View
style={[
styles.content,
{
opacity: fadeAnim,
transform: [{ translateY: slideAnim }],
},
]}
>
<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>
</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>
</Pressable>
<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>
{timer > 0 ? (
<Text style={[styles.footerText, { color: theme.colors.reverseText }]}>Resend code in 00:{timer.toString().padStart(2, "0")}</Text>
) : (
<Pressable onPress={resendOtp} disabled={isResending}>
<Text style={[styles.footerText, { color: theme.colors.reverseText, fontWeight: '600' }]}>
{isResending ? "Resending..." : "Resend Code"}
<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>
</Pressable>
)}
</Animated.View>
</View>
) : (
<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>
</KeyboardAvoidingView>
</View>
</AppView>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
gridContainer: {
...StyleSheet.absoluteFillObject,
container: {
flex: 1,
},
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',
flexWrap: 'wrap',
justifyContent: 'space-between',
marginBottom: 40,
},
gridBlock: {
width: BLOCK_SIZE,
height: BLOCK_SIZE,
margin: 2,
borderRadius: 16,
otpInput: {
width: 48,
height: 56,
borderRadius: 12,
borderWidth: 1.5,
textAlign: 'center',
fontSize: responsiveFontSize(2.2),
fontFamily: FontsFamily.nunitoBold,
},
elevatedBlock: {
shadowColor: '#ffffffff',
shadowOffset: { width: 1, height: 5 },
shadowOpacity: 1,
shadowRadius: 15,
elevation: 10,
actionSection: {
alignItems: 'center',
},
keyboardAvoidingContainer: { flex: 1, justifyContent: 'center' },
contentPanel: {
marginHorizontal: 24,
borderRadius: 32,
padding: 28,
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.14,
shadowRadius: 24,
elevation: 12,
button: {
width: '100%',
marginBottom: 24,
},
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%',
paddingHorizontal: 0, // Removed padding to use full width
resendContainer: {
alignItems: 'center',
},
// FIX: Updated Cell Styling for clean smooth radius
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',
timerText: {
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoRegular,
},
cellFilled: {
// Optional: Add specific style when filled if needed, handled inline above
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 },
});
+63 -109
View File
@@ -1,133 +1,87 @@
import React, { useEffect, useRef } from "react";
import { View, Text, StyleSheet, ActivityIndicator, Animated, Alert, ToastAndroid } from "react-native";
import * as Keychain from "react-native-keychain";
import { useNavigation } from "@react-navigation/native";
import { RootStackParamList } from "../navigation/AppNavigator";
import { PROFILE_URL } from "../utils/ApiUrls";
import { useDispatch } from "react-redux";
import { clearUser, setUserData } from "../redux/userSlice";
import colors from "../components/colors";
import { StackNavigationProp } from "@react-navigation/stack";
import React, { useEffect, useRef } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, Animated } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { useDispatch } from 'react-redux';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { RootStackParamList } from '../navigation/AppNavigator';
import { PROFILE_URL } from '../utils/ApiUrls';
import { setUserData, clearUser } from '../redux/userSlice';
import { useTheme } from '../hooks/ui/useTheme';
import { useFetch } from '../hooks/core/useFetch';
import FontsFamily from '../constents/FontsFamily';
const SplashScreen = () => {
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>();
const dispatch = useDispatch();
const fadeAnim = useRef(new Animated.Value(1)).current;
const theme = useTheme();
const fadeAnim = useRef(new Animated.Value(0)).current;
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();
if (response.status === 200 && result.status === "success") {
// Save user data in Redux
dispatch(
setUserData({
id: result.data.id,
userId: result.data.userId,
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,
})
);
return result.data.role;
} else if (response.status === 401 || result.status === 401) {
// Unauthorized: reset credentials and navigate to login
await Keychain.resetGenericPassword();
dispatch(clearUser());
ToastAndroid.show(result.message || "Session expired. Please login again.", ToastAndroid.SHORT);
navigation.replace("Login");
return null;
} else {
console.warn("Unexpected response:", result);
return null;
}
} catch (error) {
console.error("❌ Error fetching profile:", error);
ToastAndroid.show("Failed to fetch profile. Please try again.", ToastAndroid.SHORT);
return null;
}
};
const { data, isError, isLoading, error } = useFetch<any>(PROFILE_URL, {}, {
retry: false,
staleTime: 0,
});
useEffect(() => {
const startSplash = async () => {
const role = await fetchUserProfile(dispatch, navigation);
Animated.timing(fadeAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}).start();
}, []);
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
useEffect(() => {
if (!isLoading) {
const timer = setTimeout(() => {
if (data && data.status === 'success') {
const userData = data.data;
dispatch(
setUserData({
...userData,
profilePicture: userData.filePath,
profileStatus: true,
})
);
navigation.replace('Home');
} else {
navigation.replace("Login");
// If error is 401 or no data (not logged in)
dispatch(clearUser());
navigation.replace('Login');
}
});
};
}, 1500); // Minimum splash time
startSplash();
}, [navigation, fadeAnim, dispatch, ]);
return () => clearTimeout(timer);
}
}, [isLoading, data, isError]);
return (
<View style={styles.container}>
<Animated.Text style={[styles.title, { opacity: fadeAnim }]}>
Circle Collect
</Animated.Text>
<ActivityIndicator size="large" color={colors.pastelPink} style={{ marginTop: 20 }} />
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
<Animated.View style={{ opacity: fadeAnim, alignItems: 'center' }}>
<Text style={[styles.brand, { color: theme.colors.primary }]}>CircleCollect</Text>
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>Your Community Finance Hub</Text>
<ActivityIndicator size="small" color={theme.colors.primary} style={{ marginTop: 24 }} />
</Animated.View>
</View>
);
};
export default SplashScreen;
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#FFFFFF",
justifyContent: 'center',
alignItems: 'center',
},
title: {
fontSize: 28,
fontWeight: "800",
color: colors.Home,
letterSpacing: 1,
brand: {
fontSize: responsiveFontSize(4),
fontFamily: FontsFamily.nunitoExtraBold,
letterSpacing: 2,
},
subtitle: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 8,
},
});
export default SplashScreen;
+86 -49
View File
@@ -1,74 +1,111 @@
import React, { useState } from "react";
import { View, Text, TextInput, Button, Modal, StyleSheet } from "react-native";
import React, { useState } from 'react';
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;
onClose: () => 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 (
<Modal
transparent
animationType="fade"
visible={visible}
onRequestClose={onClose}
>
<View style={styles.overlay}>
<View style={styles.container}>
<Text style={styles.title}>Decline Request</Text>
<TextInput
style={styles.input}
placeholder="Enter reason"
value={reason}
onChangeText={setReason}
/>
<View style={styles.actions}>
<Button title="Cancel" onPress={onClose} />
<Button
title="Decline"
onPress={() => {
onSubmit(reason);
setReason(""); // reset after submit
}}
color="red"
/>
</View>
<Modal transparent animationType="fade" visible={visible} onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View style={styles.overlay}>
<TouchableWithoutFeedback>
<View style={[styles.container, { backgroundColor: theme.colors.surface, borderRadius: theme.borderRadius.xl }]}>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Decline Request</Text>
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
Please provide a reason for declining this join request.
</Text>
<CustomInput
placeholder="Reason for declining..."
value={reason}
onChangeText={setReason}
multiline
style={styles.input}
/>
<View style={styles.actions}>
<CustomButton
title="Cancel"
onPress={onClose}
type="outline"
style={styles.button}
/>
<CustomButton
title="Decline"
onPress={handleSubmit}
style={[styles.button, { backgroundColor: theme.colors.error }]}
disabled={!reason.trim()}
/>
</View>
</View>
</TouchableWithoutFeedback>
</View>
</View>
</TouchableWithoutFeedback>
</Modal>
);
};
export default DeclineModal;
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0,0,0,0.5)",
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.4)',
padding: 20,
},
container: {
width: "80%",
backgroundColor: "white",
padding: 20,
borderRadius: 10,
width: '100%',
maxWidth: 400,
padding: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 5,
},
title: {
fontSize: 18,
marginBottom: 10,
fontSize: responsiveFontSize(2.2),
fontFamily: FontsFamily.nunitoBold,
marginBottom: 8,
},
subtitle: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
marginBottom: 20,
lineHeight: 20,
},
input: {
borderWidth: 1,
borderColor: "#ccc",
borderRadius: 5,
padding: 8,
marginBottom: 15,
height: 100,
textAlignVertical: 'top',
marginBottom: 24,
},
actions: {
flexDirection: "row",
justifyContent: "space-between",
flexDirection: 'row',
gap: 12,
},
button: {
flex: 1,
},
});
export default DeclineModal;
+191 -143
View File
@@ -1,193 +1,241 @@
import React, { useState, useEffect } from "react";
import React, { useState, useMemo } from 'react';
import {
Keyboard,
KeyboardAvoidingView,
View,
Text,
StyleSheet,
TouchableOpacity,
TouchableWithoutFeedback,
TextInput,
FlatList,
ActivityIndicator,
LayoutAnimation,
UIManager,
Platform,
Alert,
ToastAndroid,
} from "react-native";
import { Ionicons } from "@react-native-vector-icons/ionicons";
import colors from "../../components/colors";
import * as keychain from "react-native-keychain";
import { BASE_URL } from "../../utils/ApiUrls";
UIManager,
} from 'react-native';
import LucideIcon from '../../components/common/LucideIcon';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useTheme } from '../../hooks/ui/useTheme';
import { useFetch } from '../../hooks/core/useFetch';
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);
}
const GroupCard = ({ item }: { item: any }) => {
const [expanded, setExpanded] = useState(false);
const theme = useTheme();
const toggleExpand = () => {
LayoutAnimation.easeInEaseOut();
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setExpanded(!expanded);
};
const initial = item.groupName ? item.groupName.charAt(0).toUpperCase() : "?";
const initial = item.groupName ? item.groupName.charAt(0).toUpperCase() : '?';
return (
<View style={styles.groupCard}>
<View style={styles.groupLeft}>
<View style={styles.groupAvatar}>
<Text style={styles.avatarText}>{initial}</Text>
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<TouchableOpacity
style={styles.cardHeader}
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 style={{ flex: 1 }}>
<Text style={styles.groupName}>{item.groupName}</Text>
<Text style={styles.groupDescription}>{item.description}</Text>
<Text style={styles.groupMeta}>
{item.visibility} • {item.cycleType}
<View style={styles.headerInfo}>
<Text style={[styles.groupName, { color: theme.colors.textPrimary }]}>{item.groupName}</Text>
<Text style={[styles.groupMeta, { color: theme.colors.textSecondary }]} numberOfLines={1}>
{item.visibility} • {item.cycleType} • {item.description || 'No description'}
</Text>
</View>
<TouchableOpacity onPress={toggleExpand}>
<Ionicons
name={expanded ? "chevron-up-outline" : "chevron-forward-outline"}
size={22}
color={colors.textLight}
/>
</TouchableOpacity>
</View>
<LucideIcon
name={expanded ? 'chevron-up' : 'chevron-forward'}
size={20}
color={theme.colors.textMuted}
/>
</TouchableOpacity>
{expanded && (
<View style={styles.detailsBox}>
{Object.entries(item).map(([key, value]) => (
<Text key={key} style={styles.detailText}>
<Text style={{ fontWeight: "600" }}>{key}:</Text>{" "}
{Array.isArray(value) ? value.join("-") : String(value)}
</Text>
))}
<View style={[styles.details, { borderTopColor: theme.colors.border }]}>
<View style={styles.detailRow}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>Status</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>{item.status}</Text>
</View>
<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>
);
};
// ✅ Group List with Search
const GroupList = () => {
const [groups, setGroups] = useState<any[]>([]);
const [filteredGroups, setFilteredGroups] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const theme = useTheme();
const [search, setSearch] = useState('');
useEffect(() => {
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();
}, []);
const { data, isLoading } = useFetch<any>(MY_GROUPS_URL);
useEffect(() => {
if (search.trim() === "") {
setFilteredGroups(groups);
} else {
setFilteredGroups(
groups.filter((g) =>
g.groupName.toLowerCase().includes(search.toLowerCase())
)
);
}
}, [search, groups]);
const filteredGroups = useMemo(() => {
const groups = data?.data || [];
if (!search.trim()) return groups;
return groups.filter((g: any) =>
g.groupName.toLowerCase().includes(search.toLowerCase())
);
}, [data?.data, search]);
if (loading) return <ActivityIndicator size="large" color={colors.pastelBlue} />;
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>
);
}
return (
<>
<View style={styles.searchContainer}>
<Ionicons name="search" size={20} color={colors.textLight} />
<TextInput
placeholder="Search groups..."
placeholderTextColor={colors.textLight}
style={styles.searchInput}
value={search}
onChangeText={setSearch}
/>
</View>
<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 your circles..."
placeholderTextColor={theme.colors.textMuted}
style={[styles.searchInput, { color: theme.colors.textPrimary }]}
value={search}
onChangeText={setSearch}
/>
{search.length > 0 && (
<TouchableOpacity onPress={() => setSearch('')}>
<LucideIcon name="close-circle" size={18} color={theme.colors.textMuted} />
</TouchableOpacity>
)}
</View>
<FlatList
data={filteredGroups}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <GroupCard item={item} />}
ListEmptyComponent={
<Text style={styles.emptyText}>No groups found</Text>
}
/>
</>
<FlatList
data={filteredGroups}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => <GroupCard item={item} />}
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<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;
const styles = StyleSheet.create({
searchContainer: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.white,
container: {
flex: 1,
},
loadingContainer: {
padding: 16,
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
height: 48,
borderRadius: 12,
borderWidth: 1,
paddingHorizontal: 12,
marginVertical: 10,
height: 45,
shadowColor: "#000",
shadowOpacity: 0.05,
shadowRadius: 5,
elevation: 2,
marginHorizontal: 16,
marginVertical: 12,
},
searchInput: { flex: 1, marginLeft: 8, color: colors.textDark },
groupCard: {
backgroundColor: colors.white,
padding: 14,
borderRadius: 14,
searchInput: {
flex: 1,
marginLeft: 8,
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
padding: 0,
},
listContent: {
paddingHorizontal: 16,
paddingBottom: 24,
},
card: {
borderRadius: 16,
borderWidth: 1,
marginBottom: 12,
shadowColor: "#000",
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
overflow: 'hidden',
},
groupLeft: { flexDirection: "row", alignItems: "center" },
groupAvatar: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: colors.pastelPink,
justifyContent: "center",
alignItems: "center",
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
},
avatar: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
avatarText: { fontSize: 18, fontWeight: "700", color: colors.black },
groupName: { fontSize: 16, fontWeight: "600", color: colors.textDark },
groupDescription: { fontSize: 13, color: colors.textLight, marginTop: 2 },
groupMeta: { fontSize: 12, color: colors.black, marginTop: 2 },
detailsBox: {
marginTop: 10,
padding: 10,
borderRadius: 8,
backgroundColor: "#f9f9f9",
avatarText: {
fontSize: 18,
fontFamily: FontsFamily.nunitoBold,
},
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 },
});
headerInfo: {
flex: 1,
},
groupName: {
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',
},
});
+38 -82
View File
@@ -1,24 +1,22 @@
import React, { useState, useEffect } from "react";
import {
View,
Text,
StyleSheet,
TouchableOpacity,
} from "react-native";
import colors from "../../components/colors";
import GroupList from "./GroupList";
import RequestList from "./RequestList";
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useTheme } from '../../hooks/ui/useTheme';
import GroupList from './GroupList';
import RequestList from './RequestList';
import FontsFamily from '../../constents/FontsFamily';
// ✅ Tab Layout
const GroupsTabs = () => {
const [activeTab, setActiveTab] = useState("MyGroups");
const [activeTab, setActiveTab] = useState('MyGroups');
const theme = useTheme();
const renderContent = () => {
switch (activeTab) {
case "MyGroups":
case 'MyGroups':
return <GroupList />;
case "Requests":
case 'Requests':
return <RequestList />;
default:
return null;
@@ -26,101 +24,59 @@ const GroupsTabs = () => {
};
return (
<>
{/* Tabs */}
<View style={styles.tabs}>
{["MyGroups", "Requests"].map((tab) => (
<View style={styles.container}>
<View style={[styles.tabs, { borderBottomColor: theme.colors.border }]}>
{['MyGroups', 'Requests'].map((tab) => (
<TouchableOpacity
key={tab}
style={[
styles.tabButton,
activeTab === tab && styles.activeTabButton,
activeTab === tab && { borderBottomColor: theme.colors.primary, borderBottomWidth: 2 },
]}
onPress={() => setActiveTab(tab)}
activeOpacity={0.7}
>
<Text
style={[
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>
</TouchableOpacity>
))}
</View>
{/* Content */}
{renderContent()}
</>
<View style={styles.content}>
{renderContent()}
</View>
</View>
);
};
export default GroupsTabs;
const styles = StyleSheet.create({
container: {
flex: 1,
},
tabs: {
flexDirection: "row",
justifyContent: "space-around",
marginVertical: 10,
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: "#ddd",
marginHorizontal: 16,
},
tabButton: { paddingVertical: 10, flex: 1, alignItems: "center" },
tabText: { fontSize: 16, color: colors.textLight },
activeTabButton: { borderBottomWidth: 2, borderBottomColor: colors.Home },
activeTabText: { color: colors.Home, fontWeight: "600" },
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,
tabButton: {
paddingVertical: 12,
marginRight: 24,
alignItems: 'center',
},
searchInput: { flex: 1, marginLeft: 8, color: colors.textDark },
groupCard: {
backgroundColor: colors.white,
padding: 14,
borderRadius: 14,
marginBottom: 12,
shadowColor: "#000",
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
tabText: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoMedium,
},
groupLeft: { flexDirection: "row", alignItems: "center" },
groupAvatar: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: colors.Home + "33",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
content: {
flex: 1,
marginTop: 8,
},
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 },
});
+145 -219
View File
@@ -1,200 +1,117 @@
import React, { useEffect, useState, useCallback } from "react";
import React, { useCallback, useMemo } from 'react';
import {
StyleSheet,
Text,
View,
TouchableOpacity,
UIManager,
Platform,
FlatList,
ListRenderItem,
Alert,
RefreshControl,
StatusBar,
} from "react-native";
import AppView from "../../components/AppView";
import colors from "../../components/colors";
import { useDispatch, useSelector } from "react-redux";
import { useNavigation } from "@react-navigation/native";
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";
} from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import LucideIcon from '../../components/common/LucideIcon';
if (Platform.OS === "android" && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
type UserProfile = {
id: string;
userId: number;
firstName: string | null;
lastName: string | null;
imageBlob: string | null;
role?: string;
organizerStatus?: string;
profileStatus?: boolean;
};
import AppView from '../../components/AppView';
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 { useTheme } from '../../hooks/ui/useTheme';
import { useFetch } from '../../hooks/core/useFetch';
import FontsFamily from '../../constents/FontsFamily';
interface ActionItem {
id: string;
title: string;
action: string;
tab?: "My Groups" | "Joined Groups" | "Requests" | "Your Requests";
iconName?: any;
backOne?: string;
backTwo?: string;
subtitle?: string;
tab?: 'My Groups' | 'Joined Groups' | 'Requests' | 'Your Requests';
iconName: string;
subtitle: string;
}
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: "2", title: "Joined Groups", subtitle: "View groups", action: "NAVIGATE_MANAGEMENT", tab: "Joined Groups", iconName: "people-outline", backOne: "#D7E9F7", backTwo: "#82AAE3" },
{ id: "3", title: "Requests", subtitle: "Check requests", action: "NAVIGATE_MANAGEMENT", tab: "Requests", iconName: "notifications-outline", backOne: "#DFF6DD", backTwo: "#88D18A" },
{ id: "4", title: "Create Group", subtitle: "Start new group", action: "NAVIGATE_CREATE_GROUP", iconName: "add-circle-outline", backOne: "#FFF4D2", backTwo: "#FFB344" },
{ id: "5", title: "Your Requests", subtitle: "Track requests", action: "NAVIGATE_MANAGEMENT", tab: "Your Requests", iconName: "mail-open-outline", backOne: "#FFF4D2", backTwo: "#FFB344" },
{ id: "6", title: "Transactions", subtitle: "View history", action: "TRANSACTION", iconName: "receipt-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: 'Circles you belong to', action: 'NAVIGATE_MANAGEMENT', tab: 'Joined Groups', iconName: 'people-outline' },
{ id: '3', title: 'Requests', subtitle: 'Pending join requests', action: 'NAVIGATE_MANAGEMENT', tab: 'Requests', iconName: 'notifications-outline' },
{ id: '4', title: 'Create Group', subtitle: 'Start a new circle', action: 'NAVIGATE_CREATE_GROUP', iconName: 'add-circle-outline' },
{ 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 payment history', action: 'TRANSACTION', iconName: 'receipt-outline' },
];
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: "2", title: "Your Requests", subtitle: "Track requests", action: "NAVIGATE_MANAGEMENT", tab: "Your Requests", iconName: "mail-open-outline", backOne: "#FFF4D2", backTwo: "#FFB344" },
{ id: "3", title: "Transactions", subtitle: "View history", action: "TRANSACTION", iconName: "receipt-outline", backOne: "#decbfcff", backTwo: "#B799FF" },
{ id: "4", title: "Create Group", subtitle: "Start new group", action: "NAVIGATE_CREATE_GROUP", iconName: "add-circle-outline", backOne: "#DFF6DD", backTwo: "#88D18A" },
{ 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: 'Status of your applications', action: 'NAVIGATE_MANAGEMENT', tab: 'Your Requests', iconName: 'mail-open-outline' },
{ id: '3', title: 'Transactions', subtitle: 'View payment history', action: 'TRANSACTION', iconName: 'receipt-outline' },
{ id: '4', title: 'Create Group', subtitle: 'Start a new circle', action: 'NAVIGATE_CREATE_GROUP', iconName: 'add-circle-outline' },
];
const Home: React.FC = () => {
const user = useSelector((state: any) => state.user);
const notifications = useSelector((state: any) => state.notifications.notifications);
const navigation = useNavigation<TabNavigationProp>();
const dispatch = useDispatch();
const insets = useSafeAreaInsets();
const theme = useTheme();
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 {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);
// API Hooks
const { data: profileData, refetch: refetchProfile, isFetching: isFetchingProfile } = useFetch<any>(PROFILE_URL);
const { data: dashboardData, refetch: refetchDashboard, isFetching: isFetchingDashboard } = useFetch<any>(DASHBOARD_URL);
const { data: statusData, refetch: refetchStatus, isFetching: isFetchingStatus } = useFetch<any>(USER_STATUS);
// Sync profile with Redux
React.useEffect(() => {
if (profileData?.status === 'success') {
dispatch(setUserData({ ...profileData.data, profileStatus: true }));
}
};
}, [profileData, dispatch]);
const getUserStatus = async () => {
const result = await apiCall(USER_STATUS, "GET") as any;
setStatusData(result.data);
}
const onRefresh = useCallback(() => {
refetchProfile();
refetchDashboard();
refetchStatus();
}, [refetchProfile, refetchDashboard, refetchStatus]);
const fetchUserProfile = async () => {
try {
const result = (await apiCall(PROFILE_URL, "GET")) as any;
console.log("profile data", result);
if (result.status === "success") {
setApiUser(result.data);
dispatch(setUserData({ ...result.data, profileStatus: true }));
}
} catch (error) {
console.error("❌ Error fetching profile:", error);
}
};
const actions = useMemo(() =>
user.role === 'ORGANIZER' ? organizerActions : userActions,
[user.role]);
const fetchData = useCallback(async () => {
await Promise.all([fetchUserProfile(), getDashboardData(), getUserStatus()]);
}, []);
const onRefresh = useCallback(async () => {
setRefreshing(true);
await fetchData();
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 renderGridItem: ListRenderItem<ActionItem> = ({ item }) => (
<AnimatedCard
key={item.id}
title={item.title}
subtitle={item.subtitle}
iconName={item.iconName}
backOne={item.backOne}
backTwo={item.backTwo}
onPress={() => handleActionPress(item)}
/>
);
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;
const getProfileImageUri = () => {
const blob = user.imageBlob || apiUser?.imageBlob;
return blob ? { uri: `data:image/jpeg;base64,${blob}` } : undefined;
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 }) => (
<View style={styles.cardContainer}>
<AnimatedCard
title={item.title}
subtitle={item.subtitle}
iconName={item.iconName}
onPress={() => handleActionPress(item)}
/>
</View>
);
return (
<View style={[ styles.container, {backgroundColor: theme.colors.background, paddingBottom: insets.bottom + responsiveHeight(10) }]}>
<StatusBar barStyle={theme.mode === "dark" ? "light-content" : "dark-content"}/>
<AppView style={styles.container}>
<FlatList
data={actions}
numColumns={2}
@@ -203,56 +120,50 @@ const Home: React.FC = () => {
ListHeaderComponent={
<>
<View style={styles.header}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 ,marginTop: responsiveHeight(2) }} >
<Text style={[styles.greeting, { color: theme.colors.textPrimary }]}>
Hello,
<View>
<Text style={[styles.greeting, { color: theme.colors.textSecondary }]}>Hello,</Text>
<Text style={[styles.userName, { color: theme.colors.textPrimary }]}>
{user.firstName || profileData?.data?.firstName || 'User'}
</Text>
<Text style={{ fontFamily:FontsFamily.nunitoExtraBold,color: theme.colors.textPrimary,fontSize:responsiveFontSize(2.5) }}>{user.firstName || apiUser?.firstName || "User"}</Text>
</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 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{unreadCount}</Text>
<View style={[styles.badge, { backgroundColor: theme.colors.primary }]}>
<Text style={[styles.badgeText, { color: theme.colors.onPrimary }]}>{unreadCount}</Text>
</View>
)}
<Ionicons name="notifications-outline" size={30} color={theme.colors.textPrimary} />
<LucideIcon name="notifications-outline" size={24} color={theme.colors.textPrimary} />
</TouchableOpacity>
</View>
<UserProfileStatus
profileImage={getProfileImageUri()}
statusData={statusData}
userDashboard={userDashboard}
profileImage={user.imageUrl ? { uri: user.imageUrl } : undefined}
statusData={statusData?.data}
userDashboard={dashboardData?.data}
/>
<View style={styles.actionHeader}>
<Text style={[styles.sectionTitle,{ color: theme.colors.textPrimary }]}>Quick Actions </Text>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Quick Actions</Text>
</View>
</>
}
contentContainerStyle={{ paddingBottom: 100 }}
contentContainerStyle={{
paddingBottom: insets.bottom + 86,
paddingHorizontal: 6,
}}
refreshControl={
<RefreshControl
refreshing={refreshing}
refreshing={isFetchingProfile || isFetchingDashboard || isFetchingStatus}
onRefresh={onRefresh}
colors={[colors.primary]}
tintColor={colors.primary}
tintColor={theme.colors.primary}
colors={[theme.colors.primary]}
/>
}
/>
{alertAction && (
<CustomAlert
visible={alertVisible}
title={alertAction.title}
buttons={alertAction.buttons}
message={alertAction.message}
alertType={"error"}
onClose={() => setAlertVisible(false)}
/>
)}
</View>
</AppView>
);
};
@@ -263,41 +174,56 @@ const styles = StyleSheet.create({
flex: 1,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 20,
paddingTop: 24,
paddingBottom: 16,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 10,
marginTop: 10,
marginBottom: 16,
},
greeting: { fontSize: 22,fontFamily: FontsFamily.nunitoLight },
sectionTitle: { fontSize: 20, fontWeight: "600", color: colors.textDark, flex: 1 ,fontFamily: FontsFamily.nunitoSemiBold},
actionHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginHorizontal: 20,
marginBottom: 10,
marginTop: 16
greeting: {
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoRegular,
},
payAllButton: {
paddingHorizontal: 14,
paddingVertical: 6,
backgroundColor: colors.primary,
borderRadius: 8,
userName: {
fontSize: responsiveFontSize(2.8),
fontFamily: FontsFamily.nunitoExtraBold,
marginTop: -4,
},
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: {
position: "absolute",
right: -6,
top: -4,
backgroundColor: colors.danger,
position: 'absolute',
right: 8,
top: 8,
borderRadius: 8,
width: 16,
minWidth: 16,
height: 16,
justifyContent: "center",
alignItems: "center",
justifyContent: 'center',
alignItems: 'center',
zIndex: 1,
paddingHorizontal: 4,
},
badgeText: { color: colors.white, fontSize: 10, fontWeight: "bold" },
});
badgeText: {
fontSize: 10,
fontFamily: FontsFamily.nunitoBold,
},
});
+258 -503
View File
@@ -1,23 +1,28 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useMemo } from 'react';
import {
Keyboard,
KeyboardAvoidingView,
View,
Text,
StyleSheet,
ScrollView,
FlatList,
TouchableOpacity,
RefreshControl,
Alert,
ActivityIndicator,
TouchableWithoutFeedback,
TextInput,
ToastAndroid
Alert,
ToastAndroid,
Platform,
} from 'react-native';
import { Ionicons } from '@react-native-vector-icons/ionicons';
import colors from '../../components/colors';
import LucideIcon from '../../components/common/LucideIcon';
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 * as keychain from 'react-native-keychain'
import { BASE_URL } from '../../utils/ApiUrls';
import FontsFamily from '../../constents/FontsFamily';
import SkeletonLoader from '../../components/common/SkeletonLoader';
// Define TypeScript interfaces
interface GroupRequest {
requestId: string;
groupName: string;
@@ -28,589 +33,339 @@ interface GroupRequest {
status: string;
}
interface ApiResponse {
message: string;
status: string;
statusCode: number;
data: GroupRequest[];
}
const RequestList: React.FC = () => {
const [requests, setRequests] = useState<GroupRequest[]>([]);
const [filteredRequests, setFilteredRequests] = useState<GroupRequest[]>([]);
const [refreshing, setRefreshing] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
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 RequestCard = ({
request,
onAccept,
onDecline,
isProcessing,
}: {
request: GroupRequest;
onAccept: () => void;
onDecline: () => void;
isProcessing: boolean;
}) => {
const theme = useTheme();
const getInitials = (name: string): string => {
return name
.split(' ')
.map(word => word.charAt(0))
.map((word) => word.charAt(0))
.join('')
.toUpperCase()
.substring(0, 2);
};
const clearSearch = (): void => {
setSearchQuery('');
return (
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<View style={styles.cardHeader}>
<View style={[styles.avatar, { backgroundColor: theme.colors.primary }]}>
<Text style={[styles.avatarText, { color: theme.colors.onPrimary }]}>
{getInitials(request.groupName)}
</Text>
</View>
<View style={styles.headerInfo}>
<Text style={[styles.groupName, { color: theme.colors.textPrimary }]}>{request.groupName}</Text>
<Text style={[styles.requestText, { color: theme.colors.textSecondary }]}>Circle Join Request</Text>
</View>
<View style={[styles.statusBadge, { backgroundColor: theme.colors.background }]}>
<Text style={[styles.statusText, { color: theme.colors.textPrimary }]}>{request.status}</Text>
</View>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.border }]} />
<View style={styles.userInfo}>
<View style={styles.infoRow}>
<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.actions}>
<TouchableOpacity
style={[styles.actionButton, styles.declineButton, { borderColor: theme.colors.error }]}
onPress={onDecline}
disabled={isProcessing}
>
<Text style={[styles.declineText, { color: theme.colors.error }]}>Decline</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.acceptButton, { backgroundColor: theme.colors.primary }]}
onPress={onAccept}
disabled={isProcessing}
>
<Text style={[styles.acceptText, { color: theme.colors.onPrimary }]}>
{isProcessing ? 'Processing' : 'Accept'}
</Text>
</TouchableOpacity>
</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);
},
}
);
},
},
]);
};
if (loading) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#3498db" />
<Text style={styles.loadingText}>Loading requests...</Text>
</View>
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 (error) {
if (isLoading) {
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 style={styles.loadingContainer}>
{[1, 2, 3].map((i) => (
<SkeletonLoader key={i} width="100%" height={160} style={{ marginBottom: 16, borderRadius: 16 }} />
))}
</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>
</>
<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>
)}
</View>
) : (
filteredRequests.map((request) => (
<View key={request.requestId} style={styles.requestCard}>
<View style={styles.cardHeader}>
<View style={styles.groupInfo}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>
{getInitials(request.groupName)}
</Text>
</View>
<View>
<Text style={styles.groupName}>{request.groupName}</Text>
<Text style={styles.requestText}>Group Join Request</Text>
</View>
</View>
<View style={[
styles.statusBadge,
request.status === 'PENDING' ? styles.pendingBadge : styles.otherBadge
]}>
<Text style={styles.statusText}>{request.status}</Text>
</View>
<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>
}
/>
<View style={styles.divider} />
<View style={styles.userInfo}>
<Ionicons name="person" size={16} color="#666" />
<Text style={styles.userName}>{request.userName}</Text>
</View>
<View style={styles.userInfo}>
<Ionicons name="call" size={16} color="#666" />
<Text style={styles.userDetail}>{request.mobile}</Text>
</View>
<View style={styles.actions}>
<TouchableOpacity
style={[styles.button, styles.declineButton]}
onPress={() => setShowDeclineModal({ modal: true, requestId: request.requestId })}
disabled={processingRequest === request.requestId}
>
{processingRequest === request.requestId ? (
<ActivityIndicator size="small" color="#e74c3c" />
) : (
<Text style={styles.declineButtonText}>Decline</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.acceptButton]}
onPress={() => handleAccept(request.requestId)}
disabled={processingRequest === request.requestId}
>
{processingRequest === request.requestId ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.acceptButtonText}>Accept</Text>
)}
</TouchableOpacity>
</View>
</View>
))
)}
</ScrollView>
{showDeclineModal.modal && (
<DeclineModal
visible={showDeclineModal.modal}
onClose={() => setShowDeclineModal({ modal: false, requestId: null })}
onSubmit={(reason) => handleDeclineConfirm(showDeclineModal.requestId, reason)}
/>
)}
</View>
<DeclineModal
visible={showDeclineModal.modal}
onClose={() => setShowDeclineModal({ modal: false, requestId: null })}
onSubmit={(reason) => handleDeclineConfirm(showDeclineModal.requestId, reason)}
/>
</View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
paddingBottom: '100%',
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f7fb',
padding: 20,
},
loadingText: {
fontSize: 16,
color: '#666',
marginTop: 12,
loadingContainer: {
padding: 16,
},
errorText: {
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: {
searchBar: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fff',
marginVertical: 16,
marginBottom: 8,
borderRadius: 10,
height: 48,
borderRadius: 12,
borderWidth: 1,
paddingHorizontal: 12,
height: 50,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
searchIcon: {
marginRight: 8,
marginHorizontal: 16,
marginVertical: 12,
},
searchInput: {
flex: 1,
height: '100%',
fontSize: 16,
color: '#2c3e50',
marginLeft: 8,
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
padding: 0,
},
clearButton: {
padding: 4,
},
clearSearchButton: {
marginTop: 16,
paddingVertical: 8,
listContent: {
paddingHorizontal: 16,
backgroundColor: '#ecf0f1',
borderRadius: 6,
paddingBottom: 24,
},
clearSearchText: {
color: '#7f8c8d',
fontWeight: '500',
},
emptyScrollView: {
flexGrow: 1,
justifyContent: 'center',
},
requestCard: {
backgroundColor: '#fff',
borderRadius: 12,
marginBottom: 16,
card: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
marginBottom: 16,
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
groupInfo: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
avatar: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: colors.pastelPink,
width: 48,
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
avatarText: {
color: '#fff',
fontSize: 16,
fontWeight: 'bold',
fontSize: 18,
fontFamily: FontsFamily.nunitoBold,
},
headerInfo: {
flex: 1,
},
groupName: {
fontSize: 18,
fontWeight: '600',
color: '#2c3e50',
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoSemiBold,
},
requestText: {
fontSize: 14,
color: '#7f8c8d',
fontSize: responsiveFontSize(1.3),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 2,
},
statusBadge: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 12,
},
pendingBadge: {
backgroundColor: '#fff4e6',
},
otherBadge: {
backgroundColor: '#e8f5e9',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
},
statusText: {
fontSize: 12,
fontWeight: '600',
fontSize: responsiveFontSize(1.2),
fontFamily: FontsFamily.nunitoBold,
},
divider: {
height: 1,
backgroundColor: '#ecf0f1',
marginVertical: 12,
},
userInfo: {
gap: 6,
},
infoRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
gap: 8,
},
userName: {
fontSize: 16,
color: '#2c3e50',
marginLeft: 8,
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoSemiBold,
},
userDetail: {
fontSize: 14,
color: '#7f8c8d',
marginLeft: 8,
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoRegular,
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 16,
gap: 12,
},
button: {
paddingVertical: 10,
paddingHorizontal: 20,
borderRadius: 8,
marginLeft: 12,
minWidth: 100,
actionButton: {
flex: 1,
height: 40,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
},
acceptButton: {
backgroundColor: '#2ecc71',
},
declineButton: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#e74c3c',
},
acceptButtonText: {
color: '#fff',
fontWeight: '600',
acceptButton: {},
acceptText: {
fontSize: responsiveFontSize(1.5),
fontFamily: FontsFamily.nunitoBold,
},
declineButtonText: {
color: '#e74c3c',
fontWeight: '600',
declineText: {
fontSize: responsiveFontSize(1.5),
fontFamily: FontsFamily.nunitoBold,
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
padding: 40,
marginTop: 64,
},
emptyStateText: {
fontSize: 18,
fontWeight: '600',
color: '#7f8c8d',
marginTop: 16,
marginBottom: 8,
},
emptyStateSubtext: {
fontSize: 14,
color: '#bdc3c7',
emptyText: {
marginTop: 12,
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoMedium,
textAlign: 'center',
},
});
export default RequestList;
export default RequestList;
+346 -514
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useState, useEffect } from "react";
import React, { useMemo, useState, useEffect } from 'react';
import {
StyleSheet,
Text,
@@ -9,55 +9,38 @@ import {
Image,
ScrollView,
ActivityIndicator,
} from "react-native";
import { useNavigation } from "@react-navigation/native";
import colors from "../../components/colors";
import LinearGradient from "react-native-linear-gradient";
import { Ionicons } from "@react-native-vector-icons/ionicons";
import { apiCall } from "../../utils/ApiHelper";
import { PAY_ALL_URL } from "../../utils/ApiUrls";
import {
responsiveHeight,
responsiveWidth,
responsiveFontSize
} from "react-native-responsive-dimensions";
import { useTheme } from "../../theme/ThemeProvider";
import FontsFamily from "../../constents/FontsFamily";
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import LucideIcon from '../../components/common/LucideIcon';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import { useTheme } from '../../hooks/ui/useTheme';
import { useCreate } from '../../hooks/core/useCreate';
import { PAY_ALL_URL } from '../../utils/ApiUrls';
import FontsFamily from '../../constents/FontsFamily';
type StatusData = {
export type StatusData = {
role?: string;
organizerStatus?: string;
profileStatus?: boolean;
};
type DashboardData = {
export type DashboardData = {
totalPaidAmountMonth?: number;
multiplePayAmount?: number;
completeStatusMyGroupCount?: number;
activeStatusMyGroupCount?: number;
createdGroupActiveCount?: number;
createdGroupCompleteCount?: number;
myCreatedGroups?: any[];
myJoinedGroups?: Array<any>;
requestList?: any[];
myRequests?: Array<{ status: string }>;
completeStatusMyGroupCount?: number;
dueGroupDetails?: Array<{
groupId: string;
groupName: string;
baseAmount: number;
lateFee: number;
waivedAmount: number;
totalDue: number;
dueDate: string;
isLate: boolean;
currentCycle: number;
lateFeeMode: string;
lateFeeValue: number;
hasPaid: boolean;
}>;
};
type UserProfileStatusProps = {
export type UserProfileStatusProps = {
profileImage?: { uri: string };
statusData: StatusData | null;
userDashboard: DashboardData | null;
@@ -68,53 +51,54 @@ const UserProfileStatus: React.FC<UserProfileStatusProps> = ({
statusData,
userDashboard,
}) => {
const navigation = useNavigation();
const [showModal, setShowModal] = useState(false);
const navigation = useNavigation<any>();
const theme = useTheme();
// Payment UI states
const [showModal, setShowModal] = useState(false);
const [showPayDialog, setShowPayDialog] = useState(false);
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(() => {
if (userDashboard?.dueGroupDetails) {
setSelectedGroupIds(userDashboard.dueGroupDetails.map((g) => g.groupId));
}
}, [userDashboard?.dueGroupDetails]);
// Show profile completion modal only if profile is incomplete
console.log("userDashboard : ", userDashboard)
useEffect(() => {
if (statusData && !statusData.profileStatus) setShowModal(true);
if (statusData && !statusData.profileStatus) {
setShowModal(true);
}
}, [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(
() => [
{ icon: <Ionicons name="person-outline" size={20} color={theme.colors.black} />, 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: <Ionicons name="book-outline" size={20} color={theme.colors.black} />, label: "Joined Groups", 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: <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]
);
() => [
{ icon: 'person-outline', label: 'Role', value: statusData?.role || '-' },
{ icon: 'pie-chart-outline', label: 'Active', value: userDashboard?.activeStatusMyGroupCount?.toString() || '0' },
{ icon: 'book-outline', label: 'Joined', value: userDashboard?.myJoinedGroups?.length?.toString() || '0' },
{ icon: 'people-outline', label: 'My Circles', value: userDashboard?.myCreatedGroups?.length?.toString() || '0' },
],
[userDashboard, statusData]
);
// Utility for info color
const getValueColor = (value: string) => {
if (value.includes("₹") || value === "Active" || value === "Yes ✅") return "#4CAF50";
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 dueGroups = useMemo(() => userDashboard?.dueGroupDetails || [], [userDashboard?.dueGroupDetails]);
const totalDue = useMemo(
() => dueGroups.reduce((sum, item) => sum + (item.totalDue || 0), 0),
[dueGroups]
);
const totalDueSelected = useMemo(
() =>
dueGroups
@@ -125,220 +109,149 @@ const UserProfileStatus: React.FC<UserProfileStatusProps> = ({
const handleToggleGroup = (groupId: string) => {
setSelectedGroupIds((prev) =>
prev.includes(groupId)
? prev.filter((id) => id !== groupId)
: [...prev, groupId]
prev.includes(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 = () => (
<View style={[ styles.dueBar, { borderTopColor: theme.colors.border, backgroundColor: theme.colors.gray }]}>
<Text style={[styles.dueBarText, { color: theme.colors.black }]}>
Total Due: <Text style={{ color: theme.colors.black, fontWeight: "bold" }}>₹{totalDue}</Text>
</Text>
<View style={[styles.dueBar, { backgroundColor: theme.colors.surface, borderTopColor: theme.colors.border }]}>
<View>
<Text style={[styles.dueBarLabel, { color: theme.colors.textSecondary }]}>Total Outstanding</Text>
<Text style={[styles.dueBarValue, { color: theme.colors.textPrimary }]}>₹{totalDue}</Text>
</View>
<TouchableOpacity
style={[styles.payAllButton, { backgroundColor: theme.colors.black }]}
style={[styles.payButton, { backgroundColor: theme.colors.primary }]}
onPress={() => setShowPayDialog(true)}
disabled={dueGroups.length === 0}
activeOpacity={0.8}
>
<Ionicons name="wallet-outline" size={20} color={theme.colors.white} />
<Text style={[styles.payAllButtonText, { color: theme.colors.white }]}>Pay All</Text>
<LucideIcon name="wallet-outline" size={18} color={theme.colors.onPrimary} />
<Text style={[styles.payButtonText, { color: theme.colors.onPrimary }]}>Pay Selected</Text>
</TouchableOpacity>
</View>
);
// Payment dialog modal
const renderPayDialog = () => (
<Modal visible={showPayDialog} transparent animationType="fade" onRequestClose={() => setShowPayDialog(false)}>
<View style={styles.payModalOverlay}>
<View
style={[styles.payModalBox, { backgroundColor: theme.colors.reverseBackground }]}
>
<View style={styles.payDialogHeader}>
<Ionicons name="card-outline" color={theme.colors.reverseText} size={22} />
<Text style={[styles.payDialogTitle, { color: theme.colors.reverseText }]}>Select Groups to Pay</Text>
<TouchableOpacity onPress={() => setShowPayDialog(false)}>
<Ionicons name="close-circle" color={theme.colors.reverseText} size={24} />
</TouchableOpacity>
</View>
<ScrollView
style={{ maxHeight: 200, width: "100%" }}
contentContainerStyle={{ paddingVertical: 8, paddingHorizontal: 4 }}
>
{dueGroups.map((group) => (
<TouchableOpacity
key={group.groupId}
style={[
styles.payGroupOption,
{ borderColor: theme.colors.background, borderWidth: 1 },
selectedGroupIds.includes(group.groupId) && {
...styles.payOptionSelected,
borderColor: theme.colors.reverseBackground,
},
]}
onPress={() => handleToggleGroup(group.groupId)}
activeOpacity={0.85}
>
<Ionicons
name={selectedGroupIds.includes(group.groupId) ? "checkbox" : "square-outline"}
size={22}
color={selectedGroupIds.includes(group.groupId) ? theme.colors.reverseText : theme.colors.reverseText}
/>
<View style={{ flex: 1 }}>
<Text style={[styles.payGroupName, {color: theme.colors.reverseText}]}>{group.groupName}</Text>
<Text style={[styles.payDueText, {color: theme.colors.reverseText}]}>
₹{group.baseAmount}
{group.lateFee > 0 ? ` (Late: ₹${group.lateFee})` : ""}
</Text>
</View>
{/* <Text style={[styles.payAmount, { color: "#4CAF50" }]}>₹{group.totalDue}</Text> */}
</TouchableOpacity>
))}
</ScrollView>
<View style={styles.payDialogFooter}>
<Text style={[styles.payDialogTotal, { color: theme.colors.reverseText }]}>
Total : <Text style={{ fontFamily: FontsFamily.nunitoBold, color: theme.colors.red }}>₹{totalDueSelected}</Text>
</Text>
<TouchableOpacity
style={[styles.payConfirmButton, { opacity: submitting ? 0.7 : 1 , backgroundColor: theme.colors.background}]}
onPress={handlePayAll}
disabled={submitting || selectedGroupIds.length === 0}
activeOpacity={0.8}
>
{submitting ? (
<ActivityIndicator color={theme.colors.textPrimary} />
) : (
<>
<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>
</View>
</View>
</View>
</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 style={[styles.card, { backgroundColor: theme.colors.background, borderColor: theme.colors.border }]}>
<View style={styles.headerRow}>
<View style={styles.profileSection}>
<TouchableOpacity
onPress={() => navigation.navigate('Profile')}
style={styles.avatarWrapper}
>
{profileImage ? (
<Image source={profileImage} style={styles.avatar} />
) : (
<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.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 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 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()}
<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)}>
<LucideIcon name="close" size={24} color={theme.colors.textPrimary} />
</TouchableOpacity>
</View>
<ScrollView style={styles.groupsList}>
{dueGroups.map((group) => (
<TouchableOpacity
key={group.groupId}
style={[
styles.groupItem,
{
backgroundColor: selectedGroupIds.includes(group.groupId) ? theme.colors.surface : 'transparent',
borderColor: theme.colors.border
}
]}
onPress={() => handleToggleGroup(group.groupId)}
>
<LucideIcon
name={selectedGroupIds.includes(group.groupId) ? 'checkbox' : 'square-outline'}
size={24}
color={selectedGroupIds.includes(group.groupId) ? theme.colors.primary : theme.colors.textMuted}
/>
<View style={styles.groupInfo}>
<Text style={[styles.groupName, { color: theme.colors.textPrimary }]}>{group.groupName}</Text>
<Text style={[styles.groupSub, { color: theme.colors.textSecondary }]}>
Amount: ₹{group.baseAmount} {group.lateFee > 0 ? `+ ₹${group.lateFee} fee` : ''}
</Text>
</View>
<Text style={[styles.groupAmount, { color: theme.colors.textPrimary }]}>₹{group.totalDue}</Text>
</TouchableOpacity>
))}
</ScrollView>
<View style={[styles.modalFooter, { borderTopColor: theme.colors.border }]}>
<View>
<Text style={[styles.totalLabel, { color: theme.colors.textSecondary }]}>Payable</Text>
<Text style={[styles.totalValue, { color: theme.colors.textPrimary }]}>₹{totalDueSelected}</Text>
</View>
<TouchableOpacity
style={[styles.confirmButton, { backgroundColor: theme.colors.primary, opacity: submitting ? 0.7 : 1 }]}
onPress={() => payAll({ groupIds: selectedGroupIds })}
disabled={submitting || selectedGroupIds.length === 0}
>
{submitting ? (
<ActivityIndicator color={theme.colors.onPrimary} />
) : (
<Text style={[styles.confirmText, { color: theme.colors.onPrimary }]}>Confirm Payment</Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
{showModal && (
<Modal visible transparent animationType="fade">
<View style={styles.modalOverlay}>
<View style={styles.modalBox}>
<Ionicons name="alert-circle" size={60} color={colors.primary} />
<Text style={styles.modalTitle}>Complete Your Profile</Text>
<Text style={styles.modalText}>
Your profile is incomplete. Update your details to unlock all features.
</Text>
<View style={styles.modalButtons}>
<TouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={() => {
setShowModal(false);
navigation.navigate("Profile" as never);
}}
>
<Ionicons name="person" size={18} color="#fff" />
<Text style={styles.buttonText}>Complete Profile</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.secondaryButton]}
onPress={() => setShowModal(false)}
>
<Ionicons name="time" size={18} color="#666" />
<Text style={[styles.buttonText, styles.secondaryText]}>Remind Later</Text>
</TouchableOpacity>
<View style={styles.alertOverlay}>
<View style={[styles.alertBox, { backgroundColor: theme.colors.background }]}>
<View style={[styles.alertIcon, { backgroundColor: theme.colors.surface }]}>
<LucideIcon name="person-outline" size={32} color={theme.colors.primary} />
</View>
<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>
<TouchableOpacity
style={[styles.alertButton, { backgroundColor: theme.colors.primary }]}
onPress={() => {
setShowModal(false);
navigation.navigate('Profile');
}}
>
<Text style={[styles.alertButtonText, { color: theme.colors.onPrimary }]}>Update Now</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.laterButton} onPress={() => setShowModal(false)}>
<Text style={[styles.laterText, { color: theme.colors.textMuted }]}>I'll do it later</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
@@ -351,307 +264,226 @@ export default UserProfileStatus;
const styles = StyleSheet.create({
container: {
marginHorizontal: 8,
marginBottom: 16,
paddingHorizontal: 10,
marginTop: 4,
},
card: {
borderRadius: 24,
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,
borderRadius: 16,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.2)',
// flex: 1,
minWidth: '45%',
marginTop: 21,
overflow: 'hidden',
},
infoCardHeader: {
headerRow: {
flexDirection: 'row',
padding: 14,
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: {
width: 90,
alignItems: 'center',
justifyContent: 'center',
marginRight: 14,
},
profileContainer: {
alignItems: 'center'
avatarWrapper: {
width: 72,
height: 72,
borderRadius: 36,
overflow: 'hidden',
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
borderWidth: 3,
borderColor: "#fff",
width: 72,
height: 72,
},
avatarPlaceholder: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: 'rgba(255,255,255,0.2)',
width: 72,
height: 72,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 3,
borderColor: "#fff",
},
profileBadge: {
position: 'absolute',
bottom: 0,
right: 0,
width: 26,
height: 26,
backgroundColor: '#fff',
borderRadius: 13,
justifyContent: 'center',
badgeLabel: {
flexDirection: 'row',
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: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 14,
paddingVertical: 10,
borderTopWidth: 1,
},
dueBarText: {
fontFamily: FontsFamily.nunitoExtraBold,
fontSize: responsiveWidth(4),
dueBarLabel: {
fontSize: responsiveFontSize(1.3),
fontFamily: FontsFamily.nunitoRegular,
},
payAllButton: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
dueBarValue: {
fontSize: responsiveFontSize(2),
fontFamily: FontsFamily.nunitoExtraBold,
},
payButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 10,
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: {
color: "#ffffffff",
fontWeight: "bold",
fontSize: responsiveWidth(3),
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoBold,
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.6)",
justifyContent: "center",
alignItems: "center",
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
},
modalBox: {
width: "85%",
backgroundColor: "#fff",
borderRadius: 24,
padding: 24,
alignItems: "center",
elevation: 12,
modalContent: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingBottom: 24,
maxHeight: '80%',
},
modalTitle: {
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: {
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
borderRadius: 12,
gap: 8,
justifyContent: 'space-between',
padding: 16,
borderBottomWidth: 1,
},
primaryButton: {
backgroundColor: colors.primary,
modalTitle: {
fontSize: responsiveFontSize(2.2),
fontFamily: FontsFamily.nunitoBold,
},
secondaryButton: {
backgroundColor: "#f5f5f5",
groupsList: {
padding: 10,
},
groupItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 14,
borderRadius: 16,
borderWidth: 1,
borderColor: '#e0e0e0',
marginBottom: 8,
},
buttonText: {
color: "#fff",
fontWeight: "600",
fontSize:responsiveWidth(2.5),
groupInfo: {
flex: 1,
marginLeft: 12,
},
secondaryText: {
color: "#666",
groupName: {
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,
},
});
+49 -45
View File
@@ -9,8 +9,9 @@ import {
UIManager,
Platform,
} 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 { useTheme } from "../../../hooks/ui/useTheme";
// Enable LayoutAnimation for Android
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
@@ -71,9 +72,18 @@ interface CycleDashboardData {
const formatDate = (dateStr: string | null): string => {
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}`;
}
const [year, month, day] = dateStr.split('-');
return `${day}-${month}-${year}`;
if (typeof dateStr === 'string' && dateStr.includes('-')) {
const [year, month, day] = dateStr.split('-');
return `${day}-${month}-${year}`;
}
return String(dateStr);
};
const formatCurrency = (amount: number): string => {
@@ -88,47 +98,56 @@ const formatCurrency = (amount: number): string => {
// --- Child Components ---
const CycleItem = ({ item, isExpanded, onToggle }: { item: Cycle, isExpanded: boolean, onToggle: () => void }) => {
const theme = useTheme();
return (
<View style={styles.cycleCard}>
<View style={[styles.cycleCard, { backgroundColor: theme.colors.card }]}>
<TouchableOpacity onPress={onToggle} style={styles.cycleHeader}>
<View>
<Text style={styles.cycleTitle}>Cycle {item.cycleNumber}</Text>
<Text style={styles.cycleDateRange}>
{formatDate(item.cycleStart)} - {formatDate(item.cycleEnd)}
<Text style={[styles.cycleTitle, { color: theme.colors.textPrimary }]}>Cycle {item.cycleNumber}</Text>
<Text style={[styles.cycleDateRange, { color: theme.colors.textMuted }]}>
{formatDate(item.cycleStart as any)} - {formatDate(item.cycleEnd as any)}
</Text>
</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>
{isExpanded && (
<View style={styles.cycleDetails}>
<View style={[styles.cycleDetails, { borderTopColor: theme.colors.borderLight }]}>
{/* Withdrawal Info */}
{item.withdrawal && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Withdrawal</Text>
<View style={styles.withdrawalCard}>
<Ionicons name="arrow-down-circle-outline" size={32} color={colors.Home} />
<Text style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}>Withdrawal</Text>
<View style={[styles.withdrawalCard, { backgroundColor: theme.colors.primarySoft }]}>
<LucideIcon name="arrow-down-circle-outline" size={32} color={theme.colors.primary} />
<View style={styles.withdrawalInfo}>
<Text style={styles.withdrawalUser}>{item.withdrawal.userName}</Text>
<Text style={styles.withdrawalAmount}>{formatCurrency(item.withdrawal.totalAmount)}</Text>
<Text style={[styles.withdrawalUser, { color: theme.colors.primary }]}>{item.withdrawal.userName}</Text>
<Text style={[styles.withdrawalAmount, { color: theme.colors.primary }]}>{formatCurrency(item.withdrawal.totalAmount)}</Text>
</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>
)}
{/* Contributions List */}
<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 => (
<View key={contribution.transactionId} style={[styles.contributionItem, { backgroundColor: contribution.status === 'SUCCESS' ? '#D1FAE5' : contribution.status === 'PENDING' ? '#FEF3C7' : '#FEE2E2' }]}>
<Ionicons name="person-circle-outline" size={28} color="#475569" />
<View key={contribution.transactionId} style={[styles.contributionItem, {
backgroundColor: contribution.status === 'SUCCESS' ? theme.colors.success + '15' : contribution.status === 'PENDING' ? theme.colors.warning + '15' : theme.colors.error + '15',
borderBottomColor: theme.colors.borderLight
}]}>
<LucideIcon name="person-circle-outline" size={28} color={theme.colors.textSecondary} />
<View style={{ flex: 1, marginLeft: 10 }}>
<Text style={styles.contributorName} numberOfLines={1} > {contribution.userName}</Text>
<Text style={styles.contributionDate}>{formatDate(contribution.paidDate)}</Text>
<Text style={{color: contribution.status === 'SUCCESS' ? 'green' : contribution.status === 'PENDING' ? 'orange' : 'red', fontSize:responsiveFontSize(1.5), textTransform:'capitalize'}}>{contribution.status}</Text>
<Text style={[styles.contributorName, { color: theme.colors.textPrimary }]} numberOfLines={1} > {contribution.userName}</Text>
<Text style={[styles.contributionDate, { color: theme.colors.textMuted }]}>{formatDate(contribution.paidDate as any)}</Text>
<Text style={{
color: contribution.status === 'SUCCESS' ? theme.colors.success : contribution.status === 'PENDING' ? theme.colors.warning : theme.colors.error,
fontSize: responsiveFontSize(1.5),
textTransform: 'capitalize'
}}>{contribution.status}</Text>
</View>
<Text style={styles.contributionAmount}>{formatCurrency(contribution.totalAmount)}</Text>
<Text style={[styles.contributionAmount, { color: theme.colors.textPrimary }]}>{formatCurrency(contribution.totalAmount)}</Text>
</View>
))}
</View>
@@ -143,6 +162,7 @@ const CycleItem = ({ item, isExpanded, onToggle }: { item: Cycle, isExpanded: bo
const CycleDashboard = ({ data }: { data: CycleDashboardData }) => {
const [expandedCycle, setExpandedCycle] = useState<number | null>(data.currentCycle);
const theme = useTheme();
const handleToggle = (cycleNumber: number) => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
@@ -152,19 +172,19 @@ const CycleDashboard = ({ data }: { data: CycleDashboardData }) => {
console.log("expanded cycle : ", data)
const renderHeader = () => (
<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.summaryBox}>
<Text style={styles.summaryLabel}>Total Collected</Text>
<Text style={styles.summaryValue}>{formatCurrency(data.totalCollected)}</Text>
<Text style={[styles.summaryLabel, { color: theme.colors.textMuted }]}>Total Collected</Text>
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>{formatCurrency(data.totalCollected)}</Text>
</View>
<View style={styles.summaryBox}>
<Text style={styles.summaryLabel}>Status</Text>
<Text style={[styles.summaryValue, { color: data.status === 'COMPLETED' ? colors.Settings : colors.Home }]}>{data.status}</Text>
<Text style={[styles.summaryLabel, { color: theme.colors.textMuted }]}>Status</Text>
<Text style={[styles.summaryValue, { color: data.status === 'COMPLETED' ? theme.colors.success : theme.colors.primary }]}>{data.status}</Text>
</View>
<View style={styles.summaryBox}>
<Text style={styles.summaryLabel}>Cycles</Text>
<Text style={styles.summaryValue}>{data.currentCycle}/{data.totalParticipants}</Text>
<Text style={[styles.summaryLabel, { color: theme.colors.textMuted }]}>Cycles</Text>
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>{data.currentCycle}/{data.totalParticipants}</Text>
</View>
</View>
</View>
@@ -204,7 +224,6 @@ const styles = StyleSheet.create({
groupName: {
fontSize: responsiveFontSize(2.4),
fontWeight: 'bold',
color: '#1e293b',
marginBottom: 15,
},
summaryRow: {
@@ -216,17 +235,14 @@ const styles = StyleSheet.create({
},
summaryLabel: {
fontSize: responsiveFontSize(1.4),
color: '#64748b',
marginBottom: 4,
},
summaryValue: {
fontSize: responsiveFontSize(1.6),
fontWeight: '600',
color: '#0f172a',
},
// Cycle Item Card
cycleCard: {
backgroundColor: '#fff',
borderRadius: 12,
marginBottom: 15,
elevation: 2,
@@ -245,18 +261,15 @@ const styles = StyleSheet.create({
cycleTitle: {
fontSize: responsiveFontSize(1.8),
fontWeight: '600',
color: '#1e293b',
},
cycleDateRange: {
fontSize: responsiveFontSize(1.3),
color: '#64748b',
marginTop: 2,
},
cycleDetails: {
paddingHorizontal: 16,
paddingBottom: 16,
borderTopWidth: 1,
borderTopColor: '#f1f5f9',
},
section: {
marginTop: 16,
@@ -264,14 +277,12 @@ const styles = StyleSheet.create({
sectionTitle: {
fontSize: responsiveFontSize(1.6),
fontWeight: '600',
color: '#334155',
marginBottom: 12,
},
// Withdrawal Card
withdrawalCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.pastelPink, // Example color
padding: 12,
borderRadius: 8,
},
@@ -282,15 +293,12 @@ const styles = StyleSheet.create({
withdrawalUser: {
fontSize: responsiveFontSize(1.5),
fontWeight: 'bold',
color: colors.Home
},
withdrawalAmount: {
fontSize: responsiveFontSize(1.4),
color: colors.Home
},
withdrawalDate: {
fontSize: responsiveFontSize(1.2),
color: '#475569'
},
// Contribution Item
contributionItem: {
@@ -301,20 +309,16 @@ const styles = StyleSheet.create({
borderRadius: 6,
marginBottom: 10,
borderBottomWidth: 1,
borderBottomColor: '#f1f5f9',
},
contributorName: {
fontSize: responsiveFontSize(1.5),
color: '#1e293b',
},
contributionDate: {
fontSize: responsiveFontSize(1.2),
color: '#64748b',
},
contributionAmount: {
fontSize: responsiveFontSize(1.5),
fontWeight: '500',
color: '#1e293b',
},
});
+432 -269
View File
@@ -1,77 +1,37 @@
import React, { useEffect, useState, useCallback } from "react";
import React, { useCallback, useEffect, useState } from "react";
import {
FlatList,
RefreshControl,
StyleSheet,
Text,
View,
TouchableOpacity,
ToastAndroid,
RefreshControl,
TouchableOpacity,
View,
ScrollView,
Modal,
Image,
Modal
} from "react-native";
import { useRoute } from "@react-navigation/native";
import AppView from "../../../components/AppView";
import colors from "../../../components/colors";
import Ionicons from '@react-native-vector-icons/ionicons';
import Header from "../../../components/common/Header";
import LinearGradient from "react-native-linear-gradient";
import { useTheme } from "../../../hooks/ui/useTheme";
import { useNavigation, useRoute } from "@react-navigation/native";
import { apiCall } from "../../../utils/ApiHelper";
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 CycleDashboard from "./CycleDashboard";
const CustomDropdown = ({ items, selectedValue, onValueChange, placeholder = "Select an item" }: any) => {
const [open, setOpen] = useState(false);
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>
);
};
import SkeletonLoader from "../../../components/common/SkeletonLoader";
import FontsFamily from "../../../constents/FontsFamily";
import AppView from "../../../components/AppView";
// import CustomDropdown from "../../../components/CustomDropdown";
const GroupDetails = () => {
const theme = useTheme();
const route = useRoute<any>();
const navigation = useNavigation<any>();
console.log(" route id : ", route)
const [memberData, setMemberData] = useState([]);
const [loading, setLoading] = useState(false);
const [openModal, setOpenModal] = useState(false);
@@ -82,7 +42,6 @@ const GroupDetails = () => {
const [cycleItems, setCycleItems] = useState([]);
const [selectedUser, setSelectedUser] = useState() as string | any;
const group = {
id: route.params.item?.id,
name: route.params.item?.groupName,
@@ -93,24 +52,18 @@ const GroupDetails = () => {
members: memberData,
};
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 };
console.log("paymentStatus", response.data)
const response = await apiCall(`${BASE_URL}payments/group/${group.id || route?.params?.groupId}/payment/paid`, 'GET') as any;
if (response.status === 'success') {
setSelectedUser(response.data);
}
}
const getCycleDashboard = async () => {
try {
const response = await apiCall(`${BASE_URL}payments/groups/${group.id || route?.params?.groupId}/cycle-dashboard`, "GET") as any;
const responseData = response.data;
setCycleDashboardData(responseData || []);
console.log("cycle data", responseData)
if (responseData?.cycles) {
const items = responseData.cycles.map((cycle: any) => ({
label: `Cycle ${cycle.cycleNumber}`,
@@ -126,18 +79,15 @@ const GroupDetails = () => {
}
};
const fetchGroupDetails = async () => {
try {
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);
} catch (error: any) {
ToastAndroid.show(error?.message || "Failed to fetch group details", ToastAndroid.SHORT);
}
};
const handleContribution = async () => {
try {
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 () => {
setLoading(true);
await Promise.all([
@@ -158,7 +107,6 @@ const GroupDetails = () => {
setLoading(false);
}, [group.id || route?.params?.groupId]);
useEffect(() => {
if (group.id || route?.params?.groupId) {
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 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 (
<AppView paddingHorizontal={0} style={{ flex: 1 }}>
{selectedUser?.firstName && (
<View style={styles.floatingWinnerContainer}>
<Text style={styles.floatingWinnerText}>
Winner: {`${selectedUser.firstName} ${selectedUser.lastName || ''}`}
</Text>
</View>
)}
<AppView paddingHorizontal={0} style={{ flex: 1, backgroundColor: theme.colors.background }}>
{/* Standard Header */}
<View style={[styles.header, { backgroundColor: theme.colors.background }]}>
<TouchableOpacity onPress={() => navigation.goBack()} style={[styles.iconButton, { backgroundColor: theme.colors.card }]}>
<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>
<View style={styles.iconButtonSpacer} />
</View>
<ScrollView
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}
showsVerticalScrollIndicator={false}
>
<Header title={group.name || route.name} />
<Text style={styles.description}>{group.description || route.name}</Text>
{loading ? renderSkeletons() : (
<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.statBox}>
<Text style={styles.statValue}>{memberData.length ?? 0}</Text>
<Text style={styles.statLabel}>Members</Text>
</View>
<View style={styles.statBox}>
<Text style={styles.statValue}>₹{cycleDashboardData?.contributionAmount || group.collected}</Text>
<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 style={styles.cardHeader}>
<View>
<Text style={[styles.cardLabel, { fontFamily: FontsFamily.nunitoSemiBold }]}>Total Balance</Text>
<Text style={[styles.cardBalance, { fontFamily: FontsFamily.nunitoExtraBold }]}>
₹{cycleDashboardData?.contributionAmount * memberData.length || group.goal}
</Text>
</View>
<View style={styles.iconContainer}>
<LucideIcon name="wallet-outline" size={24} color="#fff" />
</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>
{cycleItems.length > 0 && (
<CustomDropdown
items={cycleItems}
selectedValue={selectedCycleNumber}
onValueChange={setSelectedCycleNumber}
placeholder="Select a Cycle"
/>
)}
{/* 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>
)}
{selectedCycleData && cycleDashboardData && (
<CycleDashboard data={{ ...cycleDashboardData, cycles: [selectedCycleData] }} />
)}
<View style={styles.membersHeader}>
<Text style={styles.sectionTitle}>Members</Text>
<View style={styles.statusBadge}>
<Text style={styles.statusText}>{cycleDashboardData?.status || group?.status}</Text>
</View>
</View>
{memberData.map((member: any) => (
<View key={member.userId} style={styles.memberCard}>
<Ionicons name="person" size={40} color={colors.black} />
<View style={styles.memberInfo}>
<Text style={styles.memberName}>
{member.firstName} {member.role === "Organizer" ? "(Organizer)" : ""}
</Text>
{/* 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 && (
<View style={styles.sectionContainer}>
<Text style={[styles.sectionHeader, { color: theme.colors.textPrimary, fontFamily: FontsFamily.nunitoBold }]}>Cycle Statement</Text>
{/* <CustomDropdown
items={cycleItems}
selectedValue={selectedCycleNumber}
onValueChange={setSelectedCycleNumber}
placeholder="Select Statement Cycle"
/> */}
{selectedCycleData && cycleDashboardData && (
<View style={{ marginTop: 12 }}>
<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.contactsContainer, { backgroundColor: theme.colors.card }]}>
{memberData.map((member: any, index) => (
<View key={member.userId} style={[
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 style={styles.contactInfo}>
<Text style={[styles.contactName, { color: theme.colors.textPrimary, fontFamily: FontsFamily.nunitoBold }]}>
{member.firstName} {member.lastName}
</Text>
<Text style={[styles.contactRole, { color: theme.colors.textMuted, fontFamily: FontsFamily.nunitoRegular }]}>
{member.role === "Organizer" ? "Group Admin" : "Member"}
</Text>
</View>
<TouchableOpacity style={[styles.contactAction, { backgroundColor: theme.colors.background }]}>
<LucideIcon name="chatbubble-ellipses-outline" size={18} color={theme.colors.textMuted} />
</TouchableOpacity>
</View>
))}
</View>
</View>
{/* Contribute Button */}
{(cycleDashboardData?.status === 'ACTIVE' || cycleDashboardData?.status === 'DELAY') && !payable?.hasPaid && (
<TouchableOpacity
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>
)}
</View>
))}
{(cycleDashboardData?.status === 'ACTIVE' || cycleDashboardData?.status === 'DELAY') && !payable?.hasPaid && (
<TouchableOpacity onPress={() => setOpenModal(true)} style={styles.button}>
<Text style={styles.buttonText}>Contribute</Text>
</TouchableOpacity>
)}
</ScrollView>
{openModal && (
<ContributionScreen
data={payable}
visible={openModal}
onClose={() => setOpenModal(false)}
onConfirm={() => {
confirmPayment();
setOpenModal(false);
navigation.navigate('PaymentMode' as any, {
groupId: group.id || route?.params?.groupId,
});
}}
/>
)}
@@ -285,155 +341,262 @@ const GroupDetails = () => {
);
};
export default GroupDetails;
const styles = StyleSheet.create({
dropdownWrapper: {
marginHorizontal: 15,
marginBottom: 20,
},
dropdownHeader: {
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
alignItems: 'center',
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: {
paddingHorizontal: 16,
paddingVertical: 12,
paddingHorizontal: 10,
},
selectedItem: {
backgroundColor: colors.pastelOrange,
borderRadius: 6,
},
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,
iconButton: {
width: 40,
height: 40,
borderRadius: 20,
paddingHorizontal: 12,
paddingVertical: 6,
elevation: 2,
},
statusText: {
fontSize: 14,
fontWeight: '600',
color: colors.black,
},
memberCard: {
flexDirection: "row",
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#fff',
padding: 12,
borderRadius: 12,
marginBottom: 10,
elevation: 2,
marginHorizontal: 15,
},
memberInfo: {
marginLeft: 10,
iconButtonSpacer: {
width: 40,
},
memberName: {
fontSize: 15,
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",
headerTitle: {
fontSize: responsiveFontSize(2),
},
scrollContent: {
paddingBottom: 50,
paddingBottom: 100,
},
floatingWinnerContainer: {
position: 'absolute',
bottom: 80,
alignSelf: 'center',
backgroundColor: colors.pastelBlue,
paddingHorizontal: 15,
paddingVertical: 8,
borderRadius: 20,
elevation: 5,
contentPadding: {
paddingHorizontal: 16,
paddingTop: 10,
},
skeletonContainer: {
paddingHorizontal: 16,
marginTop: 20,
},
// 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,
},
floatingWinnerText: {
color: colors.black,
fontWeight: 'bold',
fontSize: 16,
iconContainer: {
width: 48,
height: 48,
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),
},
});
+1 -1
View File
@@ -11,4 +11,4 @@ const UserHome = () => {
export default UserHome
const styles = StyleSheet.create({})
const styles = StyleSheet.create({})
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -15,4 +15,4 @@ export default function ManageJoinedGroup({ route }: ManageJoinedGroupProps) {
)
}
const styles = StyleSheet.create({})
const styles = StyleSheet.create({})
+347 -125
View File
@@ -1,178 +1,400 @@
// src/screens/ManagementScreen.tsx
import React, { useState, useCallback, FC, useEffect } from "react";
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
ActivityIndicator,
Alert,
ScrollView,
ToastAndroid,
} from "react-native";
import { useNavigation, useFocusEffect, useRoute, RouteProp } from "@react-navigation/native";
import { StackNavigationProp } from "@react-navigation/stack";
import { APPROVE_REQUEST_URL, DECLINE_REQUEST_URL, GET_JOINED_GROUP_URL, MY_GROUPS_URL, REQUESTS_URL, USER_REQUEST } from "../../utils/ApiUrls";
import colors from "../../components/colors";
import AppView from "../../components/AppView";
import { apiCall } from "../../utils/ApiHelper";
import { MyGroup, JoinedGroup, JoinRequest, ApiResponse, UserJoinRequest } from '../../types';
import { MyGroupCard } from "../../components/MyGroupCard";
import { JoinedGroupCard } from "../../components/JoinedGroupCard";
import { RequestCard } from "../../components/RequestCard";
import { useSelector } from "react-redux";
import { UserRequested } from "../../components/UserRequested";
import { RootTabParamList } from "../../navigation/BottomTabs";
import { RootStackParamList } from "../../navigation/AppNavigator";
RefreshControl,
} from 'react-native';
import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native';
import { useSelector } from 'react-redux';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import LucideIcon from '../../components/common/LucideIcon';
import {
APPROVE_REQUEST_URL,
DECLINE_REQUEST_URL,
GET_JOINED_GROUP_URL,
MY_GROUPS_URL,
REQUESTS_URL,
USER_REQUEST
} from '../../utils/ApiUrls';
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 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 navigation = useNavigation<GroupManagementNavProp>();
const navigation = useNavigation<any>();
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(() => {
const passedTab = route.params?.actionTab;
if (passedTab) {
const availableTabs = user.role === 'ORGANIZER' ? ["My Groups", "Joined Groups", "Requests"] : ["Joined Groups", "Your Requests"];
if (availableTabs.includes(passedTab)) {
setActiveTab(passedTab);
}
}
}, [route.params?.actionTab, user.role]);
if (!route.params?.actionTab) return;
const routeTab = route.params.actionTab as ActiveTab;
setActiveTab(routeTab);
}, [route.params?.actionTab]);
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);
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);
}
const { mutate: updateRequest } = useUpdate<any, any>('');
}, [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(
useCallback(() => {
fetchData();
}, [fetchData])
onRefresh();
}, [])
);
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) => {
try {
// No need to set loading here if the whole screen is already loading
const res = await apiCall(url, "PUT", {}) as ApiResponse<null> | null;
if (res && res.statusCode === 200) {
ToastAndroid.show("Success", ToastAndroid.SHORT);
fetchData(); // Refresh data after action
} else {
throw new Error(res?.message || failureMessage);
}
} catch (err: any) {
console.error("Action Error:", err);
ToastAndroid.show(err.message || failureMessage, ToastAndroid.SHORT);
}
const handleDecline = (req: JoinRequest) => {
updateRequest({ resource: `${DECLINE_REQUEST_URL}${req.requestId}/reject?reason=/`, payload: {} } as any, {
onSuccess: () => {
ToastAndroid.show('Request declined', ToastAndroid.SHORT);
refetchRequests();
},
onError: (err) => ToastAndroid.show(err.message || 'Failed to decline', 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 renderContent = () => {
if (error) return <Text style={styles.emptyText}>{error}</Text>;
if (loading && myGroups.length === 0 && joinedGroups.length === 0 && requests.length === 0) {
return <ActivityIndicator size="large" color={colors.primary} style={styles.loader} />;
}
const renderSkeletons = () => (
<View style={styles.skeletonContainer}>
{[1, 2, 3].map(i => (
<SkeletonLoader key={i} width="100%" height={120} style={{ marginBottom: 16, borderRadius: 16 }} />
))}
</View>
);
const getFilteredData = () => {
switch (activeTab) {
case "My Groups":
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 <FlatList data={joinedGroups} renderItem={({ item }) => <JoinedGroupCard item={item} onViewPress={() => {
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 }} />;
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;
case 'My Groups': return myGroups;
case 'Joined Groups': return joinedGroups;
case 'Requests': return incomingRequests;
case 'Your Requests': return myRequests;
default: return [];
}
};
const tabs: ActiveTab[] = user.role === 'ORGANIZER' ? ["My Groups", "Joined Groups", "Requests"] : ["Joined Groups", "Your Requests"];
const isLoading = myLoading || joinedLoading || requestsLoading || userReqLoading;
return (
<AppView style={styles.container} paddingHorizontal={0}>
<AppView paddingHorizontal={0}>
<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 style={styles.segmentedControl}>
<View style={[styles.tabContainer, { backgroundColor: theme.colors.borderLight }]}>
{tabs.map((tab) => (
<TouchableOpacity key={tab} style={[styles.segmentButton, activeTab === tab && styles.segmentActive]} onPress={() => setActiveTab(tab)}>
<Text style={[styles.segmentText, activeTab === tab && styles.segmentTextActive]}>{tab}</Text>
<TouchableOpacity
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>
))}
</View>
<View style={styles.contentArea}>
{renderContent()}
</View>
{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>
)}
{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>
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f5f6fa' },
header: { paddingHorizontal: 20, paddingBottom: 15, paddingTop: 10 },
title: { fontSize: 22, fontWeight: "600"},
segmentedControl: { flexDirection: 'row',borderRadius: 10,marginBottom: 20, padding: 3 },
segmentButton: { flex: 1, paddingVertical: 12, alignItems: 'center', borderRadius: 8 },
segmentActive: { backgroundColor: colors.pastelOrange, elevation: 5,borderRadius:15, shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 5 },
segmentText: { fontSize: 14, fontWeight: '600', color: '#6c757d' },
segmentTextActive: { color: colors.textDark },
contentArea: { flex: 1},
loader: { marginTop: 50 },
emptyText: { textAlign: 'center', marginTop: 60, fontSize: 16, color: '#6c757d' },
header: {
paddingHorizontal: 14,
paddingTop: 10,
paddingBottom: 12,
zIndex: 10,
},
title: {
fontSize: responsiveFontSize(3.2),
fontFamily: FontsFamily.nunitoBold,
},
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;
@@ -2,42 +2,56 @@ import React from 'react';
import { createBottomTabNavigator, BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
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 { RootStackParamList } from '../../navigation/AppNavigator'; // Adjust path
import colors from '../../components/colors';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { useTheme } from '../../hooks/ui/useTheme';
import FontsFamily from '../../constents/FontsFamily';
// --- Screen Imports ---
import EditGroupScreen from './EditGroupScreen';
import Payments from './Payments';
import Member from './Member';
// --- Type Definitions ---
type Props = NativeStackScreenProps<RootStackParamList, 'ManagementTabs'>;
const Tab = createBottomTabNavigator();
// --- Icon Mapping ---
const iconMap: { [key: string]: string } = {
EditGroup: 'trail-sign-outline',
Payments: 'trending-up',
const iconMap: { [key: string]: any } = {
EditGroup: 'settings-outline',
Payments: 'card-outline',
Member: 'people-outline',
};
// --- Custom Floating Tab Bar Component ---
const FloatingTabBar = ({ state, descriptors, navigation }: BottomTabBarProps) => {
const insets = useSafeAreaInsets();
const theme = useTheme();
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) => {
const { options } = descriptors[route.key];
const label = options.tabBarLabel?.toString() ?? options.title ?? route.name;
const isFocused = state.index === index;
const iconName = iconMap[route.name] || 'help-circle-outline';
const onPress = () => {
@@ -52,33 +66,38 @@ const FloatingTabBar = ({ state, descriptors, navigation }: BottomTabBarProps) =
}
};
const onLongPress = () => {
navigation.emit({
type: 'tabLongPress',
target: route.key,
});
};
return (
<TouchableOpacity
key={route.key}
accessibilityRole="button"
accessibilityState={isFocused ? { selected: true } : {}}
accessibilityLabel={options.tabBarAccessibilityLabel}
testID={options.tabBarButtonTestID}
onPress={onPress}
onLongPress={onLongPress}
// The style for the tab item itself no longer needs elevation
style={[styles.tabItem, { backgroundColor: isFocused ? colors.pastelOrange : 'transparent' }]}
style={[
styles.tabItem,
{
backgroundColor: isFocused ? theme.colors.primary : 'transparent',
borderRadius: theme.borderRadius.full,
}
]}
>
<Ionicons
<LucideIcon
name={iconName}
size={24}
color={isFocused ? colors.black : '#6B7280'}
size={20}
color={isFocused ? theme.colors.onPrimary : theme.colors.textMuted}
/>
<Text style={[styles.tabLabel, { color: isFocused ? colors.black : '#6B7280' }]}>
{label}
</Text>
{isFocused && (
<Text
numberOfLines={1}
style={[
styles.tabLabel,
{
color: theme.colors.onPrimary,
}
]}
>
{label}
</Text>
)}
</TouchableOpacity>
);
})}
@@ -86,7 +105,6 @@ const FloatingTabBar = ({ state, descriptors, navigation }: BottomTabBarProps) =
);
};
// --- Main Tab Navigator Component ---
const ManagementTabNavigation: React.FC<Props> = ({ route }) => {
const { groupId } = route.params;
@@ -126,50 +144,29 @@ const ManagementTabNavigation: React.FC<Props> = ({ route }) => {
);
};
// --- Styles ---
const styles = StyleSheet.create({
tabBarContainer: {
flexDirection: 'row',
position: 'absolute',
left: 20, // Adjusted for better visual balance
right: 20, // Adjusted for better visual balance
backgroundColor: 'white',
borderRadius: 30,
paddingVertical: 8,
paddingHorizontal: 8, // Added padding
left: 16,
right: 16,
paddingVertical: 6,
paddingHorizontal: 6,
alignItems: 'center',
justifyContent: 'space-around',
// iOS Shadow
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.1,
shadowRadius: 10,
// Android Shadow
elevation: 8,
justifyContent: 'space-between',
},
tabItem: {
flex: 1, // Allow items to fill the space
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10, // Increased padding for a better touch area
borderRadius: 25,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.1,
shadowRadius: 10, // This is for the background color highlight
paddingVertical: 9,
gap: 8,
marginHorizontal: 4,
},
// The 'activeTab' style is no longer needed as the logic is handled inline
tabLabel: {
fontSize: 12,
marginTop: 4,
fontWeight: '600',
fontFamily: FontsFamily.nunitoBold,
},
});
+126 -209
View File
@@ -1,23 +1,20 @@
import React, { useEffect, useState, useCallback } from 'react';
import React from 'react';
import {
StyleSheet,
Text,
View,
FlatList,
ActivityIndicator,
SafeAreaView,
Image,
TouchableOpacity,
Alert,
RefreshControl,
} from 'react-native';
import { Ionicons } from "@react-native-vector-icons/ionicons"; // Assuming you use Expo or have vector icons installed
import { apiCall } from '../../utils/ApiHelper'; // Adjust path to your apiCall utility
import LucideIcon from '../../components/common/LucideIcon';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import AppView from '../../components/AppView';
import colors from '../../components/colors';
import { RouteProp, useRoute } from '@react-navigation/native';
import { useTheme } from '../../hooks/ui/useTheme';
import { useFetch } from '../../hooks/core/useFetch';
import { BASE_URL } from '../../utils/ApiUrls';
import FontsFamily from '../../constents/FontsFamily';
import SkeletonLoader from '../../components/common/SkeletonLoader';
// --- Type Definitions ---
interface Member {
id: string;
userId: number;
@@ -31,270 +28,190 @@ interface Member {
role: 'USER' | 'ORGANIZER';
}
interface ApiResponse {
message: string;
status: string;
statusCode: number;
data: Member[];
}
type RootStackParamList = {
Member: { groupId: string }; // or number, depending on your data type
};
const MemberScreen = ({ route }: any) => {
const theme = useTheme();
const { groupId } = route?.params || {};
type MemberScreenRouteProp = RouteProp<RootStackParamList, 'Member'>;
const { data: membersRes, isLoading, refetch } = useFetch<any>(
`${BASE_URL}fund/group/all/member?groupId=${groupId}`,
{ enabled: !!groupId }
);
interface Props {
route: MemberScreenRouteProp;
}
const members: Member[] = membersRes?.data || [];
// --- 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}`,
'GET'
);
if (response && response.status === 'success') {
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 fullName = `${item.firstName} ${item.lastName || ''}`.trim();
const initial = item.firstName ? item.firstName.charAt(0).toUpperCase() : '?';
const isOrganizer = item.role === 'ORGANIZER';
return (
<TouchableOpacity style={styles.card}>
<View style={styles.avatarPlaceholder}>
<Text style={styles.avatarText}>{initial}</Text>
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<View style={[styles.avatarPlaceholder, { backgroundColor: theme.colors.background }]}>
<Text style={[styles.avatarText, { color: theme.colors.textPrimary }]}>{initial}</Text>
</View>
<View style={styles.infoContainer}>
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
<Text style={styles.name}>{fullName}</Text>
<View style={styles.nameRow}>
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{fullName}</Text>
{isOrganizer && (
<View style={styles.organizerIcon}>
<Text>Orginzer</Text>
<View style={[styles.badge, { backgroundColor: theme.colors.primary }]}>
<Text style={[styles.badgeText, { color: theme.colors.onPrimary }]}>Organizer</Text>
</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>
</TouchableOpacity>
</View>
);
};
const renderContent = () => {
if (loading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#4F46E5" />
<Text style={styles.loadingText}>Loading Members...</Text>
</View>
);
}
if (error) {
return (
<View style={styles.centered}>
<Text style={styles.errorText}>no members found</Text>
</View>
);
}
if (members.length === 0) {
return (
<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
data={members}
renderItem={renderItem}
keyExtractor={item => item.id}
contentContainerStyle={styles.listContainer}
showsVerticalScrollIndicator={false}
/>
);
};
const renderSkeletons = () => (
<View style={styles.skeletonList}>
{[1, 2, 3, 4, 5].map(i => (
<SkeletonLoader key={i} width="100%" height={80} style={{ marginBottom: 12, borderRadius: 12 }} />
))}
</View>
);
return (
<AppView>
<AppView paddingHorizontal={0}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Group Members</Text>
<Text style={styles.headerSubtitle}>{members.length} member(s)</Text>
<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>
{renderContent()}
{isLoading ? (
renderSkeletons()
) : (
<FlatList
data={members}
renderItem={renderItem}
keyExtractor={item => item.id}
contentContainerStyle={styles.listContainer}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={refetch} colors={[theme.colors.primary]} />
}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<LucideIcon name="people-outline" size={64} color={theme.colors.textMuted} />
<Text style={[styles.emptyTitle, { color: theme.colors.textPrimary }]}>No Members Yet</Text>
<Text style={[styles.emptySubtitle, { color: theme.colors.textSecondary }]}>
Invite people to join your group to get started.
</Text>
</View>
}
/>
)}
</AppView>
);
};
// --- Styles ---
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F3F4F6',
},
header: {
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 12,
paddingHorizontal: 14,
paddingVertical: 10,
},
headerTitle: {
fontSize: 28,
fontWeight: 'bold',
color: '#111827',
fontSize: responsiveFontSize(2.8),
fontFamily: FontsFamily.nunitoBold,
},
headerSubtitle: {
fontSize: 16,
color: '#6B7280',
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 4,
},
listContainer: {
padding: 16,
paddingHorizontal: 14,
paddingBottom: 86,
},
skeletonList: {
paddingHorizontal: 14,
},
card: {
flexDirection: 'row',
backgroundColor: '#FFFFFF',
padding: 16,
borderRadius: 12,
marginBottom: 16,
borderRadius: 16,
marginBottom: 12,
alignItems: 'center',
shadowColor: '#374151',
shadowOpacity: 0.08,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
elevation: 3,
borderWidth: 1,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 8,
},
avatarPlaceholder: {
width: 52,
height: 52,
borderRadius: 26,
backgroundColor: '#D1D5DB',
width: 48,
height: 48,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
marginRight: 16,
},
avatarText: {
color: '#4B5563',
fontSize: 24,
fontWeight: 'bold',
},
organizerIcon: {
borderRadius: 10,
padding: 2,
borderWidth: 2,
borderColor: colors.pastelOrange
fontSize: responsiveFontSize(2),
fontFamily: FontsFamily.nunitoBold,
},
infoContainer: {
flex: 1,
},
nameRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
name: {
fontSize: 18,
fontWeight: '700',
color: '#111827',
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoBold,
},
badge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
},
badgeText: {
fontSize: responsiveFontSize(1),
fontFamily: FontsFamily.nunitoBold,
textTransform: 'uppercase',
},
username: {
fontSize: 14,
color: '#6B7280',
marginBottom: 8,
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 2,
},
contactRow: {
contactInfo: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
marginTop: 6,
gap: 4,
},
contactText: {
marginLeft: 8,
fontSize: 14,
color: '#374151',
fontSize: responsiveFontSize(1.3),
fontFamily: FontsFamily.nunitoMedium,
},
menuButton: {
padding: 8,
marginLeft: 8,
},
centered: {
flex: 1,
justifyContent: 'center',
emptyContainer: {
alignItems: 'center',
padding: 20,
},
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',
marginTop: 80,
paddingHorizontal: 40,
},
emptyTitle: {
fontSize: 22,
fontWeight: 'bold',
color: '#374151',
marginBottom: 8,
fontSize: responsiveFontSize(2.2),
fontFamily: FontsFamily.nunitoBold,
marginTop: 20,
},
emptyText: {
fontSize: 16,
color: '#6B7280',
emptySubtitle: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
textAlign: 'center',
}
marginTop: 8,
lineHeight: 22,
},
});
export default MemberScreen;
+351 -235
View File
@@ -1,22 +1,28 @@
import React, { useEffect, useState, useCallback } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, ScrollView, RefreshControl, Alert, TouchableOpacity, Modal, TextInput, ToastAndroid } from 'react-native';
import { useRoute, RouteProp } from '@react-navigation/native';
import { Ionicons } from "@react-native-vector-icons/ionicons";
import colors from '../../components/colors';
import AppView from '../../components/AppView';
import { apiCall } from '../../utils/ApiHelper';
import { BASE_URL } from '../../utils/ApiUrls';
import React, { useState, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
RefreshControl,
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 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 ---
interface ApiResponse<T> {
status: 'success' | 'error';
data: T;
message?: string;
statusCode: number;
}
interface Participant {
userId: string;
firstName: string;
@@ -24,13 +30,12 @@ interface Participant {
mobile: string;
contributionAmount: number;
paidAmount: number;
payAmount?: number;
status: 'SUCCESS' | 'PENDING' | 'FAILED' | 'UNPAID';
lateFee: number;
lateFeeWaived: boolean;
totalPaidAmount: string;
expectedAmount: number;
waivedAmount:number
waivedAmount: number;
}
interface PaymentDetails {
@@ -44,242 +49,262 @@ interface PaymentDetails {
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 = () => {
// --- HOOKS ---
// All hooks are called at the top level, which is correct.
const route = useRoute<PaymentsRouteProp>();
const { groupId } = route.params;
const theme = useTheme();
const route = useRoute<any>();
const { groupId } = route?.params || {};
const [paymentDetails, setPaymentDetails] = useState<PaymentDetails | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [isGroupActive, setIsGroupActive] = useState(true);
const [isModalVisible, setModalVisible] = useState(false);
const [isWaiverModalVisible, setWaiverModalVisible] = useState(false);
const [currentParticipant, setCurrentParticipant] = useState<Participant | null>(null);
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 ---
// These are regular functions and do not call hooks inside them, which is correct.
const getCycleData = async () => {
try {
const response = await apiCall(`${BASE_URL}/payments/groups/${groupId}/cycle-dashboard`, 'GET') as ApiResponse<any>;
if (response && response.statusCode === 200) {
setCycleData(response.data);
}
} catch (error: any) {
console.error("Failed to fetch cycle data:", error.message);
// Fetch Cycle Dashboard
const {
isLoading: isCycleLoading,
refetch: refetchCycle,
} = useFetch<any>(`${BASE_URL}payments/groups/${groupId}/cycle-dashboard`, {
enabled: !!groupId,
});
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);
},
}
};
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(() => {
setRefreshing(true);
getCycleData();
fetchPaymentDetails();
}, [fetchPaymentDetails]);
Promise.all([
refetchPayments(),
refetchCycle(),
]).finally(() => {
setRefreshing(false);
});
}, [refetchPayments, refetchCycle]);
const openWaiverDialog = (participant: Participant) => {
setCurrentParticipant(participant);
setWaiverAmount(participant.lateFee.toString());
setModalVisible(true);
setWaiverModalVisible(true);
};
const handleWaiveFee = async () => {
console.log("first approch ")
// if (!currentParticipant || !paymentDetails) console.log("second approch ") ;
const handleWaiveFee = () => {
const amountToWaive = parseFloat(waiverAmount);
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;
}
if (amountToWaive > currentParticipant.lateFee) {
ToastAndroid.show(`Waiver cannot exceed the current late fee of ₹${currentParticipant.lateFee}.`, ToastAndroid.SHORT);
if (currentParticipant && amountToWaive > currentParticipant.lateFee) {
ToastAndroid.show(`Waiver cannot exceed ₹${currentParticipant.lateFee}`, ToastAndroid.SHORT);
return;
}
try {
const url = `${BASE_URL}payments/groups/${groupId}/cycles/${paymentDetails.currentCycle}/waive/${currentParticipant.userId}?amountToWaive=${amountToWaive}`;
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);
}
const url = `${BASE_URL}payments/groups/${groupId}/cycles/${paymentDetails?.currentCycle}/waive/${currentParticipant?.userId}?amountToWaive=${amountToWaive}`;
waiveFee({ resource: url, payload: {} } as any);
};
// --- RENDER LOGIC ---
const renderParticipant = (participant: Participant) => {
const isPending = participant.status === 'UNPAID';
const canWaive = participant.lateFee > 0 && isPending
const isUnpaid = participant.status === 'UNPAID';
const canWaive = participant.lateFee > 0 && isUnpaid;
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}>
<Text style={styles.participantName}>{participant.firstName} {participant.lastName}</Text>
<Text style={styles.participantMobile}>{participant.mobile}</Text>
<Text style={[styles.participantName, { color: theme.colors.textPrimary }]}>
{participant.firstName} {participant.lastName}
</Text>
<Text style={[styles.participantMobile, { color: theme.colors.textSecondary }]}>{participant.mobile}</Text>
</View>
<View style={styles.paymentStatus}>
<Text style={[styles.statusText, {
color: participant.status === "PENDING"
? colors.warning
: participant.status === "FAILED"
? 'red'
: participant.status === "UNPAID" ? '#f68a89' : colors.success
}]}>
<Text
style={[
styles.statusText,
{
color:
participant.status === 'SUCCESS'
? theme.colors.success
: participant.status === 'UNPAID'
? theme.colors.error
: theme.colors.warning,
},
]}
>
{participant.status}
</Text>
{participant.status === "UNPAID" && <Text style={styles.amountText}>
{`₹${participant?.expectedAmount} `}
</Text>}
{participant.status === "SUCCESS" && <Text style={styles.amountText}>
{`₹${participant?.totalPaidAmount}`}
</Text>}
{/* <Text style={styles.amountText}>
{isPending ? `₹${participant.payAmount || 0}` : `₹${participant.totalPaidAmount}`}
</Text> */}
<Text style={[styles.amountText, { color: theme.colors.textPrimary }]}>
₹{participant.status === 'SUCCESS' ? participant.totalPaidAmount : participant.expectedAmount}
</Text>
{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 style={styles.menuContainer}>
{canWaive && (
<TouchableOpacity onPress={() => openWaiverDialog(participant)}>
<Ionicons name="ellipsis-vertical" size={22} color="#6c757d" />
</TouchableOpacity>
)}
</View>
{canWaive && (
<TouchableOpacity onPress={() => openWaiverDialog(participant)} style={styles.menuButton}>
<LucideIcon name="ellipsis-vertical" size={20} color={theme.colors.textMuted} />
</TouchableOpacity>
)}
</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 (
<AppView paddingHorizontal={0}>
<Modal
transparent={true}
visible={isModalVisible}
animationType="fade"
onRequestClose={() => setModalVisible(false)}
<CustomModal
visible={isWaiverModalVisible}
onClose={() => setWaiverModalVisible(false)}
title="Waive Late Fee"
onConfirm={handleWaiveFee}
confirmText={isWaiving ? 'Applying...' : 'Apply Waiver'}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContainer}>
<Text style={styles.modalTitle}>Waive Late Fee</Text>
<Text style={styles.modalSubtitle}>
For {currentParticipant?.firstName}. Current late fee is ₹{currentParticipant?.lateFee}.
</Text>
<TextInput
style={styles.input}
keyboardType="numeric"
value={waiverAmount}
onChangeText={setWaiverAmount}
placeholder="Enter amount to waive"
/>
<View style={styles.modalButtonContainer}>
<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>
<Text style={[styles.modalSubtitle, { color: theme.colors.textSecondary }]}>
For {currentParticipant?.firstName}. Current late fee: ₹{currentParticipant?.lateFee}
</Text>
<TextInput
style={[
styles.input,
{
backgroundColor: theme.colors.background,
color: theme.colors.textPrimary,
borderColor: theme.colors.border,
},
]}
keyboardType="numeric"
value={waiverAmount}
onChangeText={setWaiverAmount}
placeholder="Enter amount to waive"
placeholderTextColor={theme.colors.textMuted}
/>
</CustomModal>
{loading && !refreshing ? (
<ActivityIndicator style={{ marginTop: 50 }} size="large" color={colors.primary} />
) : !isGroupActive ? (
{((paymentError as any)?.response?.status === 400 || (paymentError as any)?.statusCode === 400 || !isGroupActive || groupDetailsRes?.data?.status === 'APPROVED' || groupDetailsRes?.data?.status === 'PENDING') ? (
<View style={styles.fullScreenMessage}>
<Ionicons name="warning-outline" size={40} color={colors.black} />
<Text style={styles.fullScreenMessageText}>
This group is not currently active.
<LucideIcon name="alert-circle-outline" size={60} color={theme.colors.textMuted} />
<Text style={[styles.fullScreenMessageText, { color: theme.colors.textPrimary }]}>
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>
<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>
</View>
) : (
<ScrollView
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}>
<Text style={styles.headerTitle}>{paymentDetails.groupName}</Text>
<Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}>{paymentDetails?.groupName}</Text>
</View>
<View style={styles.summaryContainer}>
<View style={styles.summaryBox}>
<Text style={styles.summaryLabel}>Cycle</Text>
<Text style={styles.summaryValue}>{paymentDetails.currentCycle}</Text>
</View>
<View style={styles.summaryBox}>
<Text style={styles.summaryLabel}>Contribution</Text>
<Text style={styles.summaryValue}>₹ {paymentDetails.contributionAmount}</Text>
</View>
<View style={styles.summaryBox}>
<Text style={styles.summaryLabel}>Total Amount</Text>
<Text style={[styles.summaryValue, { color: colors.black }]}>₹{paymentDetails.totalDueWithLateFee}</Text>
{paymentDetails && (
<View style={[styles.summaryContainer, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<View style={styles.summaryBox}>
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>Cycle</Text>
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
#{paymentDetails.currentCycle}
</Text>
</View>
<View style={styles.divider} />
<View style={styles.summaryBox}>
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>Contribution</Text>
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
₹{paymentDetails.contributionAmount}
</Text>
</View>
<View style={styles.divider} />
<View style={styles.summaryBox}>
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>Total Amount</Text>
<Text style={[styles.summaryValue, { color: theme.colors.primary }]}>
₹{paymentDetails.totalDueWithLateFee}
</Text>
</View>
</View>
)}
<View style={styles.sectionHeader}>
<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>
<Text style={styles.sectionTitle}>Participants</Text>
{paymentDetails?.cycleCompleted &&
<Text style={{ marginLeft: 20, marginBottom: 10, color: '#6c757d' }}>Next Cycle from : {paymentDetails?.nextCycleStart}</Text>
}{paymentDetails.participants.map(renderParticipant)}
{paymentDetails?.participants.map(renderParticipant)}
</>
)}
</ScrollView>
@@ -289,58 +314,149 @@ const Payments: React.FC = () => {
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f4f6f8' },
header: { padding: 20, paddingBottom: 15 },
headerTitle: { color: '#000000', fontSize: 24, fontWeight: 'bold' },
summaryContainer: { flexDirection: 'row', justifyContent: 'space-around', padding: 20, backgroundColor: '#fff', marginHorizontal: 15, borderRadius: 12, elevation: 2 },
summaryBox: { alignItems: 'center' },
summaryLabel: { fontSize: 14, color: '#6c757d', marginBottom: 4 },
summaryValue: { fontSize: 18, fontWeight: '700', color: colors.black },
sectionTitle: { fontSize: 20, fontWeight: 'bold', marginLeft: 20, marginTop: 20, marginBottom: 10 },
participantCard: { backgroundColor: '#fff', marginHorizontal: 15, marginBottom: 10, borderRadius: 10, padding: 15, flexDirection: 'row', alignItems: 'center', elevation: 1 },
participantInfo: { flex: 1 },
participantName: { fontSize: 16, fontWeight: '600' },
participantMobile: { fontSize: 14, color: '#6c757d', marginTop: 2 },
paymentStatus: { alignItems: 'flex-end', marginHorizontal: 10 },
statusText: { fontSize: 14, fontWeight: 'bold', textTransform: 'uppercase' },
amountText: { fontSize: 14, color: '#495057', marginTop: 2 },
lateFeeText: { fontSize: 13, color: colors.danger, fontWeight: '600', marginTop: 4 },
waivedText: { fontSize: 13, color: colors.success, fontWeight: '600', marginTop: 4 },
menuContainer: { justifyContent: 'center', alignItems: 'center', minWidth: 25 },
modalOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)', justifyContent: 'center', alignItems: 'center' },
modalContainer: { width: '85%', backgroundColor: 'white', borderRadius: 15, padding: 20, elevation: 10 },
modalTitle: { fontSize: 20, fontWeight: 'bold', color: '#333', marginBottom: 5 },
modalSubtitle: { fontSize: 14, color: '#666', marginBottom: 20 },
input: { width: '100%', borderWidth: 1, borderColor: '#ddd', borderRadius: 8, paddingHorizontal: 15, paddingVertical: 12, fontSize: 16, marginBottom: 20 },
modalButtonContainer: { flexDirection: 'row', justifyContent: 'flex-end' },
modalButton: { paddingVertical: 10, paddingHorizontal: 20, borderRadius: 8, marginLeft: 10 },
cancelButton: { backgroundColor: '#f0f0f0' },
cancelButtonText: { color: '#333', fontWeight: 'bold' },
saveButton: { backgroundColor: colors.primary },
saveButtonText: { color: 'white', fontWeight: 'bold' },
container: {
flex: 1,
},
header: {
paddingHorizontal: 14,
paddingVertical: 10,
},
headerTitle: {
fontSize: responsiveFontSize(2.8),
fontFamily: FontsFamily.nunitoBold,
},
summaryContainer: {
flexDirection: 'row',
marginHorizontal: 14,
padding: 16,
borderRadius: 16,
borderWidth: 1,
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 8,
},
summaryBox: {
flex: 1,
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: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f4f6f8',
padding: 20,
padding: 40,
},
fullScreenMessageText: {
fontSize: responsiveFontSize(1.8),
color: '#333',
fontSize: responsiveFontSize(2),
fontFamily: FontsFamily.nunitoBold,
textAlign: 'center',
marginTop: 15,
fontWeight: 'bold',
marginTop: 20,
},
refreshButton: {
marginTop: 20,
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 8,
marginTop: 16,
padding: 12,
},
refreshButtonText: {
color: colors.black,
fontWeight: 'bold',
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoBold,
},
});
File diff suppressed because it is too large Load Diff
+159 -176
View File
@@ -1,43 +1,46 @@
import React, { useEffect, useRef } from 'react'
import { StyleSheet, Text, View, Animated, ToastAndroid } from 'react-native'
import { Ionicons } from '@react-native-vector-icons/ionicons'
import CustomModal from '../../components/common/CustomModal'
import { apiCall } from '../../utils/ApiHelper'
import { BASE_URL } from '../../utils/ApiUrls'
import colors from '../../components/colors'
import React, { useEffect, useRef } from 'react';
import { StyleSheet, Text, View, Animated, ToastAndroid } from 'react-native';
import LucideIcon from '../../components/common/LucideIcon';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
import CustomModal from '../../components/common/CustomModal';
import { useTheme } from '../../hooks/ui/useTheme';
import { useUpdate } from '../../hooks/core/useUpdate';
import { BASE_URL } from '../../utils/ApiUrls';
import FontsFamily from '../../constents/FontsFamily';
interface WinnerUserProps {
data: {
withdrawalDone: boolean
selectionDone: boolean
selectionId: string
currentCycle: number
pendingParticipants: number
withdrawalDetails: any
paidParticipants: number
withdrawalDone: boolean;
selectionDone: boolean;
selectionId: string;
currentCycle: number;
pendingParticipants: number;
withdrawalDetails: any;
paidParticipants: number;
selectedUser: {
firstName: string
lastName: string
mobile: string
id: string
userId: number
email: string
}
cycleEndDate: number[]
cycleStartDate: number[]
allPaid: boolean
selectionDate: number[]
totalParticipants: number
cycleStatus: string
selectionMethod: string
canProceedToWithdrawal: boolean
}
visible: boolean
onClose: () => void
groupId: string | number
firstName: string;
lastName: string;
mobile: string;
id: string;
userId: number;
email: string;
};
cycleEndDate: number[];
cycleStartDate: number[];
allPaid: boolean;
selectionDate: number[];
totalParticipants: number;
cycleStatus: string;
selectionMethod: string;
canProceedToWithdrawal: boolean;
};
visible: boolean;
onClose: () => void;
groupId: string | number;
}
const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId }) => {
const theme = useTheme();
const {
withdrawalDone,
selectionDone,
@@ -50,13 +53,11 @@ const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId
cycleStatus,
selectionMethod,
canProceedToWithdrawal,
} = data || {}
} = data || {};
const scaleAnim = useRef(new Animated.Value(0)).current
const pulseAnim = useRef(new Animated.Value(1)).current
const fadeAnim = useRef(new Animated.Value(0)).current
console.log("winner data : ", data)
const scaleAnim = useRef(new Animated.Value(0)).current;
const pulseAnim = useRef(new Animated.Value(1)).current;
const fadeAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
@@ -64,190 +65,172 @@ const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId
toValue: 1,
friction: 5,
useNativeDriver: true,
}).start()
}).start();
Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.1,
duration: 600,
toValue: 1.05,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 600,
duration: 800,
useNativeDriver: true,
}),
])
).start()
).start();
Animated.timing(fadeAnim, {
toValue: 1,
duration: 1500,
duration: 1000,
useNativeDriver: true,
}).start()
}).start();
} else {
scaleAnim.setValue(0)
pulseAnim.setValue(1)
fadeAnim.setValue(0)
scaleAnim.setValue(0);
pulseAnim.setValue(1);
fadeAnim.setValue(0);
}
}, [visible])
}, [visible]);
const onConfirm = async () => {
try {
const res = await apiCall(`${BASE_URL}payments/withdraw`, 'PUT', { groupId, userId: data?.selectedUser?.id, cycleNumber: currentCycle }) as any
console.log("res winner user : ", res)
ToastAndroid.show('Withdrawal successful', ToastAndroid.SHORT)
onClose()
} catch (error) {
console.log('Confirm payment error : ', error)
} finally {
onClose()
const { mutate: withdrawFunds, isPending } = useUpdate<any, any>(
`${BASE_URL}payments/withdraw`,
{
onSuccess: () => {
ToastAndroid.show('Withdrawal successful', ToastAndroid.SHORT);
onClose();
},
onError: (err) => {
ToastAndroid.show(err?.message || 'Withdrawal failed', ToastAndroid.SHORT);
},
}
}
);
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 (
<CustomModal
visible={visible}
onClose={onClose}
confirmText={canProceedToWithdrawal ? 'Proceed to Withdraw' : 'Close'}
onConfirm={canProceedToWithdrawal ? onConfirm : onClose}
confirmButton={colors.pastelOrange}
confirmText={canProceedToWithdrawal ? (isPending ? 'Processing...' : 'Withdraw Funds') : 'Close'}
onConfirm={canProceedToWithdrawal ? handleWithdraw : onClose}
confirmButton={theme.colors.primary}
>
<View style={styles.container}>
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
{/* Header */}
<View style={styles.header}>
<Ionicons name="trophy" size={24} color="#ff9800" />
<Text style={styles.cycle}>Cycle {currentCycle} Winner </Text>
<Ionicons name="trophy" size={24} color="#ff9800" />
<View style={[styles.trophyContainer, { backgroundColor: theme.colors.background }]}>
<LucideIcon name="trophy" size={32} color={theme.colors.primary} />
</View>
<Text style={[styles.cycle, { color: theme.colors.textSecondary }]}>Cycle {currentCycle} Winner</Text>
<Animated.Text style={[styles.name, { color: theme.colors.textPrimary, transform: [{ scale: pulseAnim }] }]}>
{selectedUser?.firstName} {selectedUser?.lastName}
</Animated.Text>
</View>
{/* Winner Name */}
<Animated.Text style={[styles.name, { transform: [{ scale: pulseAnim }] }]}>
{selectedUser?.firstName} {selectedUser?.lastName}
</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 style={[styles.detailsCard, { backgroundColor: theme.colors.background, borderColor: theme.colors.border }]}>
{renderRow('call-outline', 'Mobile', selectedUser?.mobile)}
{renderRow('mail-outline', 'Email', selectedUser?.email || 'N/A')}
{renderRow('people-outline', 'Participants', `${paidParticipants}/${totalParticipants}`)}
{renderRow(
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 style={styles.row}>
<Ionicons name="mail" size={18} color="#555" />
<Text style={styles.label}>Email:</Text>
<Text style={styles.value}>{selectedUser?.email}</Text>
</View>
<View style={styles.row}>
<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 }]}>
<Animated.Text style={[styles.congrats, { color: theme.colors.success, opacity: fadeAnim }]}>
{selectionDone ? 'Congratulations!' : 'Selection Pending'}
</Animated.Text>
</Animated.View>
</View>
</CustomModal>
)
}
export default WinnerUser
);
};
const styles = StyleSheet.create({
container: {
padding: 20,
padding: 10,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 16,
marginBottom: 24,
},
trophyContainer: {
width: 64,
height: 64,
borderRadius: 32,
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',
marginBottom: 12,
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#555',
marginLeft: 6,
marginRight: 4,
cycle: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoBold,
textTransform: 'uppercase',
letterSpacing: 1,
marginBottom: 4,
},
value: {
fontSize: 16,
color: '#000',
},
congrats: {
fontSize: 20,
fontWeight: '700',
color: '#4caf50',
marginTop: 20,
name: {
fontSize: responsiveFontSize(2.8),
fontFamily: FontsFamily.nunitoBold,
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;
+107 -158
View File
@@ -1,28 +1,29 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect } from 'react';
import {
StyleSheet,
Text,
View,
FlatList,
TouchableOpacity,
Modal,
PermissionsAndroid,
Platform,
NativeModules,
Linking,
Alert,
ToastAndroid,
} from "react-native";
import AppView from "../../components/AppView";
import colors from "../../components/colors";
} from 'react-native';
import { responsiveFontSize } from 'react-native-responsive-dimensions';
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 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 theme = useTheme();
const [contacts, setContacts] = useState<any[]>([]);
const [selectedContact, setSelectedContact] = useState<any>(null);
const [modalVisible, setModalVisible] = useState(false);
@@ -33,18 +34,17 @@ const YourContacts = () => {
const handleFetchContacts = async () => {
try {
if (Platform.OS === "android") {
if (Platform.OS === 'android') {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_CONTACTS,
{
title: "Contacts Permission",
message: "This app would like to view your contacts.",
buttonPositive: "OK",
title: 'Contacts Permission',
message: 'This app would like to view your contacts.',
buttonPositive: 'OK',
}
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
console.log("Contacts permission denied");
return;
}
}
@@ -52,225 +52,174 @@ const YourContacts = () => {
const fetchedContacts = await ContactsModule.getContacts();
setContacts(fetchedContacts);
} 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) => {
if (!contact) return;
const message = `Hey ${contact.name}, check out this awesome app! Download here: ${APP_URL}`;
// direct send to contact if you want
const url = `whatsapp://send?phone=${contact.phone}&text=${encodeURIComponent(
message
)}`;
const url = `whatsapp://send?phone=${contact.phone}&text=${encodeURIComponent(message)}`;
try {
const supported = await Linking.canOpenURL("whatsapp://send");
const supported = await Linking.canOpenURL('whatsapp://send');
if (supported) {
// WhatsApp installed
await Linking.openURL(url);
} else {
// WhatsApp not installed → send user to Play Store/App Store
if (Platform.OS === "android") {
if (Platform.OS === 'android') {
await Linking.openURL(WHATSAPP_URL);
} else if (Platform.OS === "ios") {
await Linking.openURL("itms-apps://apps.apple.com/app/whatsapp-messenger/id310633997");
} else if (Platform.OS === 'ios') {
await Linking.openURL('itms-apps://apps.apple.com/app/whatsapp-messenger/id310633997');
}
}
} catch (err) {
console.error("Error sending to WhatsApp:", err);
ToastAndroid.show("Something went wrong while trying to open WhatsApp", ToastAndroid.SHORT);
ToastAndroid.show('Could not open WhatsApp', ToastAndroid.SHORT);
}
setModalVisible(false);
};
const renderContact = ({ item }: { item: any }) => (
<View style={styles.contactRow}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>
{item.name ? item.name.charAt(0).toUpperCase() : "?"}
<View style={[styles.contactRow, { borderBottomColor: theme.colors.borderLight }]}>
<View style={[styles.avatar, { backgroundColor: theme.colors.surface }]}>
<Text style={[styles.avatarText, { color: theme.colors.textPrimary }]}>
{item.name ? item.name.charAt(0).toUpperCase() : '?'}
</Text>
</View>
<View style={styles.contactInfo}>
<Text style={styles.contactName}>{item.name}</Text>
<Text style={styles.contactPhone}>{item.phone}</Text>
<Text style={[styles.contactName, { color: theme.colors.textPrimary }]}>{item.name}</Text>
<Text style={[styles.contactPhone, { color: theme.colors.textSecondary }]}>{item.phone}</Text>
</View>
<TouchableOpacity
style={styles.inviteButton}
style={[styles.inviteButton, { borderColor: theme.colors.primary }]}
onPress={() => {
setSelectedContact(item);
setModalVisible(true);
}}
>
<Text style={styles.inviteText}>Invite</Text>
<Text style={[styles.inviteText, { color: theme.colors.primary }]}>Invite</Text>
</TouchableOpacity>
</View>
);
return (
<AppView>
<View>
<Text style={styles.header}>Select Contact</Text>
<AppView style={{ backgroundColor: theme.colors.background }}>
<View style={styles.header}>
<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>
<FlatList
data={contacts}
keyExtractor={(item, index) => index.toString()}
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 */}
<Modal
transparent={true}
<CustomModal
visible={modalVisible}
animationType="slide"
onRequestClose={() => setModalVisible(false)}
onClose={() => setModalVisible(false)}
onConfirm={() => handleSend(selectedContact)}
confirmText="Send Invite"
cancelText="Cancel"
confirmButton={theme.colors.primary}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}>Send Invite</Text>
{selectedContact && (
<Text style={styles.modalText}>
Send invite to {selectedContact.name} ({selectedContact.phone})
</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 style={styles.modalBody}>
<Text style={[styles.modalTitle, { color: theme.colors.textPrimary }]}>Send Invite</Text>
{selectedContact && (
<Text style={[styles.modalText, { color: theme.colors.textSecondary }]}>
Invite <Text style={{ color: theme.colors.textPrimary, fontWeight: 'bold' }}>{selectedContact.name}</Text> to the app via WhatsApp?
</Text>
)}
</View>
</Modal>
</CustomModal>
</AppView>
);
};
export default YourContacts;
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
paddingHorizontal: 10,
paddingTop: 20,
},
header: {
fontSize: 20,
fontWeight: "bold",
marginBottom: 15,
padding: 20,
paddingBottom: 10,
},
headerTitle: {
fontSize: responsiveFontSize(2.8),
fontFamily: FontsFamily.nunitoBold,
},
headerSubtitle: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 4,
},
listContent: {
paddingHorizontal: 20,
paddingBottom: 20,
},
contactRow: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
borderBottomWidth: 0.5,
borderBottomColor: "#ddd",
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
borderBottomWidth: 1,
},
avatar: {
width: 45,
height: 45,
borderRadius: 25,
backgroundColor: colors.pastelOrange,
justifyContent: "center",
alignItems: "center",
marginRight: 12,
width: 48,
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
marginRight: 14,
},
avatarText: {
color: "#fff",
fontSize: 18,
fontWeight: "bold",
fontSize: responsiveFontSize(2.2),
fontFamily: FontsFamily.nunitoBold,
},
contactInfo: {
flex: 1,
},
contactName: {
fontSize: 16,
fontWeight: "600",
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoBold,
},
contactPhone: {
fontSize: 13,
color: "gray",
fontSize: responsiveFontSize(1.4),
fontFamily: FontsFamily.nunitoRegular,
marginTop: 2,
},
inviteButton: {
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 10,
borderWidth: 1,
borderColor: colors.Settings,
},
inviteText: {
color: colors.Settings,
fontWeight: "bold",
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoBold,
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.5)",
justifyContent: "center",
alignItems: "center",
},
modalContent: {
width: "80%",
backgroundColor: "#fff",
borderRadius: 10,
padding: 20,
alignItems: "center",
modalBody: {
padding: 10,
alignItems: 'center',
},
modalTitle: {
fontSize: 18,
fontWeight: "bold",
marginBottom: 10,
fontSize: responsiveFontSize(2.2),
fontFamily: FontsFamily.nunitoBold,
marginBottom: 12,
},
modalText: {
fontSize: 15,
marginBottom: 20,
textAlign: "center",
fontSize: responsiveFontSize(1.8),
fontFamily: FontsFamily.nunitoRegular,
textAlign: 'center',
lineHeight: 24,
},
modalActions: {
flexDirection: "row",
justifyContent: "space-between",
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",
emptyContainer: {
alignItems: 'center',
marginTop: 40,
},
});
export default YourContacts;
+7 -19
View File
@@ -9,15 +9,11 @@ import {
import { useDispatch, useSelector } from 'react-redux';
import {
removeNotification,
clearAllNotifications,
markAsRead,
markAllAsRead,
} from '../../redux/notificationSlice';
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 Header from '../../components/common/Header';
import { useNavigation } from '@react-navigation/native';
import { useNotificationHandler } from '../hook/useNotificationHandler';
import colors from '../../components/colors';
@@ -28,7 +24,6 @@ type FilterType = 'all' | 'JOIN_REQUEST' | 'CONTRIBUTION' | 'CONTRIBUTION_ACK' |
const Notification = () => {
const dispatch = useDispatch();
const navigation = useNavigation();
const { handleMarkAsReadAndNavigate } = useNotificationHandler();
const notifications = useSelector(
(state: any) => state.notifications.notifications
@@ -41,10 +36,6 @@ const Notification = () => {
dispatch(removeNotification(messageId));
};
const handleClearAll = () => {
dispatch(clearAllNotifications());
};
const handleMarkAsRead = (messageId: string, screen: string, groupId: string, cycleNumber: string, contributionId: string) => {
console.log("screen type : ", screen);
handleMarkAsReadAndNavigate(
@@ -63,10 +54,6 @@ const Notification = () => {
);
};
const handleMarkAllAsRead = () => {
dispatch(markAllAsRead());
};
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
const now = new Date();
@@ -181,7 +168,8 @@ const Notification = () => {
<AppView>
{/* Header with actions */}
<Header
title={`Notifications ${unreadCount > 0 ? `(${unreadCount})` : ''}`} arrowColor='#000' titleColor='#000'/>
title={`Notifications ${unreadCount > 0 ? `(${unreadCount})` : ''}`}
/>
{/* <View style={styles.headerActions}>
{unreadCount > 0 && (
<LinearGradient
@@ -226,7 +214,7 @@ const Notification = () => {
<View style={styles.notificationContent}>
{/* Icon */}
<View style={styles.iconContainer}>
<Ionicons
<LucideIcon
name={getNotificationIcon(item.data.msgType)}
size={24}
color={getNotificationColor(item.data.msgType)}
@@ -272,7 +260,7 @@ const Notification = () => {
onPress={() => handleDelete(item.messageId)}
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>
</View>
</View>
@@ -282,7 +270,7 @@ const Notification = () => {
}}
ListEmptyComponent={
<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.emptySubtext}>
When you get notifications, they'll appear here
@@ -422,4 +410,4 @@ const styles = StyleSheet.create({
textAlign: 'center',
lineHeight: 20,
},
});
});
+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,
},
});
+160 -126
View File
@@ -3,192 +3,226 @@ import {
View,
Text,
StyleSheet,
Alert,
ActivityIndicator,
ScrollView,
KeyboardAvoidingView,
Platform,
TouchableOpacity,
ToastAndroid,
} from "react-native";
import * as Keychain from "react-native-keychain";
import { CustomInput, CustomButton } from "../../components/common/Custom";
import colors from "../../components/colors";
import LucideIcon from '../../components/common/LucideIcon';
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 { Ionicons } from "@react-native-vector-icons/ionicons";
import { BASE_URL } from "../../utils/ApiUrls";
import FontsFamily from "../../constents/FontsFamily";
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 theme = useTheme();
const navigation = useNavigation<any>();
const [idName, setIdName] = useState("");
const [idNumber, setIdNumber] = useState("");
const [issueDate, setIssueDate] = useState("");
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<OrganizerApplyErrors>({});
const handleSubmit = async () => {
if (!idName || !idNumber || !issueDate) {
ToastAndroid.show("Please fill all fields", ToastAndroid.SHORT);
return;
}
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);
const { mutate: applyOrganizer, isPending: loading } = useCreate<any, any>(APPLY_ORGANIZER_URL, {
onSuccess: (res) => {
console.log("api res",res)
if (res.status === "success") {
ToastAndroid.show("Application submitted successfully!", ToastAndroid.SHORT);
navigation.navigate("Home");
} 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);
ToastAndroid.show("An unexpected error occurred. Please try again.", ToastAndroid.SHORT);
} finally {
setLoading(false);
},
onError: (err: any) => {
ToastAndroid.show(err.message || "An error occurred", ToastAndroid.SHORT);
}
});
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 (
<AppView barStyle="dark-content">
<AppView keyboardVerticalOffset={60} dismissKeyboardOnTap={false}>
<View style={styles.header}>
<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>
<Text style={styles.headerTitle}>Become an Organizer</Text>
<View style={styles.placeholder} />
<Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}>Become Organizer</Text>
</View>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === "ios" ? "padding" : undefined}
keyboardVerticalOffset={Platform.OS === "ios" ? 60 : 0}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
showsVerticalScrollIndicator={false}
>
<ScrollView
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.title}>Submit Your ID for Verification</Text>
<Text style={styles.subtitle}>
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
<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.
</Text>
<CustomInput
placeholder="ID Name (e.g., Aadhaar, PAN Card)"
label="ID Type"
placeholder="e.g., Aadhaar, PAN Card"
value={idName}
onChangeText={setIdName}
onChangeText={(val) => handleChange("idName", val)}
icon="person-outline"
error={""}
error={errors.idName}
/>
<CustomInput
placeholder="ID Number"
label="ID Number"
placeholder="Enter your ID number"
value={idNumber}
onChangeText={setIdNumber}
onChangeText={(val) => handleChange("idNumber", val.toUpperCase())}
autoCapitalize="characters"
icon="card-outline"
error=""
error={errors.idNumber}
/>
<CustomInput
placeholder="Issue Date (YYYY-MM-DD)"
label="Issue Date"
placeholder="DD-MM-YYYY"
value={issueDate}
onChangeText={setIssueDate}
onChangeText={(val) => handleChange("issueDate", formatIssueDateInput(val))}
icon="calendar-outline"
error=""
error={errors.issueDate}
/>
<CustomButton
title={loading ? "Submitting..." : "Request Organizer Role"}
title="Submit Application"
onPress={handleSubmit}
loading={loading}
style={styles.submitBtn}
icon="send-outline"
buttonBackground={colors.pastelBlue}
style={{ opacity: loading ? 0.6 : 1, marginTop: 20 }}
/>
{loading && (
<ActivityIndicator
style={{ marginTop: 15 }}
size="large"
color={colors.primary}
/>
)}
</ScrollView>
</KeyboardAvoidingView>
</View>
</ScrollView>
</AppView>
);
};
export default BecomeOrg;
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: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#FFF',
borderBottomWidth: 1,
borderBottomColor: '#E0E0E0',
paddingHorizontal: 24,
paddingVertical: 16,
},
backButton: {
padding: 5,
marginRight: 16,
padding: 4,
},
headerTitle: {
fontSize: responsiveFontSize(2.4),
fontFamily: FontsFamily.nunitoBold,
},
scrollView: {
flex: 1,
fontSize: 20,
fontWeight: "600",
color: "#000",
},
scrollContent: {
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",
},
placeholder: {
width: 24, // to balance the back button
subtitle: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
textAlign: "center",
marginBottom: 30,
},
submitBtn: {
marginTop: 12,
}
});
export default BecomeOrg;
+144 -91
View File
@@ -3,24 +3,35 @@ import {
StyleSheet,
View,
Text,
Alert,
ActivityIndicator,
TouchableOpacity,
ToastAndroid,
} from "react-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 colors from "../../components/colors";
import { useTheme } from "../../hooks/ui/useTheme";
import { useUpdate } from "../../hooks/core/useUpdate";
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 { 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 theme = useTheme();
const navigation = useNavigation<any>();
const route = useRoute();
// Get initial data from route parameters passed from the Profile screen
const {
currentBankName,
currentAccountNumber,
@@ -30,99 +41,124 @@ const EditBankDetails = () => {
currentBankName?: string;
currentAccountNumber?: string;
currentBranchCode?: string;
onUpdate:()=>void
onUpdate: () => void;
};
// Initialize state with the passed-in details
const [bankName, setBankName] = useState(currentBankName || "");
const [bankAccountNumber, setBankAccountNumber] = useState(currentAccountNumber || "");
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 handleUpdateBankDetails = async () => {
if (!bankName || !bankAccountNumber || !branchCode) {
ToastAndroid.show("Please fill in all bank details.", ToastAndroid.SHORT);
const { mutate: updateBank, isPending: loading } = useUpdate<any, any>(UPDATE_BANK_URL, {
onSuccess: () => {
ToastAndroid.show("Bank details updated successfully!", 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;
}
try {
setLoading(true);
const response = await apiCall(UPDATE_BANK_URL, 'PUT', {
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);
}
updateBank({
bankName: bankName.trim(),
bankAccountNumber: bankAccountNumber.trim(),
branchCode: branchCode.trim(),
});
};
return (
<AppView>
<View style={styles.header}>
<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>
<Text style={styles.headerTitle}>Edit Bank Details</Text>
<Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}>Edit Bank Details</Text>
</View>
<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
label="Bank Name"
placeholder="Enter your bank's name"
value={bankName}
onChangeText={setBankName}
icon="business"
error=""
/>
<CustomInput
label="Bank Account Number"
placeholder="Enter your account number"
value={bankAccountNumber}
onChangeText={setBankAccountNumber}
icon="card"
keyboardType="numeric"
error=""
/>
<CustomInput
label="Branch Code"
placeholder="Enter the branch code (IFSC)"
value={branchCode}
onChangeText={setBranchCode}
icon="barcode"
error=""
/>
<View style={styles.buttonContainer}>
<CustomButton
title={loading ? "Saving..." : "Save Changes"}
onPress={handleUpdateBankDetails}
icon={loading ? undefined : "save"}
buttonBackground={colors.pastelBlue}
<CustomInput
label="Bank Name"
placeholder="Enter your bank's name"
value={bankName}
onChangeText={(val) => handleChange("bankName", val)}
icon="business-outline"
error={errors.bankName}
/>
<CustomButton
title="Cancel"
onPress={() => navigation.goBack()}
style={styles.cancelButton}
buttonBackground={colors.pastelBlue}
<CustomInput
label="Bank Account Number"
placeholder="Enter your account number"
value={bankAccountNumber}
onChangeText={(val) => handleChange("bankAccountNumber", val)}
icon="card-outline"
keyboardType="numeric"
maxLength={18}
error={errors.bankAccountNumber}
/>
<CustomInput
label="Branch Code (IFSC)"
placeholder="Enter the branch code"
value={branchCode}
onChangeText={(val) => handleChange("branchCode", val)}
autoCapitalize="characters"
maxLength={11}
icon="barcode-outline"
error={errors.branchCode}
/>
<View style={styles.buttonContainer}>
<CustomButton
title="Save Changes"
onPress={handleUpdate}
loading={loading}
style={styles.saveButton}
/>
<CustomButton
title="Cancel"
onPress={() => navigation.goBack()}
type="outline"
style={styles.cancelButton}
/>
</View>
</View>
</View>
</AppView>
@@ -133,33 +169,50 @@ const styles = StyleSheet.create({
header: {
flexDirection: "row",
alignItems: "center",
padding: 16,
paddingHorizontal: 24,
paddingVertical: 16,
},
backButton: {
marginRight: 16,
padding: 4,
},
headerTitle: {
fontSize: 22,
fontWeight: "600",
color: colors.Profile,
fontSize: responsiveFontSize(2.4),
fontFamily: FontsFamily.nunitoBold,
},
container: {
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: {
fontSize: 18,
fontWeight: "700",
color: "#1e293b",
marginBottom: 20,
fontSize: responsiveFontSize(2),
fontFamily: FontsFamily.nunitoBold,
marginBottom: 8,
},
sectionSubtitle: {
fontSize: responsiveFontSize(1.6),
fontFamily: FontsFamily.nunitoRegular,
marginBottom: 24,
},
buttonContainer: {
marginTop: 24,
marginTop: 12,
},
saveButton: {
marginBottom: 4,
},
cancelButton: {
marginTop: 12,
borderWidth: 1,
borderColor: "#cbd5e1",
marginTop: 4,
},
});
File diff suppressed because it is too large Load Diff
+76 -46
View File
@@ -1,12 +1,11 @@
import React, { useState } from "react";
import { View, Text, StyleSheet, Switch } from "react-native";
import React from "react";
import { View, Text, StyleSheet, TouchableOpacity } from "react-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 = {
BecomeOrg: undefined;
Profile: undefined;
};
import { useTheme } from "../../hooks/ui/useTheme";
import FontsFamily from "../../constents/FontsFamily";
type SwitchNavigatorProps = {
role: string;
@@ -14,54 +13,85 @@ type SwitchNavigatorProps = {
};
const SwitchNavigator: React.FC<SwitchNavigatorProps> = ({ role, status }) => {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const [isEnabled, setIsEnabled] = useState(false);
const theme = useTheme();
const navigation = useNavigation<any>();
const canApply = role === "USER" && (status === "NOT_APPLIED" || !status);
const prettyStatus = status ? status.replace(/_/g, " ") : "NOT APPLIED";
const toggleSwitch = () => {
const newValue = !isEnabled;
setIsEnabled(newValue);
if (newValue) {
navigation.navigate("BecomeOrg"); // Navigate when ON
} else {
navigation.navigate("Profile"); // Navigate when OFF
}
};
console.log("status :", status);
console.log("role :", role);
const primaryText = canApply ? "Become an Organizer" : "Organizer Application";
const secondaryText = canApply
? "Tap to apply and unlock organizer tools"
: `Current status: ${prettyStatus}`;
return (
<View style={styles.container}>
<Text style={styles.text}>
{!isEnabled && role !== "USER"
? "Become an Organizer"
: `Request Organizer Role ${status}`}
</Text>
<Switch
trackColor={{ false: "#767577", true: "#81b0ff" }}
thumbColor={isEnabled ? "#007AFF" : "#f4f3f4"}
ios_backgroundColor="#3e3e3e"
onValueChange={toggleSwitch}
value={isEnabled}
/>
</View>
<TouchableOpacity
activeOpacity={0.88}
onPress={() => navigation.navigate("BecomeOrg")}
style={[styles.container, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}
>
<View style={[styles.leadingIcon, { backgroundColor: theme.colors.primarySoft }]}>
<LucideIcon
name={canApply ? "sparkles-outline" : "time-outline"}
size={20}
color={theme.colors.primary}
/>
</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({
container: {
flexDirection: "row",
justifyContent: 'flex-end',
paddingHorizontal: 16,
paddingVertical: 10,
gap: 10,
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 13,
borderRadius: 16,
borderWidth: 1,
elevation: 1,
shadowColor: '#000',
shadowOpacity: 0.02,
shadowRadius: 8,
shadowOffset: { width: 0, height: 2 },
},
text: {
fontSize: 18,
flexShrink: 1, // Wrap text if too long
leadingIcon: {
width: 34,
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 AppView from '../../components/AppView'
import React from 'react';
import { StyleSheet, Text, View, TouchableOpacity } from 'react-native';
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 = () => {
return (
<AppView barStyle='dark-content'>
<Text>Transaction</Text>
</AppView>
)
}
const theme = useTheme();
const navigation = useNavigation();
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({
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;
+1 -1
View File
@@ -13,4 +13,4 @@ const Setting = () => {
export default Setting
const styles = StyleSheet.create({})
const styles = StyleSheet.create({})
+1 -1
View File
@@ -198,4 +198,4 @@ const styles = StyleSheet.create({
emptyContainer: { alignItems: "center", justifyContent: "center", },
emptyTitle: { fontSize: 18, fontWeight: "600", color: "#374151" },
emptySubtitle: { marginTop: 8, color: "#6b7280" },
});
});
+87 -47
View File
@@ -1,75 +1,115 @@
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 {
primary: string;
primarySoft: string;
onPrimary: string;
secondary: string;
background: string;
surface: string;
card: string;
textPrimary: string;
textSecondary: string;
success: string;
border: string;
logincard?: string;
logincard1?: string;
textMuted: string;
reverseBackground: string;
reverseText?: string;
optCard?: string;
gray?: string;
black?: string;
white?: string;
red?: string;
green?: string;
reverseText: string;
border: string;
borderLight: string;
white: string;
black: string;
error: string;
success: string;
warning: string;
}
export interface Theme {
mode: ThemeMode;
colors: ThemeColors;
spacing: typeof spacing;
borderRadius: typeof borderRadius;
palette: typeof palette;
}
export const light: Theme = {
mode: 'light',
spacing,
borderRadius,
palette,
colors: {
primary: '#F4721E',
secondary: '#FF9A62',
reverseBackground: '#000000',
background: '#FFFFFF',
card: '#F3F4F6',
textPrimary: '#000000',
reverseText: '#FFFFFF',
textSecondary: '#666666',
success: '#008e02',
border: '#E5E7EB',
logincard: '#e0e0e0',
logincard1: '#66666eb3',
optCard: '#66666eb3',
gray:'#e0e0e0ff',
black:'#000000',
white:'#FFFFFF',
red:'#FF0000',
green:'#00C853'
primary: '#1C1C1F',
primarySoft: 'rgba(28, 28, 31, 0.1)',
onPrimary: palette.white,
secondary: '#5E5E64',
background: palette.white,
surface: '#F4F4F6',
card: palette.white,
textPrimary: palette.black,
textSecondary: '#5E5E64',
textMuted: '#7B7B82',
reverseBackground: palette.black,
reverseText: palette.white,
border: '#DADAE0',
borderLight: '#ECECF1',
white: palette.white,
black: palette.black,
error: '#B94747',
success: '#2E8A57',
warning: '#9B6E19',
},
};
export const dark: Theme = {
mode: 'dark',
spacing,
borderRadius,
palette,
colors: {
primary: '#F4721E',
secondary: '#FF9A62',
reverseBackground: '#FFFFFF',
background: '#000000', // Fixed: Dark mode = Black bg
card: '#111214',
textPrimary: '#FFFFFF',
textSecondary: '#A1A1A1',
reverseText: '#000000',
success: '#00C853',
border: '#222427',
logincard: '#000000',
logincard1: '#8d8d8f',
optCard: '#e0e0e0',
gray:'#66666eb3',
black:'#000000',
white:'#FFFFFF',
red:'#FF0000',
green:'#00C853'
primary: '#E4E4E8',
primarySoft: 'rgba(228, 228, 232, 0.18)',
onPrimary: '#101013',
secondary: '#C1C1C8',
background: '#0D0D11',
surface: '#1A1A1F',
card: '#141419',
textPrimary: '#F5F5F8',
textSecondary: '#C1C1C8',
textMuted: '#9A9AA2',
reverseBackground: palette.white,
reverseText: palette.black,
border: '#2D2D35',
borderLight: '#22222A',
white: palette.white,
black: palette.black,
error: '#B86666',
success: '#3B8E62',
warning: '#A87B2F',
},
};
+2 -2
View File
@@ -16,8 +16,8 @@ export interface MyGroup {
currentParticipants: number;
maxParticipants: number;
contributionAmount: number;
cycleType: "MONTHLY" | "QUARTERLY" | "YEARLY";
status: "PENDING" | "ACTIVE" | "COMPLETED";
cycleType: "CUSTOM" | "DAILY" | "WEEKLY" | "MONTHLY" | "QUARTERLY" | "HALF_YEARLY" | "YEARLY";
status: "PENDING" | "APPROVED" | "ACTIVE" | "DELAY" | "COMPLETED" | "REJECTED" | "CANCELLED";
}
export interface JoinedGroup {
+26 -7
View File
@@ -1,5 +1,5 @@
import axios, { AxiosRequestConfig } from "axios";
import { Alert, ToastAndroid } from "react-native";
import { ToastAndroid } from "react-native";
import * as Keychain from "react-native-keychain";
/**
@@ -16,13 +16,23 @@ export async function apiCall<T>(
config?: AxiosRequestConfig,
responseErrorMessage?:string
): 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 {
// Get saved credentials from Keychain
const creds = await Keychain.getGenericPassword();
console.log("Keychain Credentials Found:", creds); // Log if creds were found
const isMultipart = typeof FormData !== "undefined" && data instanceof FormData;
const headers = {
"Content-Type": "application/json",
"Content-Type": isMultipart ? "multipart/form-data" : "application/json",
...(creds ? { Authorization: `Bearer ${creds.password}` } : {}),
...config?.headers,
};
@@ -35,11 +45,22 @@ export async function apiCall<T>(
headers,
});
// --- Response Logging ---
console.log(`--- API Response:${url}///// [${response}] `);
console.log(`[HOOK_API][${normalizedMethod}] SUCCESS URL:`, url);
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;
} 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) {
// Server responded with a status other than 2xx
// console.error(
@@ -60,8 +81,6 @@ export async function apiCall<T>(
// console.error("API Error:", error);
}
console.log(`API : ${url} API Error:`, error);
return error.response.status
// Re-throw the error so that the calling function can handle it if needed
throw error;
return null;
}
}
+4 -1
View File
@@ -25,4 +25,7 @@ export const UPLOAD_GROUP_IMAGE = `${BASE_URL}fund/groups/`;
export const STORE_FCM_TOKEN = `${BASE_URL}token/store?fcmToken=`
export const REMOVE_FCM_TOKEN = `${BASE_URL}token/remove?fcmToken=`
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`