Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efcd3b15e0 | ||
|
|
a34baa8be9 | ||
|
|
cbad923b1a | ||
|
|
4af33c376f | ||
|
|
7094b35bfb | ||
|
|
8c3aee8dba | ||
|
|
c6ba774167 | ||
|
|
e653b48a8b | ||
|
|
ac9e7772b9 | ||
|
|
84d609d4ac |
@@ -20,7 +20,7 @@ import { ThemeProvider } from './src/theme/ThemeProvider';
|
||||
|
||||
|
||||
LogBox.ignoreAllLogs(true);
|
||||
const queryClient = new QueryClient({
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 2,
|
||||
@@ -29,8 +29,9 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
function App() {
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
|
||||
function App() {
|
||||
|
||||
useEffect(() => {
|
||||
requestPermission();
|
||||
@@ -53,18 +54,18 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<PaperProvider >
|
||||
<AppNavigator />
|
||||
</PaperProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</SafeAreaProvider>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<PaperProvider >
|
||||
<AppNavigator />
|
||||
</PaperProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,97 +1,114 @@
|
||||
This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli).
|
||||
# CircleCollect
|
||||
|
||||
# Getting Started
|
||||
CircleCollect is a React Native mobile app for organizing and managing group fund collections (rotating savings / contribution circles). Organizers create contribution groups, members join and contribute on a cycle, and a winner can be randomly selected each cycle. Payments are handled through an in-app payment gateway, and push notifications keep members up to date.
|
||||
|
||||
> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding.
|
||||
## Features
|
||||
|
||||
## Step 1: Start Metro
|
||||
- **OTP-based authentication** — login with a mobile number and one-time password; tokens stored securely with `react-native-keychain`
|
||||
- **Organizer registration** — regular users can apply to become organizers and register bank / mobile money details
|
||||
- **Group management** — create, edit, and review groups; browse public groups; approve or decline join requests; invite members from contacts
|
||||
- **Contributions and payments** — contribute to one group or pay several at once (bulk pay), choose a payment mode, and complete checkout in a payment gateway WebView
|
||||
- **Cycle winner selection** — randomly select a winning member for a group's current cycle
|
||||
- **Transactions** — view personal transaction history and group payment records
|
||||
- **Push notifications** — Firebase Cloud Messaging with Notifee for display, including deep-link redirection from a notification to the relevant screen
|
||||
- **Profile** — edit profile details, profile picture, and bank details
|
||||
|
||||
First, you will need to run **Metro**, the JavaScript build tool for React Native.
|
||||
## Tech stack
|
||||
|
||||
To start the Metro dev server, run the following command from the root of your React Native project:
|
||||
| Area | Library |
|
||||
| --- | --- |
|
||||
| Framework | React Native 0.81, React 19, TypeScript |
|
||||
| Navigation | React Navigation (native stack + bottom tabs) with deep linking |
|
||||
| State | Redux Toolkit + redux-persist (user, notifications) |
|
||||
| Server state | TanStack React Query with custom CRUD hooks (`useFetch`, `useCreate`, `useUpdate`, `useDelete`) |
|
||||
| Networking | Axios client with auth interceptors (`src/api/client.ts`) |
|
||||
| Push notifications | `@react-native-firebase/messaging` + `@notifee/react-native` |
|
||||
| Secure storage | `react-native-keychain`, AsyncStorage |
|
||||
| UI | React Native Paper, RN Elements, vector icons, Reanimated, linear gradient, calendars |
|
||||
| Payments | Gateway checkout via `react-native-webview` |
|
||||
|
||||
```sh
|
||||
# Using npm
|
||||
npm start
|
||||
## Project structure
|
||||
|
||||
# OR using Yarn
|
||||
yarn start
|
||||
```
|
||||
src/
|
||||
├── api/ # Axios client with request/response interceptors
|
||||
├── assets/ # Images and static assets
|
||||
├── components/ # Shared UI (cards, buttons, alerts, common widgets)
|
||||
├── constents/ # Font family constants
|
||||
├── hooks/
|
||||
│ ├── core/ # React Query CRUD hooks + API debug helpers
|
||||
│ └── ui/ # useTheme
|
||||
├── navigation/ # AppNavigator (root stack), BottomTabs, route types
|
||||
├── payment/ # Payment WebView wrapper
|
||||
├── redux/ # Store, userSlice, notificationSlice
|
||||
├── screens/
|
||||
│ ├── home/ # Dashboard, group lists, requests
|
||||
│ ├── management/ # Group/member management, payments, winner selection
|
||||
│ ├── payment/ # Payment mode selection, gateway WebView
|
||||
│ ├── profile/ # Profile, become organizer, bank details
|
||||
│ ├── transaction/# Transaction history
|
||||
│ ├── notification/
|
||||
│ └── setting/
|
||||
├── theme/ # ThemeProvider and theme tokens
|
||||
└── utils/ # API URLs, helpers, Firebase setup, navigation service
|
||||
```
|
||||
|
||||
## Step 2: Build and run your app
|
||||
The backend base URL and all endpoints are defined in `src/utils/ApiUrls.tsx`.
|
||||
|
||||
With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app:
|
||||
## Getting started
|
||||
|
||||
### Android
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 20
|
||||
- A working [React Native environment](https://reactnative.dev/docs/set-up-your-environment) (Android Studio / Xcode)
|
||||
- Firebase configuration files in place (`android/app/google-services.json`, iOS `GoogleService-Info.plist`) for push notifications
|
||||
|
||||
### Install dependencies
|
||||
|
||||
```sh
|
||||
# Using npm
|
||||
npm run android
|
||||
|
||||
# OR using Yarn
|
||||
yarn android
|
||||
npm install
|
||||
```
|
||||
|
||||
### iOS
|
||||
|
||||
For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps).
|
||||
|
||||
The first time you create a new project, run the Ruby bundler to install CocoaPods itself:
|
||||
For iOS, also install CocoaPods dependencies (first clone and after updating native deps):
|
||||
|
||||
```sh
|
||||
bundle install
|
||||
```
|
||||
|
||||
Then, and every time you update your native dependencies, run:
|
||||
|
||||
```sh
|
||||
bundle exec pod install
|
||||
```
|
||||
|
||||
For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html).
|
||||
### Run the app
|
||||
|
||||
Start Metro:
|
||||
|
||||
```sh
|
||||
# Using npm
|
||||
npm run ios
|
||||
|
||||
# OR using Yarn
|
||||
yarn ios
|
||||
npm start
|
||||
```
|
||||
|
||||
If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device.
|
||||
Then in a separate terminal:
|
||||
|
||||
This is one way to run your app — you can also build it directly from Android Studio or Xcode.
|
||||
```sh
|
||||
# Android
|
||||
npm run android
|
||||
|
||||
## Step 3: Modify your app
|
||||
# iOS
|
||||
npm run ios
|
||||
```
|
||||
|
||||
Now that you have successfully run the app, let's make changes!
|
||||
## Scripts
|
||||
|
||||
Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh).
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `npm start` | Start the Metro dev server |
|
||||
| `npm run android` | Build and run on Android |
|
||||
| `npm run ios` | Build and run on iOS |
|
||||
| `npm test` | Run Jest tests |
|
||||
| `npm run lint` | Run ESLint |
|
||||
|
||||
When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload:
|
||||
## Configuration
|
||||
|
||||
- **Android**: Press the <kbd>R</kbd> key twice or select **"Reload"** from the **Dev Menu**, accessed via <kbd>Ctrl</kbd> + <kbd>M</kbd> (Windows/Linux) or <kbd>Cmd ⌘</kbd> + <kbd>M</kbd> (macOS).
|
||||
- **iOS**: Press <kbd>R</kbd> in iOS Simulator.
|
||||
- **API base URL** — set in `src/utils/ApiUrls.tsx` (`BASE_URL`). A commented-out local URL is provided for development against a local backend.
|
||||
- **Deep linking** — the app registers the `myapp://` scheme for notification redirection (see `src/navigation/AppNavigator.tsx`).
|
||||
|
||||
## Congratulations! :tada:
|
||||
## Troubleshooting
|
||||
|
||||
You've successfully run and modified your React Native App. :partying_face:
|
||||
|
||||
### Now what?
|
||||
|
||||
- If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps).
|
||||
- If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started).
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page.
|
||||
|
||||
# Learn More
|
||||
|
||||
To learn more about React Native, take a look at the following resources:
|
||||
|
||||
- [React Native Website](https://reactnative.dev) - learn more about React Native.
|
||||
- [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment.
|
||||
- [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**.
|
||||
- [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts.
|
||||
- [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native.
|
||||
See the React Native [Troubleshooting guide](https://reactnative.dev/docs/troubleshooting). If Android builds fail after dependency changes, try `cd android && ./gradlew clean`.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
arguments=--init-script C\:\\Users\\Lincpay\\AppData\\Roaming\\Code\\User\\globalStorage\\redhat.java\\1.53.0\\config_win\\org.eclipse.osgi\\58\\0\\.cp\\gradle\\init\\init.gradle --init-script C\:\\Users\\Lincpay\\AppData\\Roaming\\Code\\User\\globalStorage\\redhat.java\\1.53.0\\config_win\\org.eclipse.osgi\\58\\0\\.cp\\gradle\\protobuf\\init.gradle --init-script C\:\\Users\\Lincpay\\AppData\\Roaming\\Code\\User\\globalStorage\\redhat.java\\1.53.0\\config_win\\org.eclipse.osgi\\58\\0\\.cp\\gradle\\kotlin\\init.gradle --init-script C\:\\Users\\Lincpay\\AppData\\Roaming\\Code\\User\\globalStorage\\redhat.java\\1.53.0\\config_win\\org.eclipse.osgi\\58\\0\\.cp\\gradle\\groovy\\init.gradle
|
||||
arguments=--init-script C\:\\Users\\Lincpay\\AppData\\Roaming\\Antigravity\\User\\globalStorage\\redhat.java\\1.52.0\\config_win\\org.eclipse.osgi\\58\\0\\.cp\\gradle\\init\\init.gradle --init-script C\:\\Users\\Lincpay\\AppData\\Roaming\\Antigravity\\User\\globalStorage\\redhat.java\\1.52.0\\config_win\\org.eclipse.osgi\\58\\0\\.cp\\gradle\\protobuf\\init.gradle
|
||||
auto.sync=false
|
||||
build.scans.enabled=false
|
||||
connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": 3,
|
||||
"artifactType": {
|
||||
"type": "APK",
|
||||
"kind": "Directory"
|
||||
},
|
||||
"applicationId": "com.circlecollect",
|
||||
"variantName": "release",
|
||||
"elements": [
|
||||
{
|
||||
"type": "SINGLE",
|
||||
"filters": [],
|
||||
"attributes": [],
|
||||
"versionCode": 1,
|
||||
"versionName": "1.0",
|
||||
"outputFile": "app-release.apk"
|
||||
}
|
||||
],
|
||||
"elementType": "File",
|
||||
"baselineProfiles": [
|
||||
{
|
||||
"minApi": 28,
|
||||
"maxApi": 30,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/1/app-release.dm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"minApi": 31,
|
||||
"maxApi": 2147483647,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/0/app-release.dm"
|
||||
]
|
||||
}
|
||||
],
|
||||
"minSdkVersionForDexing": 24
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:screenOrientation="portrait"
|
||||
android:exported="true">
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 25 KiB |
@@ -39,6 +39,7 @@
|
||||
"react-native-dialog": "^9.3.0",
|
||||
"react-native-elements": "^3.4.3",
|
||||
"react-native-fast-confetti": "^1.1.1",
|
||||
"react-native-gesture-handler": "^2.28.0",
|
||||
"react-native-image-picker": "^8.2.1",
|
||||
"react-native-inset-shadow": "^1.0.3",
|
||||
"react-native-keychain": "^10.0.0",
|
||||
@@ -53,6 +54,7 @@
|
||||
"react-native-svg": "^15.13.0",
|
||||
"react-native-vector-icons": "^10.3.0",
|
||||
"react-native-walkthrough-tooltip": "^1.6.0",
|
||||
"react-native-webview": "^13.16.1",
|
||||
"react-native-worklets": "^0.6.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"redux-persist": "^6.0.0"
|
||||
@@ -1806,7 +1808,6 @@
|
||||
"node_modules/@egjs/hammerjs": {
|
||||
"version": "2.0.17",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/hammerjs": "^2.0.36"
|
||||
},
|
||||
@@ -4453,8 +4454,7 @@
|
||||
},
|
||||
"node_modules/@types/hammerjs": {
|
||||
"version": "2.0.46",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
@@ -12639,7 +12639,6 @@
|
||||
"node_modules/react-native-gesture-handler": {
|
||||
"version": "2.28.0",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@egjs/hammerjs": "^2.0.17",
|
||||
"hoist-non-react-statics": "^3.3.0",
|
||||
@@ -12896,6 +12895,20 @@
|
||||
"@types/react": ">=16.8.24"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-webview": {
|
||||
"version": "13.16.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz",
|
||||
"integrity": "sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"invariant": "2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-worklets": {
|
||||
"version": "0.6.0",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"react-native-dialog": "^9.3.0",
|
||||
"react-native-elements": "^3.4.3",
|
||||
"react-native-fast-confetti": "^1.1.1",
|
||||
"react-native-gesture-handler": "^2.28.0",
|
||||
"react-native-image-picker": "^8.2.1",
|
||||
"react-native-inset-shadow": "^1.0.3",
|
||||
"react-native-keychain": "^10.0.0",
|
||||
@@ -55,6 +56,7 @@
|
||||
"react-native-svg": "^15.13.0",
|
||||
"react-native-vector-icons": "^10.3.0",
|
||||
"react-native-walkthrough-tooltip": "^1.6.0",
|
||||
"react-native-webview": "^13.16.1",
|
||||
"react-native-worklets": "^0.6.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"redux-persist": "^6.0.0"
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { FC } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ToastAndroid } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import React, { FC, useCallback, useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import { responsiveFontSize } from 'react-native-responsive-dimensions';
|
||||
import { useTheme } from '../hooks/ui/useTheme';
|
||||
import { useFetch } from '../hooks/core/useFetch';
|
||||
import { useCreate } from '../hooks/core/useCreate';
|
||||
import { CONTRIBUTE } from '../utils/ApiUrls';
|
||||
import FontsFamily from '../constents/FontsFamily';
|
||||
import { apiCall } from '../utils/ApiHelper';
|
||||
|
||||
interface JoinedGroup {
|
||||
id: string;
|
||||
@@ -22,7 +21,8 @@ interface JoinedGroup {
|
||||
currentParticipants: number;
|
||||
contributionAmount: number;
|
||||
cycleType: 'MONTHLY' | 'WEEKLY' | 'QUARTERLY' | 'CUSTOM';
|
||||
status: 'ACTIVE' | 'PENDING' | 'COMPLETED'| 'APPROVED';
|
||||
status: 'ACTIVE' | 'PENDING' | 'COMPLETED' | 'APPROVED' | 'DELAY';
|
||||
customCycleDays?: number;
|
||||
}
|
||||
|
||||
interface JoinedGroupCardProps {
|
||||
@@ -42,24 +42,25 @@ const formatDate = (dateStr: string | null): string => {
|
||||
return `${dayNum} ${months[monthIndex]} ${year}`;
|
||||
};
|
||||
|
||||
export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item }:any) => {
|
||||
export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item }: any) => {
|
||||
const theme = useTheme();
|
||||
const navigation = useNavigation<any>();
|
||||
console.log("item",item)
|
||||
const [payable, setPayable] = useState<any>(null);
|
||||
|
||||
const { data: payableRes } = useFetch<any>(`${CONTRIBUTE}${item.id}/calculate-contribution`);
|
||||
const payable = payableRes?.data;
|
||||
|
||||
const { mutate: contribute, isPending: loading } = useCreate<any, any>(
|
||||
`payments/contribute/${item?.id}?amount=${payable?.totalPayable}`,
|
||||
{
|
||||
onSuccess: () => {
|
||||
ToastAndroid.show('Payment successful!', ToastAndroid.SHORT);
|
||||
},
|
||||
onError: () => {
|
||||
ToastAndroid.show('Payment failed. Please try again.', ToastAndroid.SHORT);
|
||||
},
|
||||
const fetchPayable = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiCall(`${CONTRIBUTE}${item.id}/calculate-contribution`, 'GET') as any;
|
||||
setPayable(res?.data || null);
|
||||
} catch (error) {
|
||||
setPayable(null);
|
||||
console.log(`Failed to fetch fresh payable details for group ${item.id}`, error);
|
||||
}
|
||||
}, [item.id]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
fetchPayable();
|
||||
}, [fetchPayable])
|
||||
);
|
||||
|
||||
const getStatusColors = (status: JoinedGroup['status']) => {
|
||||
@@ -103,7 +104,7 @@ export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item }:any) => {
|
||||
</View>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={[styles.infoLabel, { color: theme.colors.textMuted }]}>Duration</Text>
|
||||
<Text style={[styles.infoValue, { color: theme.colors.textPrimary }]}>{`${item?.cycleType} (${item?.customCycleDays} days)`}</Text>
|
||||
<Text style={[styles.infoValue, { color: theme.colors.textPrimary }]}>{item?.cycleType === 'CUSTOM' ? `${item?.cycleType} (${item?.customCycleDays} days)` : item?.cycleType}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -145,7 +146,7 @@ export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item }:any) => {
|
||||
<Text style={[styles.paidText, { color: theme.colors.success }]}>✓ PAID</Text>
|
||||
</View>
|
||||
) : (
|
||||
item.status !== 'COMPLETED' && (
|
||||
(item?.status === "ACTIVE" || item?.status === "DELAY") && (
|
||||
<TouchableOpacity
|
||||
style={[styles.payButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={() => {
|
||||
@@ -154,10 +155,9 @@ export const JoinedGroupCard: FC<JoinedGroupCardProps> = ({ item }:any) => {
|
||||
routeFrom: 'GroupDetails'
|
||||
})
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={[styles.payButtonText, { color: theme.colors.onPrimary }]}>
|
||||
{loading ? '...' : 'Pay'}
|
||||
Pay
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ const getStatusStyle = (status: UserJoinRequest["status"], theme: ReturnType<typ
|
||||
label: "Pending",
|
||||
icon: "time-outline",
|
||||
};
|
||||
case "REJECT":
|
||||
case "REJECTED":
|
||||
return {
|
||||
backgroundColor: theme.colors.error + "22",
|
||||
textColor: theme.colors.error,
|
||||
@@ -76,11 +76,11 @@ export const UserRequested: FC<UserRequestedProps> = ({ item }) => {
|
||||
{" "}Group Created By:{" "}
|
||||
<Text style={[styles.userName, { color: theme.colors.textPrimary }]} numberOfLines={1}>{item.createdBy}</Text>
|
||||
</Text>
|
||||
{item.reason ? (
|
||||
{/* {item.reason ? (
|
||||
<Text style={[styles.reasonText, { color: theme.colors.textMuted }]} numberOfLines={2}>
|
||||
<LucideIcon name="chatbubbles-outline" size={13} color={theme.colors.textMuted} /> {item.reason}
|
||||
</Text>
|
||||
) : null}
|
||||
) : null} */}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { StyleSheet, View, Text, TextInput, TouchableOpacity, TextInputProps } f
|
||||
import LucideIcon, { LucideIconName } from './LucideIcon';
|
||||
import { responsiveFontSize } from 'react-native-responsive-dimensions';
|
||||
import { useTheme } from '../../hooks/ui/useTheme';
|
||||
import { ActivityIndicator } from 'react-native-paper';
|
||||
|
||||
// --- Custom Input ---
|
||||
type CustomInputProps = TextInputProps & {
|
||||
@@ -143,7 +144,7 @@ const CustomButton: React.FC<CustomButtonProps> = ({
|
||||
<LucideIcon name={icon} size={20} color={textColor} style={{ marginRight: 8 }} />
|
||||
)}
|
||||
<Text style={[styles.buttonText, { color: textColor }]}>
|
||||
{loading ? 'Processing...' : title}
|
||||
{loading ?<ActivityIndicator color={textColor} /> : title}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -25,6 +25,7 @@ import ManagementScreen from "../screens/management/ManagementScreen";
|
||||
import { navigationRef } from "../utils/navigationService";
|
||||
import NotificationRedirection from "../screens/NotificationRedirection";
|
||||
import PaymentModeScreen from "../screens/payment/PaymentModeScreen";
|
||||
import PaymentGatewayWebViewScreen from "../screens/payment/PaymentGatewayWebViewScreen";
|
||||
// import screen from "../components/test";
|
||||
|
||||
export type RootStackParamList = {
|
||||
@@ -48,7 +49,18 @@ export type RootStackParamList = {
|
||||
TransactionList: undefined
|
||||
RandomSelectUserScreen: { groupId: string | number, cycleNumber: number };
|
||||
Notification: undefined;
|
||||
PaymentMode: { groupId: string | number; amount: number; payableData?: any };
|
||||
PaymentMode: {
|
||||
groupId?: string | number;
|
||||
groupIds?: Array<string | number>;
|
||||
amount?: number;
|
||||
routeFrom?: string;
|
||||
routeData?: any;
|
||||
payableData?: any;
|
||||
};
|
||||
PaymentGatewayWebView: {
|
||||
paymentUrl: string;
|
||||
paymentData?: any;
|
||||
};
|
||||
NotificationRedirection: { data: any };
|
||||
Management:
|
||||
| { actionTab: "My Groups" }
|
||||
@@ -122,6 +134,7 @@ export default function AppNavigator() {
|
||||
<Stack.Screen name="Management" component={ManagementScreen} options={{ headerShown: false }} />
|
||||
<Stack.Screen name="NotificationRedirection" component={NotificationRedirection} options={{ headerShown: false }} />
|
||||
<Stack.Screen name="PaymentMode" component={PaymentModeScreen} options={{ headerShown: false }} />
|
||||
<Stack.Screen name="PaymentGatewayWebView" component={PaymentGatewayWebViewScreen} options={{ headerShown: false }} />
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
@@ -42,19 +42,19 @@ const BottomTabs: React.FC = () => {
|
||||
tabBarStyle: {
|
||||
position: "absolute",
|
||||
bottom: Math.max(insets.bottom, 8),
|
||||
left: 12,
|
||||
right: 12,
|
||||
left: 40,
|
||||
right: 40,
|
||||
height: 64,
|
||||
borderRadius: 18,
|
||||
borderRadius: 22,
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderTopWidth: 0,
|
||||
elevation: 8,
|
||||
elevation: 10,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 8,
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 10,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
tabBarItemStyle: {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// navigation/types.ts
|
||||
|
||||
import { NavigatorScreenParams } from '@react-navigation/native';
|
||||
import { StackScreenProps } from '@react-navigation/stack';
|
||||
|
||||
// Type for the parameters passed to each screen in the Bottom Tab Navigator
|
||||
@@ -32,6 +31,18 @@ export type RootStackParamList = {
|
||||
ManageJoinedGroup: { groupId: string | number };
|
||||
Invite: { groupId: string | number; code: string | number };
|
||||
ManagementTabs:any
|
||||
PaymentMode: {
|
||||
groupId?: string | number;
|
||||
groupIds?: Array<string | number>;
|
||||
amount?: number;
|
||||
routeFrom?: string;
|
||||
routeData?: any;
|
||||
payableData?: any;
|
||||
};
|
||||
PaymentGatewayWebView: {
|
||||
paymentUrl: string;
|
||||
paymentData?: any;
|
||||
};
|
||||
NotificationRedirection: { data: any };
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
Dimensions,
|
||||
Image,
|
||||
RefreshControl,
|
||||
ToastAndroid,
|
||||
@@ -25,8 +24,6 @@ import { CustomInput, CustomButton } from '../components/common/Custom';
|
||||
import SkeletonLoader from '../components/common/SkeletonLoader';
|
||||
import FontsFamily from '../constents/FontsFamily';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
// --- Types ---
|
||||
interface Group {
|
||||
id: string | number;
|
||||
@@ -76,7 +73,7 @@ const GroupCard = ({
|
||||
const isFull = item.currentParticipants >= item.maxParticipants;
|
||||
const isPending = Boolean(item.joinRequestPending);
|
||||
const canJoin = !isJoined && !isPending && !isFull;
|
||||
console.log("item>>>>>>>>>>>>>>", item);
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.card,
|
||||
@@ -100,6 +97,20 @@ const GroupCard = ({
|
||||
|
||||
<View style={[styles.cardDivider, { backgroundColor: theme.colors.border }]} />
|
||||
|
||||
<View style={[styles.contributionBanner, { backgroundColor: theme.colors.primarySoft }]}>
|
||||
<View>
|
||||
<Text style={[styles.contributionLabel, { color: theme.colors.textMuted }]}>
|
||||
Contribution Amount
|
||||
</Text>
|
||||
<Text style={[styles.contributionValue, { color: theme.colors.primary }]}>
|
||||
UGX {Number(item.contributionAmount || 0).toLocaleString('en-IN')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.contributionIconWrap, { backgroundColor: theme.colors.primary + '18' }]}>
|
||||
<LucideIcon name="cash-outline" size={18} color={theme.colors.primary} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.cardMeta}>
|
||||
<View style={styles.metaItem}>
|
||||
<LucideIcon name="people-outline" size={14} color={theme.colors.textMuted} />
|
||||
@@ -190,7 +201,7 @@ export default function GroupScreen() {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
onRefresh();
|
||||
}, [])
|
||||
}, [onRefresh])
|
||||
);
|
||||
const handleJoin = (groupId: string | number) => {
|
||||
console.log("Hitting here");
|
||||
@@ -395,13 +406,42 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
cardMeta: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
contributionBanner: {
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
marginBottom: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
contributionLabel: {
|
||||
fontSize: responsiveFontSize(1.25),
|
||||
fontFamily: FontsFamily.nunitoSemiBold,
|
||||
marginBottom: 4,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
contributionValue: {
|
||||
fontSize: responsiveFontSize(2),
|
||||
fontFamily: FontsFamily.nunitoExtraBold,
|
||||
},
|
||||
contributionIconWrap: {
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
metaItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
marginRight: 4,
|
||||
},
|
||||
metaText: {
|
||||
fontSize: responsiveFontSize(1.3),
|
||||
|
||||
@@ -19,6 +19,7 @@ import { responsiveFontSize } from 'react-native-responsive-dimensions';
|
||||
import LucideIcon from '../components/common/LucideIcon';
|
||||
import * as Keychain from 'react-native-keychain';
|
||||
import FontsFamily from '../constents/FontsFamily';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
// --- Type Definitions ---
|
||||
interface Participant {
|
||||
@@ -56,6 +57,7 @@ type InviteScreenProps = NativeStackScreenProps<RootStackParamList, 'Invite'>;
|
||||
const InviteScreen: React.FC<InviteScreenProps> = ({ route, navigation }) => {
|
||||
const { groupId, code } = route.params;
|
||||
const theme = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
@@ -81,9 +83,20 @@ const InviteScreen: React.FC<InviteScreenProps> = ({ route, navigation }) => {
|
||||
);
|
||||
|
||||
const groupDetails: GroupDetails | null = detailsRes?.data || null;
|
||||
const hasEntryFee = Number(groupDetails?.entryFee || 0) > 0;
|
||||
|
||||
const handleJoin = () => {
|
||||
if (!groupDetails) return;
|
||||
|
||||
if (hasEntryFee) {
|
||||
navigation.navigate('PaymentMode' as never, {
|
||||
groupId: groupDetails.id,
|
||||
routeFrom: 'GroupScreen',
|
||||
routeData: groupDetails,
|
||||
} as never);
|
||||
return;
|
||||
}
|
||||
|
||||
joinGroup({}, {
|
||||
onSuccess: () => {
|
||||
ToastAndroid.show(`Join request sent for "${groupDetails.groupName}".`, ToastAndroid.SHORT);
|
||||
@@ -125,73 +138,95 @@ const InviteScreen: React.FC<InviteScreenProps> = ({ route, navigation }) => {
|
||||
|
||||
return (
|
||||
<AppView>
|
||||
<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 style={styles.screen}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 120 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.inviteBadge, { backgroundColor: theme.colors.primary }]}>
|
||||
<LucideIcon name="mail-open-outline" size={24} color={theme.colors.onPrimary} />
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<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={`UGX${groupDetails.contributionAmount.toLocaleString('en-IN')}`} />
|
||||
<InfoRow
|
||||
label="Frequency"
|
||||
value={groupDetails.cycleType === 'CUSTOM' ? `${groupDetails.customCycleDays} Day(s)` : groupDetails.cycleType}
|
||||
/>
|
||||
<InfoRow label="Min/Max Participants" value={`${groupDetails.minParticipants} / ${groupDetails.maxParticipants}`} />
|
||||
<InfoRow label="Entry Fee" value={`UGX${groupDetails.entryFee}`} />
|
||||
<InfoRow
|
||||
label="Late Payment Charge"
|
||||
value={`UGX${groupDetails.latePaymentCharge} (${groupDetails.lateFeeMode || 'DAILY'})`}
|
||||
/>
|
||||
<InfoRow label="Withdrawal Method" value={groupDetails.withdrawalMethod || 'RANDOM'} />
|
||||
<InfoRow label="Status" value={groupDetails.status} />
|
||||
<InfoRow label="Invite Code" value={code} />
|
||||
</View>
|
||||
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
|
||||
<Text style={[styles.cardTitle, { color: theme.colors.textPrimary }]}>Circle Overview</Text>
|
||||
<InfoRow label="Organizer" value={organizerName} />
|
||||
<InfoRow label="Contribution" value={`UGX${groupDetails.contributionAmount.toLocaleString('en-IN')}`} />
|
||||
<InfoRow
|
||||
label="Frequency"
|
||||
value={groupDetails.cycleType === 'CUSTOM' ? `${groupDetails.customCycleDays} Day(s)` : groupDetails.cycleType}
|
||||
/>
|
||||
<InfoRow label="Min/Max Participants" value={`${groupDetails.minParticipants} / ${groupDetails.maxParticipants}`} />
|
||||
<InfoRow label="Entry Fee" value={`UGX${groupDetails.entryFee}`} />
|
||||
<InfoRow
|
||||
label="Late Payment Charge"
|
||||
value={`UGX${groupDetails.latePaymentCharge} (${groupDetails.lateFeeMode || 'DAILY'})`}
|
||||
/>
|
||||
<InfoRow label="Withdrawal Method" value={groupDetails.withdrawalMethod || 'RANDOM'} />
|
||||
<InfoRow label="Status" value={groupDetails.status} />
|
||||
<InfoRow label="Invite Code" value={code} />
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
|
||||
<Text style={[styles.cardTitle, { color: theme.colors.textPrimary }]}>
|
||||
Members ({groupDetails.totalParticipants}/{groupDetails.maxParticipants})
|
||||
</Text>
|
||||
{groupDetails.participants.length > 0 ? (
|
||||
groupDetails.participants.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)}
|
||||
<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>
|
||||
<Text style={[styles.participantName, { color: theme.colors.textPrimary }]}>
|
||||
{`${p.firstName} ${p.lastName || ''}`.trim()}
|
||||
</Text>
|
||||
{p.role === 'ORGANIZER' && (
|
||||
<View style={[styles.organizerBadge, { backgroundColor: theme.colors.background }]}>
|
||||
<Text style={[styles.organizerBadgeText, { color: theme.colors.textMuted }]}>Organizer</Text>
|
||||
</View>
|
||||
)}
|
||||
</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.emptyText, { color: theme.colors.textSecondary }]}>Be the first to join!</Text>
|
||||
)}
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<Text style={[styles.emptyText, { color: theme.colors.textSecondary }]}>Be the first to join!</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<CustomButton
|
||||
title={isJoinable ? 'Accept Invitation' : `Circle is ${groupDetails.status}`}
|
||||
onPress={handleJoin}
|
||||
loading={isJoining}
|
||||
disabled={!isJoinable || isJoining}
|
||||
style={styles.joinButton}
|
||||
/>
|
||||
</ScrollView>
|
||||
<View
|
||||
style={[
|
||||
styles.floatingButtonContainer,
|
||||
{
|
||||
backgroundColor: theme.colors.background,
|
||||
borderTopColor: theme.colors.border,
|
||||
paddingBottom: Math.max(insets.bottom, 12),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CustomButton
|
||||
title={
|
||||
isJoinable
|
||||
? hasEntryFee
|
||||
? 'Pay & Accept Invitation'
|
||||
: 'Accept Invitation'
|
||||
: `Circle is ${groupDetails.status}`
|
||||
}
|
||||
onPress={handleJoin}
|
||||
loading={isJoining}
|
||||
disabled={!isJoinable || isJoining}
|
||||
style={styles.joinButton}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</AppView>
|
||||
);
|
||||
};
|
||||
@@ -207,6 +242,9 @@ const InfoRow = ({ label, value }: { label: string; value: string | number }) =>
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
@@ -308,8 +346,17 @@ const styles = StyleSheet.create({
|
||||
fontSize: responsiveFontSize(1.6),
|
||||
fontFamily: FontsFamily.nunitoMedium,
|
||||
},
|
||||
floatingButtonContainer: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
joinButton: {
|
||||
marginTop: 16,
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
|
||||
@@ -52,10 +52,10 @@ export default function LoginScreen() {
|
||||
);
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (!/^\d{10}$/.test(mobile)) {
|
||||
setError('Please enter a 10-digit mobile number');
|
||||
return;
|
||||
}
|
||||
if (!/^\d{9}$/.test(mobile)) {
|
||||
setError('Please enter a 9-digit mobile number');
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
try {
|
||||
@@ -96,7 +96,7 @@ export default function LoginScreen() {
|
||||
<View style={styles.formSection}>
|
||||
<CustomInput
|
||||
label="Mobile Number"
|
||||
placeholder="e.g. 9876543210"
|
||||
placeholder="e.g. 987654321"
|
||||
value={mobile}
|
||||
onChangeText={(text) => {
|
||||
setMobile(text.replace(/\D/g, ''));
|
||||
@@ -104,7 +104,7 @@ export default function LoginScreen() {
|
||||
if (error) setError('');
|
||||
}}
|
||||
keyboardType="phone-pad"
|
||||
maxLength={10}
|
||||
maxLength={9}
|
||||
icon="call-outline"
|
||||
error={error}
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ import * as Keychain from 'react-native-keychain';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { useTheme } from '../hooks/ui/useTheme';
|
||||
import { useCreate } from '../hooks/core/useCreate';
|
||||
import { useFetch } from '../hooks/core/useFetch';
|
||||
import { LOGIN_URL, VARIFY_OTP_URL } from '../utils/ApiUrls';
|
||||
import { getFcmToken } from '../utils/firebase/firebaseServices';
|
||||
import { CustomButton } from '../components/common/Custom';
|
||||
@@ -57,7 +58,7 @@ export default function OtpVerificationScreen() {
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [fadeAnim, slideAnim]);
|
||||
|
||||
const { mutate: verifyOtp, isPending: verifying } = useCreate<any, any>(VARIFY_OTP_URL, {
|
||||
onSuccess: async (res) => {
|
||||
@@ -84,17 +85,31 @@ export default function OtpVerificationScreen() {
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: resendOtp, isPending: resending } = useCreate<any, any>(LOGIN_URL, {
|
||||
onSuccess: () => {
|
||||
setTimer(30);
|
||||
setCode(Array(CELL_COUNT).fill(''));
|
||||
inputs.current[0]?.focus();
|
||||
ToastAndroid.show('OTP Resent', ToastAndroid.SHORT);
|
||||
},
|
||||
onError: () => {
|
||||
ToastAndroid.show('Failed to resend OTP', ToastAndroid.SHORT);
|
||||
},
|
||||
});
|
||||
const { refetch: resendOtp, isFetching: resending } = useFetch<any>(
|
||||
LOGIN_URL + mobile,
|
||||
{},
|
||||
{
|
||||
enabled: false,
|
||||
retry: false,
|
||||
}
|
||||
);
|
||||
|
||||
const handleResendOtp = async () => {
|
||||
try {
|
||||
const { data } = await resendOtp();
|
||||
|
||||
if (data?.statusCode === 200) {
|
||||
setTimer(30);
|
||||
setCode(Array(CELL_COUNT).fill(''));
|
||||
inputs.current[0]?.focus();
|
||||
ToastAndroid.show('OTP Resent', ToastAndroid.SHORT);
|
||||
} else {
|
||||
ToastAndroid.show(data?.message || 'Failed to send OTP. Please try again.', ToastAndroid.SHORT);
|
||||
}
|
||||
} catch {
|
||||
ToastAndroid.show('Something went wrong. Please check your connection.', ToastAndroid.SHORT);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextChange = (text: string, index: number) => {
|
||||
// If text length > 1, it's likely a paste operation
|
||||
@@ -159,7 +174,7 @@ export default function OtpVerificationScreen() {
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Verification</Text>
|
||||
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
|
||||
Enter the 6-digit code sent to {'\n'}
|
||||
<Text style={{ color: theme.colors.textPrimary, fontWeight: '700' }}>+91 {mobile}</Text>
|
||||
<Text style={[styles.emphasisText, { color: theme.colors.textPrimary }]}>{mobile}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -200,10 +215,10 @@ export default function OtpVerificationScreen() {
|
||||
<View style={styles.resendContainer}>
|
||||
{timer > 0 ? (
|
||||
<Text style={[styles.timerText, { color: theme.colors.textMuted }]}>
|
||||
Resend code in <Text style={{ fontWeight: '700' }}>00:{timer.toString().padStart(2, '0')}</Text>
|
||||
Resend code in <Text style={styles.emphasisText}>00:{timer.toString().padStart(2, '0')}</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<TouchableOpacity onPress={() => resendOtp({ params: { mobile } })} disabled={resending}>
|
||||
<TouchableOpacity onPress={handleResendOtp} disabled={resending}>
|
||||
<Text style={[styles.resendText, { color: theme.colors.primary }]}>
|
||||
{resending ? 'Sending...' : 'Resend Code'}
|
||||
</Text>
|
||||
@@ -276,4 +291,7 @@ const styles = StyleSheet.create({
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
emphasisText: {
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,8 +8,9 @@ import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { PROFILE_URL } from '../utils/ApiUrls';
|
||||
import { setUserData, clearUser } from '../redux/userSlice';
|
||||
import { useTheme } from '../hooks/ui/useTheme';
|
||||
import { useFetch } from '../hooks/core/useFetch';
|
||||
|
||||
import FontsFamily from '../constents/FontsFamily';
|
||||
import { useFetch } from '../hooks/core/useFetch';
|
||||
|
||||
const SplashScreen = () => {
|
||||
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>();
|
||||
|
||||
@@ -38,7 +38,7 @@ const organizerActions: ActionItem[] = [
|
||||
{ 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: '5', title: 'Your Requests', subtitle: 'Status of your applications', action: 'NAVIGATE_MANAGEMENT', tab: 'Requests', iconName: 'mail-open-outline' },
|
||||
{ id: '6', title: 'Transactions', subtitle: 'View payment history', action: 'TRANSACTION', iconName: 'receipt-outline' },
|
||||
];
|
||||
|
||||
|
||||
@@ -5,17 +5,13 @@ import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
ToastAndroid,
|
||||
Image,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import LucideIcon from '../../components/common/LucideIcon';
|
||||
import { responsiveFontSize } from 'react-native-responsive-dimensions';
|
||||
import { useTheme } from '../../hooks/ui/useTheme';
|
||||
import { useCreate } from '../../hooks/core/useCreate';
|
||||
import { PAY_ALL_URL } from '../../utils/ApiUrls';
|
||||
import FontsFamily from '../../constents/FontsFamily';
|
||||
|
||||
export type StatusData = {
|
||||
@@ -70,20 +66,6 @@ const UserProfileStatus: React.FC<UserProfileStatusProps> = ({
|
||||
}
|
||||
}, [statusData]);
|
||||
|
||||
const { mutate: payAll, isPending: submitting } = useCreate<any, any>(PAY_ALL_URL, {
|
||||
onSuccess: (res) => {
|
||||
if (res?.status) {
|
||||
ToastAndroid.show('Payment initiated successfully', ToastAndroid.SHORT);
|
||||
setShowPayDialog(false);
|
||||
} else {
|
||||
ToastAndroid.show(res?.message || 'Payment failed', ToastAndroid.SHORT);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
ToastAndroid.show('Something went wrong during payment', ToastAndroid.SHORT);
|
||||
},
|
||||
});
|
||||
|
||||
const infoItems = useMemo(
|
||||
() => [
|
||||
{ icon: 'person-outline', label: 'Role', value: statusData?.role || '-' },
|
||||
@@ -113,6 +95,24 @@ const UserProfileStatus: React.FC<UserProfileStatusProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const handleProceedToPayment = () => {
|
||||
if (selectedGroupIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowPayDialog(false);
|
||||
navigation.navigate('PaymentMode', {
|
||||
amount: totalDueSelected,
|
||||
routeFrom: 'UserProfileStatus',
|
||||
groupIds: selectedGroupIds,
|
||||
routeData: {
|
||||
currencyCode: 'UGX',
|
||||
totalPayable: totalDueSelected,
|
||||
groupCount: selectedGroupIds.length,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderDueBar = () => (
|
||||
<View style={[styles.dueBar, { backgroundColor: theme.colors.surface, borderTopColor: theme.colors.border }]}>
|
||||
<View>
|
||||
@@ -214,15 +214,11 @@ const UserProfileStatus: React.FC<UserProfileStatusProps> = ({
|
||||
<Text style={[styles.totalValue, { color: theme.colors.textPrimary }]}>UGX{totalDueSelected}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.confirmButton, { backgroundColor: theme.colors.primary, opacity: submitting ? 0.7 : 1 }]}
|
||||
onPress={() => payAll({ groupIds: selectedGroupIds })}
|
||||
disabled={submitting || selectedGroupIds.length === 0}
|
||||
style={[styles.confirmButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={handleProceedToPayment}
|
||||
disabled={selectedGroupIds.length === 0}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator color={theme.colors.onPrimary} />
|
||||
) : (
|
||||
<Text style={[styles.confirmText, { color: theme.colors.onPrimary }]}>Confirm Payment</Text>
|
||||
)}
|
||||
<Text style={[styles.confirmText, { color: theme.colors.onPrimary }]}>Choose Method</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
LayoutAnimation,
|
||||
UIManager,
|
||||
@@ -28,7 +27,7 @@ interface Contribution {
|
||||
waivedAmount: number;
|
||||
totalAmount: number;
|
||||
status: 'SUCCESS' | 'PENDING' | 'FAILED';
|
||||
paidDate: [number, number, number];
|
||||
paidDate: [number, number, number] | string | null;
|
||||
}
|
||||
|
||||
interface Withdrawal {
|
||||
@@ -37,13 +36,13 @@ interface Withdrawal {
|
||||
baseAmount: number;
|
||||
totalAmount: number;
|
||||
status: 'SUCCESS' | 'PENDING' | 'FAILED';
|
||||
paidDate: [number, number, number];
|
||||
paidDate: [number, number, number] | string | null;
|
||||
}
|
||||
|
||||
interface Cycle {
|
||||
cycleNumber: number;
|
||||
cycleStart: [number, number, number];
|
||||
cycleEnd: [number, number, number];
|
||||
cycleStart: [number, number, number] | string | null;
|
||||
cycleEnd: [number, number, number] | string | null;
|
||||
expectedContribution: number;
|
||||
contributions: Contribution[];
|
||||
withdrawal: Withdrawal | null;
|
||||
@@ -87,11 +86,10 @@ const formatDate = (dateStr: string | null): string => {
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number): string => {
|
||||
return new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
return `UGX ${new Intl.NumberFormat('en-IN', {
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount || 0)}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -132,24 +130,32 @@ const CycleItem = ({ item, isExpanded, onToggle }: { item: Cycle, isExpanded: bo
|
||||
{/* Contributions List */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}>Contributions ({item.paidParticipants}/{item.totalParticipants})</Text>
|
||||
{item.contributions.map(contribution => (
|
||||
<View key={contribution.transactionId} style={[styles.contributionItem, {
|
||||
backgroundColor: contribution.status === 'SUCCESS' ? theme.colors.success + '15' : contribution.status === 'PENDING' ? theme.colors.warning + '15' : theme.colors.error + '15',
|
||||
borderBottomColor: theme.colors.borderLight
|
||||
}]}>
|
||||
<LucideIcon name="person-circle-outline" size={28} color={theme.colors.textSecondary} />
|
||||
<View style={{ flex: 1, marginLeft: 10 }}>
|
||||
<Text style={[styles.contributorName, { color: theme.colors.textPrimary }]} numberOfLines={1} > {contribution.userName}</Text>
|
||||
<Text style={[styles.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>
|
||||
{item.contributions.length > 0 ? (
|
||||
item.contributions.map(contribution => (
|
||||
<View key={contribution.transactionId} style={[styles.contributionItem, {
|
||||
backgroundColor: contribution.status === 'SUCCESS' ? theme.colors.success + '15' : contribution.status === 'PENDING' ? theme.colors.warning + '15' : theme.colors.error + '15',
|
||||
borderBottomColor: theme.colors.borderLight
|
||||
}]}>
|
||||
<LucideIcon name="person-circle-outline" size={28} color={theme.colors.textSecondary} />
|
||||
<View style={{ flex: 1, marginLeft: 10 }}>
|
||||
<Text style={[styles.contributorName, { color: theme.colors.textPrimary }]} numberOfLines={1}>{contribution.userName}</Text>
|
||||
<Text style={[styles.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, { color: theme.colors.textPrimary }]}>{formatCurrency(contribution.totalAmount)}</Text>
|
||||
</View>
|
||||
<Text style={[styles.contributionAmount, { color: theme.colors.textPrimary }]}>{formatCurrency(contribution.totalAmount)}</Text>
|
||||
))
|
||||
) : (
|
||||
<View style={[styles.emptyState, { borderColor: theme.colors.borderLight, backgroundColor: theme.colors.background }]}>
|
||||
<Text style={[styles.emptyStateText, { color: theme.colors.textMuted }]}>
|
||||
No contributions recorded for this cycle yet.
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -163,13 +169,17 @@ const CycleItem = ({ item, isExpanded, onToggle }: { item: Cycle, isExpanded: bo
|
||||
const CycleDashboard = ({ data }: { data: CycleDashboardData }) => {
|
||||
const [expandedCycle, setExpandedCycle] = useState<number | null>(data.currentCycle);
|
||||
const theme = useTheme();
|
||||
const activeCycleNumber = data.cycles?.[0]?.cycleNumber ?? data.currentCycle ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedCycle(activeCycleNumber);
|
||||
}, [activeCycleNumber]);
|
||||
|
||||
const handleToggle = (cycleNumber: number) => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||
setExpandedCycle(expandedCycle === cycleNumber ? null : cycleNumber);
|
||||
};
|
||||
|
||||
console.log("expanded cycle : ", data)
|
||||
const renderHeader = () => (
|
||||
<View style={styles.headerContainer}>
|
||||
<Text style={[styles.groupName, { color: theme.colors.textPrimary }]}>{data.groupName}</Text>
|
||||
@@ -184,26 +194,24 @@ const CycleDashboard = ({ data }: { data: CycleDashboardData }) => {
|
||||
</View>
|
||||
<View style={styles.summaryBox}>
|
||||
<Text style={[styles.summaryLabel, { color: theme.colors.textMuted }]}>Cycles</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>{data.currentCycle}/{data.totalParticipants}</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>{data.currentCycle}/{data.totalCycles}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={data.cycles}
|
||||
keyExtractor={(item) => item.cycleNumber.toString()}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.container}>
|
||||
{renderHeader()}
|
||||
{data.cycles.map((item) => (
|
||||
<CycleItem
|
||||
key={item.cycleNumber.toString()}
|
||||
item={item}
|
||||
isExpanded={expandedCycle === item.cycleNumber}
|
||||
onToggle={() => handleToggle(item.cycleNumber)}
|
||||
/>
|
||||
)}
|
||||
ListHeaderComponent={renderHeader}
|
||||
contentContainerStyle={styles.container}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -320,5 +328,16 @@ const styles = StyleSheet.create({
|
||||
fontSize: responsiveFontSize(1.5),
|
||||
fontWeight: '500',
|
||||
},
|
||||
emptyState: {
|
||||
borderWidth: 1,
|
||||
borderStyle: 'dashed',
|
||||
borderRadius: 10,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
emptyStateText: {
|
||||
fontSize: responsiveFontSize(1.4),
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
@@ -8,8 +7,7 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ScrollView,
|
||||
Image,
|
||||
Modal
|
||||
useWindowDimensions
|
||||
} from "react-native";
|
||||
import LinearGradient from "react-native-linear-gradient";
|
||||
|
||||
@@ -25,77 +23,103 @@ import CycleDashboard from "./CycleDashboard";
|
||||
import SkeletonLoader from "../../../components/common/SkeletonLoader";
|
||||
import FontsFamily from "../../../constents/FontsFamily";
|
||||
import AppView from "../../../components/AppView";
|
||||
// import CustomDropdown from "../../../components/CustomDropdown";
|
||||
|
||||
const formatCycleDateLabel = (dateValue?: string | null) => {
|
||||
if (!dateValue || typeof dateValue !== "string") {
|
||||
return "Date pending";
|
||||
}
|
||||
|
||||
const parts = dateValue.split("-");
|
||||
if (parts.length !== 3) {
|
||||
return dateValue;
|
||||
}
|
||||
|
||||
const [year, month, day] = parts;
|
||||
const parsedDate = new Date(Number(year), Number(month) - 1, Number(day));
|
||||
|
||||
return parsedDate.toLocaleDateString("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
});
|
||||
};
|
||||
|
||||
const GroupDetails = () => {
|
||||
const theme = useTheme();
|
||||
const route = useRoute<any>();
|
||||
const navigation = useNavigation<any>();
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const groupId = route?.params?.item?.id || route?.params?.groupId;
|
||||
|
||||
const [memberData, setMemberData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
const [payable, setPayable] = useState<any>();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [cycleDashboardData, setCycleDashboardData] = useState<any>([]);
|
||||
const [selectedCycleNumber, setSelectedCycleNumber] = useState(null);
|
||||
const [cycleItems, setCycleItems] = useState([]);
|
||||
const [cycleDashboardData, setCycleDashboardData] = useState<any>(null);
|
||||
const [selectedCycleNumber, setSelectedCycleNumber] = useState<number | null>(null);
|
||||
const [selectedUser, setSelectedUser] = useState() as string | any;
|
||||
|
||||
const group = {
|
||||
id: route.params.item?.id,
|
||||
name: route.params.item?.groupName,
|
||||
collected: route.params.item?.contributionAmount,
|
||||
goal: route.params.item?.contributionAmount * route.params.item?.currentParticipants,
|
||||
description: route.params.item?.description,
|
||||
status: route.params.item?.status,
|
||||
id: route?.params?.item?.id,
|
||||
name: route?.params?.item?.groupName,
|
||||
collected: route?.params?.item?.contributionAmount,
|
||||
goal: route?.params?.item?.contributionAmount * route?.params?.item?.currentParticipants,
|
||||
description: route?.params?.item?.description,
|
||||
status: route?.params?.item?.status,
|
||||
members: memberData,
|
||||
};
|
||||
|
||||
const getPaymentStatus = async () => {
|
||||
const response = await apiCall(`${BASE_URL}payments/group/${group.id || route?.params?.groupId}/payment/paid`, 'GET') as any;
|
||||
const getPaymentStatus = useCallback(async () => {
|
||||
const response = await apiCall(`${BASE_URL}payments/group/${groupId}/payment/paid`, 'GET') as any;
|
||||
if (response.status === 'success') {
|
||||
setSelectedUser(response.data);
|
||||
}
|
||||
}
|
||||
}, [groupId]);
|
||||
|
||||
const getCycleDashboard = async () => {
|
||||
const getCycleDashboard = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiCall(`${BASE_URL}payments/groups/${group.id || route?.params?.groupId}/cycle-dashboard`, "GET") as any;
|
||||
const response = await apiCall(`${BASE_URL}payments/groups/${groupId}/cycle-dashboard`, "GET") as any;
|
||||
const responseData = response.data;
|
||||
setCycleDashboardData(responseData || []);
|
||||
setCycleDashboardData(responseData || null);
|
||||
if (responseData?.cycles) {
|
||||
const items = responseData.cycles.map((cycle: any) => ({
|
||||
label: `Cycle ${cycle.cycleNumber}`,
|
||||
value: cycle.cycleNumber
|
||||
}));
|
||||
setCycleItems(items);
|
||||
if (responseData.currentCycle) {
|
||||
setSelectedCycleNumber(responseData.currentCycle);
|
||||
}
|
||||
setSelectedCycleNumber((previousCycleNumber) => {
|
||||
const hasPreviousSelection = responseData.cycles.some(
|
||||
(cycle: any) => cycle.cycleNumber === previousCycleNumber
|
||||
);
|
||||
|
||||
if (hasPreviousSelection) {
|
||||
return previousCycleNumber;
|
||||
}
|
||||
|
||||
if (responseData.currentCycle) {
|
||||
return responseData.currentCycle;
|
||||
}
|
||||
|
||||
return responseData.cycles[0]?.cycleNumber ?? null;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
ToastAndroid.show("Failed to fetch cycle data", ToastAndroid.SHORT);
|
||||
}
|
||||
};
|
||||
}, [groupId]);
|
||||
|
||||
const fetchGroupDetails = async () => {
|
||||
const fetchGroupDetails = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiCall(`${GROUP_ALL_MEMBER}?groupId=${group.id || route?.params?.groupId}`, "GET") as any;
|
||||
const res = await apiCall(`${GROUP_ALL_MEMBER}?groupId=${groupId}`, "GET") as any;
|
||||
setMemberData(res.data);
|
||||
} catch (error: any) {
|
||||
ToastAndroid.show(error?.message || "Failed to fetch group details", ToastAndroid.SHORT);
|
||||
}
|
||||
};
|
||||
}, [groupId]);
|
||||
|
||||
const handleContribution = async () => {
|
||||
const handleContribution = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiCall(`${CONTRIBUTE}${group.id || route?.params?.groupId}/calculate-contribution`, 'GET') as any;
|
||||
const res = await apiCall(`${CONTRIBUTE}${groupId}/calculate-contribution`, 'GET') as any;
|
||||
setPayable(res.data);
|
||||
} catch (error) {
|
||||
console.log("Error in handleContribution: ", error);
|
||||
}
|
||||
};
|
||||
}, [groupId]);
|
||||
|
||||
const fetchAllData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -105,14 +129,14 @@ const GroupDetails = () => {
|
||||
getCycleDashboard()
|
||||
]);
|
||||
setLoading(false);
|
||||
}, [group.id || route?.params?.groupId]);
|
||||
}, [fetchGroupDetails, getCycleDashboard, handleContribution]);
|
||||
|
||||
useEffect(() => {
|
||||
if (group.id || route?.params?.groupId) {
|
||||
if (groupId) {
|
||||
fetchAllData();
|
||||
getPaymentStatus();
|
||||
}
|
||||
}, [group.id || route?.params?.groupId, fetchAllData]);
|
||||
}, [fetchAllData, getPaymentStatus, groupId]);
|
||||
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
@@ -120,26 +144,19 @@ const GroupDetails = () => {
|
||||
await fetchAllData();
|
||||
await getPaymentStatus();
|
||||
setRefreshing(false);
|
||||
}, [fetchAllData]);
|
||||
}, [fetchAllData, getPaymentStatus]);
|
||||
|
||||
|
||||
const confirmPayment = async () => {
|
||||
try {
|
||||
const res = await apiCall(`${BASE_URL}payments/contribute/${group?.id}?amount=${payable?.totalPayable}`, 'POST') as any;
|
||||
console.log('payment res ', res)
|
||||
if (res.status === 200 || res.status === 201 || res.statusCode === 200) {
|
||||
ToastAndroid.show('Payment done!', ToastAndroid.SHORT);
|
||||
onRefresh();
|
||||
} else {
|
||||
ToastAndroid.show(res?.data?.message || 'Payment failed!', ToastAndroid.SHORT);
|
||||
}
|
||||
} catch (error) {
|
||||
ToastAndroid.show("An error occurred during payment.", ToastAndroid.SHORT);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const selectedCycleData = cycleDashboardData?.cycles?.find((c: any) => c.cycleNumber === selectedCycleNumber);
|
||||
const selectedCycleData =
|
||||
cycleDashboardData?.cycles?.find((c: any) => c.cycleNumber === selectedCycleNumber)
|
||||
|| cycleDashboardData?.cycles?.[0];
|
||||
const currentCycleNumber = cycleDashboardData?.currentCycle;
|
||||
const cycleCount = cycleDashboardData?.cycles?.length || 0;
|
||||
const shouldScrollCycles = cycleCount > 2;
|
||||
const cycleCardWidth = cycleCount <= 1
|
||||
? windowWidth - 64
|
||||
: shouldScrollCycles
|
||||
? Math.max(156, Math.min(188, (windowWidth - 92) / 2))
|
||||
: (windowWidth - 76) / 2;
|
||||
|
||||
const renderSkeletons = () => (
|
||||
<View style={styles.skeletonContainer}>
|
||||
@@ -150,7 +167,7 @@ const GroupDetails = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<AppView paddingHorizontal={0} style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
||||
<AppView paddingHorizontal={0} keyboardSafe={false} dismissKeyboardOnTap={false} style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
||||
{/* Standard Header */}
|
||||
<View style={[styles.header, { backgroundColor: theme.colors.background }]}>
|
||||
<TouchableOpacity onPress={() => navigation.goBack()} style={[styles.iconButton, { backgroundColor: theme.colors.card }]}>
|
||||
@@ -163,11 +180,14 @@ const GroupDetails = () => {
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={[theme.colors.primary]} tintColor={theme.colors.onPrimary} />
|
||||
}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
nestedScrollEnabled={true}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{loading ? renderSkeletons() : (
|
||||
<View style={styles.contentPadding}>
|
||||
@@ -255,18 +275,156 @@ const GroupDetails = () => {
|
||||
</View>
|
||||
|
||||
{/* Cycle Timeline */}
|
||||
{cycleItems.length > 0 && (
|
||||
{cycleDashboardData?.cycles?.length > 0 && (
|
||||
<View style={styles.sectionContainer}>
|
||||
<Text style={[styles.sectionHeader, { color: theme.colors.textPrimary, fontFamily: FontsFamily.nunitoBold }]}>Cycle Statement</Text>
|
||||
{/* <CustomDropdown
|
||||
items={cycleItems}
|
||||
selectedValue={selectedCycleNumber}
|
||||
onValueChange={setSelectedCycleNumber}
|
||||
placeholder="Select Statement Cycle"
|
||||
/> */}
|
||||
<View style={[styles.cycleSelectorShell, { backgroundColor: theme.colors.card, borderColor: theme.colors.borderLight }]}>
|
||||
<View style={styles.cycleSelectorHeader}>
|
||||
<View style={styles.cycleSelectorCopy}>
|
||||
<Text style={[styles.cycleSelectorEyebrow, { color: theme.colors.textMuted, fontFamily: FontsFamily.nunitoSemiBold }]}>
|
||||
Statement Viewer
|
||||
</Text>
|
||||
<Text style={[styles.cycleSelectorTitle, { color: theme.colors.textPrimary, fontFamily: FontsFamily.nunitoBold }]}>
|
||||
Viewing Cycle {selectedCycleData?.cycleNumber}
|
||||
</Text>
|
||||
<Text style={[styles.cycleSelectorHint, { color: theme.colors.textMuted, fontFamily: FontsFamily.nunitoRegular }]}>
|
||||
Switch between past and current cycles to review collections.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.currentCycleBadge, { backgroundColor: theme.colors.primarySoft }]}>
|
||||
<LucideIcon name="time-outline" size={14} color={theme.colors.primary} />
|
||||
<Text style={[styles.currentCycleBadgeText, { color: theme.colors.primary, fontFamily: FontsFamily.nunitoBold }]}>
|
||||
Current #{currentCycleNumber}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
horizontal
|
||||
scrollEnabled={shouldScrollCycles}
|
||||
nestedScrollEnabled
|
||||
directionalLockEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={[
|
||||
styles.cycleRailContent,
|
||||
!shouldScrollCycles && styles.cycleRailStaticContent
|
||||
]}
|
||||
>
|
||||
{cycleDashboardData?.cycles?.map((cycle: any) => {
|
||||
const isSelected = cycle.cycleNumber === selectedCycleNumber;
|
||||
const isCurrent = cycle.cycleNumber === currentCycleNumber;
|
||||
const completedCount = cycle.paidParticipants || 0;
|
||||
const participantCount = cycle.totalParticipants || 0;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={cycle.cycleNumber}
|
||||
onPress={() => setSelectedCycleNumber(cycle.cycleNumber)}
|
||||
activeOpacity={0.92}
|
||||
style={[
|
||||
styles.cycleSwitchCard,
|
||||
{
|
||||
width: cycleCardWidth,
|
||||
backgroundColor: isSelected ? theme.colors.primary : theme.colors.background,
|
||||
borderColor: isSelected ? theme.colors.primary : theme.colors.borderLight,
|
||||
shadowColor: isSelected ? theme.colors.primary : "#000",
|
||||
}
|
||||
]}
|
||||
>
|
||||
<View style={styles.cycleSwitchTopRow}>
|
||||
<View style={[
|
||||
styles.cycleNumberBadge,
|
||||
{ backgroundColor: isSelected ? "rgba(255,255,255,0.18)" : theme.colors.primarySoft }
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.cycleNumberBadgeText,
|
||||
{
|
||||
color: isSelected ? theme.colors.onPrimary : theme.colors.primary,
|
||||
fontFamily: FontsFamily.nunitoExtraBold,
|
||||
}
|
||||
]}>
|
||||
#{cycle.cycleNumber}
|
||||
</Text>
|
||||
</View>
|
||||
{isCurrent && (
|
||||
<View style={[
|
||||
styles.cycleStatusPill,
|
||||
{ backgroundColor: isSelected ? "rgba(255,255,255,0.18)" : theme.colors.success + "18" }
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.cycleStatusPillText,
|
||||
{
|
||||
color: isSelected ? theme.colors.onPrimary : theme.colors.success,
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
}
|
||||
]}>
|
||||
Current
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text style={[
|
||||
styles.cycleSwitchTitle,
|
||||
{
|
||||
color: isSelected ? theme.colors.onPrimary : theme.colors.textPrimary,
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
}
|
||||
]}>
|
||||
Cycle {cycle.cycleNumber}
|
||||
</Text>
|
||||
|
||||
<Text style={[
|
||||
styles.cycleSwitchDates,
|
||||
{
|
||||
color: isSelected ? theme.colors.onPrimary : theme.colors.textMuted,
|
||||
fontFamily: FontsFamily.nunitoRegular,
|
||||
}
|
||||
]}>
|
||||
{formatCycleDateLabel(cycle.cycleStart)} - {formatCycleDateLabel(cycle.cycleEnd)}
|
||||
</Text>
|
||||
|
||||
<View style={styles.cycleSwitchFooter}>
|
||||
<View style={styles.cycleSwitchFooterItem}>
|
||||
<LucideIcon
|
||||
name={isSelected ? "eye-outline" : "calendar-outline"}
|
||||
size={14}
|
||||
color={isSelected ? theme.colors.onPrimary : theme.colors.textMuted}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.cycleSwitchFooterText,
|
||||
{
|
||||
color: isSelected ? theme.colors.onPrimary : theme.colors.textMuted,
|
||||
fontFamily: FontsFamily.nunitoSemiBold,
|
||||
}
|
||||
]}>
|
||||
{isSelected ? "Showing" : "Open"}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[
|
||||
styles.cycleSwitchFooterText,
|
||||
{
|
||||
color: isSelected ? theme.colors.onPrimary : theme.colors.textMuted,
|
||||
fontFamily: FontsFamily.nunitoSemiBold,
|
||||
}
|
||||
]}>
|
||||
{completedCount}/{participantCount} paid
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
{selectedCycleData && cycleDashboardData && (
|
||||
<View style={{ marginTop: 12 }}>
|
||||
<CycleDashboard data={{ ...cycleDashboardData, cycles: [selectedCycleData] }} />
|
||||
<CycleDashboard
|
||||
data={{
|
||||
...cycleDashboardData,
|
||||
currentCycle: selectedCycleData.cycleNumber,
|
||||
cycles: [selectedCycleData]
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -300,9 +458,7 @@ const GroupDetails = () => {
|
||||
{member.role === "Organizer" ? "Group Admin" : "Member"}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={[styles.contactAction, { backgroundColor: theme.colors.background }]}>
|
||||
<LucideIcon name="chatbubble-ellipses-outline" size={18} color={theme.colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -314,7 +470,7 @@ const GroupDetails = () => {
|
||||
onPress={() => setOpenModal(true)}
|
||||
style={[styles.payButton, { backgroundColor: theme.colors.primary }]}
|
||||
>
|
||||
<LucideIcon name="card" size={20} color="#fff" style={{ marginRight: 8 }} />
|
||||
<LucideIcon name="card" size={20} color={theme.colors.onPrimary} style={{ marginRight: 8 }} />
|
||||
<Text style={[styles.payButtonText, { color: theme.colors.onPrimary, fontFamily: FontsFamily.nunitoBold }]}>
|
||||
Pay Contribution
|
||||
</Text>
|
||||
@@ -330,9 +486,10 @@ const GroupDetails = () => {
|
||||
visible={openModal}
|
||||
onClose={() => setOpenModal(false)}
|
||||
onConfirm={() => {
|
||||
setOpenModal(false);
|
||||
navigation.navigate('PaymentMode' as any, {
|
||||
groupId: group.id || route?.params?.groupId,
|
||||
setOpenModal(false);
|
||||
navigation.navigate('PaymentMode' as any, {
|
||||
groupId,
|
||||
routeFrom: 'GroupDetails',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
@@ -365,7 +522,8 @@ const styles = StyleSheet.create({
|
||||
fontSize: responsiveFontSize(2),
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 100,
|
||||
flexGrow: 1,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
contentPadding: {
|
||||
paddingHorizontal: 16,
|
||||
@@ -529,6 +687,112 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 12,
|
||||
paddingLeft: 4,
|
||||
},
|
||||
cycleSelectorShell: {
|
||||
borderRadius: 22,
|
||||
borderWidth: 1,
|
||||
paddingVertical: 18,
|
||||
paddingLeft: 16,
|
||||
marginBottom: 4,
|
||||
},
|
||||
cycleSelectorHeader: {
|
||||
paddingRight: 16,
|
||||
marginBottom: 16,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
cycleSelectorCopy: {
|
||||
flex: 1,
|
||||
paddingRight: 12,
|
||||
},
|
||||
cycleSelectorEyebrow: {
|
||||
fontSize: responsiveFontSize(1.25),
|
||||
letterSpacing: 1,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 6,
|
||||
},
|
||||
cycleSelectorTitle: {
|
||||
fontSize: responsiveFontSize(2),
|
||||
marginBottom: 4,
|
||||
},
|
||||
cycleSelectorHint: {
|
||||
fontSize: responsiveFontSize(1.5),
|
||||
},
|
||||
currentCycleBadge: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 999,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
currentCycleBadgeText: {
|
||||
fontSize: responsiveFontSize(1.35),
|
||||
marginLeft: 6,
|
||||
},
|
||||
cycleRailContent: {
|
||||
paddingRight: 16,
|
||||
},
|
||||
cycleRailStaticContent: {
|
||||
width: '100%',
|
||||
paddingRight: 0,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
cycleSwitchCard: {
|
||||
minHeight: 142,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
padding: 14,
|
||||
marginRight: 12,
|
||||
justifyContent: 'space-between',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 14,
|
||||
elevation: 4,
|
||||
},
|
||||
cycleSwitchTopRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 14,
|
||||
},
|
||||
cycleNumberBadge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
},
|
||||
cycleNumberBadgeText: {
|
||||
fontSize: responsiveFontSize(1.45),
|
||||
},
|
||||
cycleStatusPill: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 999,
|
||||
},
|
||||
cycleStatusPillText: {
|
||||
fontSize: responsiveFontSize(1.2),
|
||||
},
|
||||
cycleSwitchTitle: {
|
||||
fontSize: responsiveFontSize(1.9),
|
||||
marginBottom: 6,
|
||||
},
|
||||
cycleSwitchDates: {
|
||||
fontSize: responsiveFontSize(1.35),
|
||||
lineHeight: 18,
|
||||
},
|
||||
cycleSwitchFooter: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: 16,
|
||||
},
|
||||
cycleSwitchFooterItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
cycleSwitchFooterText: {
|
||||
fontSize: responsiveFontSize(1.3),
|
||||
marginLeft: 6,
|
||||
},
|
||||
// Contacts List
|
||||
contactsContainer: {
|
||||
borderRadius: 20,
|
||||
|
||||
@@ -543,7 +543,7 @@ const EditGroupScreen: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{paymentStatus?.cycleStatus === 'READY_FOR_WITHDRAWAL' && paymentStatus?.selectedUser && (
|
||||
<TouchableOpacity onPress={() => setSelectedUserModel(true)} style={styles.secondaryBtn}>
|
||||
<TouchableOpacity onPress={() => setSelectedUserModel(true)} style={styles.primaryBtn}>
|
||||
<Text style={styles.secondaryBtnText}>View Winner</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
@@ -592,9 +592,9 @@ const EditGroupScreen: React.FC = () => {
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<>
|
||||
<LucideIcon name="power" size={24} color={ theme.colors.reverseText} style={{ marginRight: 8 }} />
|
||||
<LucideIcon name="power" size={24} color={theme.colors.reverseText} style={{ marginRight: 8 }} />
|
||||
<Text style={styles.floatingBtnText}>Activate Group</Text>
|
||||
{/* <LucideIcon name="arrow-forward" size={20} color={ theme.colors.reverseText} style={{ marginLeft: 8, opacity: 0.8 }} /> */}
|
||||
{/* <LucideIcon name="arrow-forward" size={20} color={ theme.colors.reverseText} style={{ marginLeft: 8, opacity: 0.8 }} /> */}
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
@@ -709,7 +709,7 @@ const getStyles = (theme: any) => StyleSheet.create({
|
||||
|
||||
// Participants
|
||||
participantRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
|
||||
avatarPlaceholder: { width: 40, height: 40, borderRadius: 20, backgroundColor: '#6366F1', justifyContent: 'center', alignItems: 'center', marginRight: 12 },
|
||||
avatarPlaceholder: { width: 40, height: 40, borderRadius: 10, backgroundColor: theme.colors.primary, justifyContent: 'center', alignItems: 'center', marginRight: 12 },
|
||||
pName: { fontSize: 16, fontFamily: FontsFamily.nunitoBold, color: theme.colors.textPrimary },
|
||||
pRole: { fontSize: 12, color: theme.colors.textSecondary },
|
||||
viewAllBtn: { paddingVertical: 8, alignItems: 'center', borderTopWidth: 1, borderTopColor: '#f0f0f0', marginTop: 8 },
|
||||
@@ -718,9 +718,9 @@ const getStyles = (theme: any) => StyleSheet.create({
|
||||
// Actions
|
||||
actionsContainer: { padding: 20 },
|
||||
primaryBtn: { backgroundColor: theme.colors.primary, padding: 16, borderRadius: 12, alignItems: 'center', elevation: 2, marginBottom: 12 },
|
||||
btnText: { color: theme.colors.white, fontSize: 16, fontFamily: FontsFamily.nunitoBold },
|
||||
btnText: { color: theme.colors.reverseText, fontSize: 16, fontFamily: FontsFamily.nunitoBold },
|
||||
secondaryBtn: { backgroundColor: '#f0f0f0', padding: 16, borderRadius: 12, alignItems: 'center' },
|
||||
secondaryBtnText: { color: theme.colors.textPrimary, fontSize: 16, fontFamily: FontsFamily.nunitoBold },
|
||||
secondaryBtnText: { color: theme.colors.reverseText, fontSize: 16, fontFamily: FontsFamily.nunitoBold },
|
||||
|
||||
// Modal
|
||||
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -35,10 +35,12 @@ import FontsFamily from '../../constents/FontsFamily';
|
||||
import { RootTabParamList } from '../../navigation/BottomTabs';
|
||||
|
||||
type ManagementRouteProp = RouteProp<RootTabParamList, 'Management'>;
|
||||
type ActiveTab = 'My Groups' | 'Joined Groups' | 'Requests' | 'Your Requests';
|
||||
type GroupStatusFilter = 'PENDING' | 'APPROVED' | 'ACTIVE' | 'DELAY' | 'COMPLETED' | 'REJECTED' | 'CANCELLED';
|
||||
type ActiveTab = 'My Groups' | 'Joined Groups' | 'Requests' | 'My Requests';
|
||||
type RequestSubTab = 'Join Requests' | 'My Requests';
|
||||
type GroupStatusFilter = 'ALL' | 'PENDING' | 'APPROVED' | 'ACTIVE' | 'DELAY' | 'COMPLETED' | 'REJECTED' | 'CANCELLED';
|
||||
|
||||
const GROUP_STATUS_FILTERS: GroupStatusFilter[] = [
|
||||
'ALL',
|
||||
'PENDING',
|
||||
'APPROVED',
|
||||
'ACTIVE',
|
||||
@@ -49,6 +51,7 @@ const GROUP_STATUS_FILTERS: GroupStatusFilter[] = [
|
||||
];
|
||||
|
||||
const JOINED_GROUP_STATUS_FILTERS: GroupStatusFilter[] = [
|
||||
'ALL',
|
||||
'ACTIVE',
|
||||
'DELAY',
|
||||
'COMPLETED',
|
||||
@@ -72,7 +75,8 @@ const ManagementScreen: React.FC = () => {
|
||||
user.role === 'ORGANIZER' ? 'My Groups' : 'Joined Groups'
|
||||
);
|
||||
const [myGroupStatusFilter, setMyGroupStatusFilter] = useState<GroupStatusFilter>('APPROVED');
|
||||
const [joinedGroupStatusFilter, setJoinedGroupStatusFilter] = useState<GroupStatusFilter>('ACTIVE');
|
||||
const [joinedGroupStatusFilter, setJoinedGroupStatusFilter] = useState<GroupStatusFilter>('ALL');
|
||||
const [requestSubTab, setRequestSubTab] = useState<RequestSubTab>('Join Requests');
|
||||
|
||||
useEffect(() => {
|
||||
if (!route.params?.actionTab) return;
|
||||
@@ -80,68 +84,139 @@ const ManagementScreen: React.FC = () => {
|
||||
setActiveTab(routeTab);
|
||||
}, [route.params?.actionTab]);
|
||||
|
||||
const myGroupsParams = useMemo(
|
||||
() => ({ status: myGroupStatusFilter, page: 0, size: 10 }),
|
||||
[myGroupStatusFilter]
|
||||
);
|
||||
const myGroupsParams = {
|
||||
...(myGroupStatusFilter && myGroupStatusFilter !== 'ALL'
|
||||
? { status: myGroupStatusFilter }
|
||||
: {}),
|
||||
page: 0,
|
||||
size: 10,
|
||||
};
|
||||
|
||||
const joinedGroupsParams = {
|
||||
...(joinedGroupStatusFilter && joinedGroupStatusFilter !== 'ALL'
|
||||
? { status: joinedGroupStatusFilter }
|
||||
: {}),
|
||||
page: 0,
|
||||
size: 10,
|
||||
};
|
||||
|
||||
const requestsParams = { page: 0, size: 10 };
|
||||
const myRequestsParams = { page: 0, size: 10 };
|
||||
|
||||
const alwaysFreshFetchOptions = {
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchOnMount: 'always' as const,
|
||||
refetchOnReconnect: true,
|
||||
};
|
||||
|
||||
const joinedGroupsParams = useMemo(
|
||||
() => ({ status: joinedGroupStatusFilter, page: 0, size: 10 }),
|
||||
[joinedGroupStatusFilter]
|
||||
);
|
||||
|
||||
// Data Fetching
|
||||
const { data: myGroupsRes, isLoading: myLoading, refetch: refetchMy } = useFetch<any>(
|
||||
const {
|
||||
data: myGroupsRes,
|
||||
isLoading: myLoading,
|
||||
isFetching: myFetching,
|
||||
refetch: refetchMy,
|
||||
} = useFetch<any>(
|
||||
MY_GROUPS_URL,
|
||||
myGroupsParams,
|
||||
{ enabled: user.role === 'ORGANIZER' }
|
||||
{ enabled: user.role === 'ORGANIZER' && activeTab === 'My Groups', ...alwaysFreshFetchOptions }
|
||||
);
|
||||
const { data: joinedRes, isLoading: joinedLoading, refetch: refetchJoined } = useFetch<any>(
|
||||
const {
|
||||
data: joinedRes,
|
||||
isLoading: joinedLoading,
|
||||
isFetching: joinedFetching,
|
||||
refetch: refetchJoined,
|
||||
} = useFetch<any>(
|
||||
GET_JOINED_GROUP_URL,
|
||||
joinedGroupsParams
|
||||
joinedGroupsParams,
|
||||
{ enabled: activeTab === 'Joined Groups', ...alwaysFreshFetchOptions }
|
||||
);
|
||||
const { data: requestsRes, isLoading: requestsLoading, refetch: refetchRequests } = useFetch<any>(
|
||||
const {
|
||||
data: requestsRes,
|
||||
isLoading: requestsLoading,
|
||||
isFetching: requestsFetching,
|
||||
refetch: refetchRequests,
|
||||
} = useFetch<any>(
|
||||
REQUESTS_URL,
|
||||
undefined,
|
||||
{ enabled: user.role === 'ORGANIZER' }
|
||||
requestsParams,
|
||||
{
|
||||
enabled: user.role === 'ORGANIZER' && activeTab === 'Requests' && requestSubTab === 'Join Requests',
|
||||
...alwaysFreshFetchOptions,
|
||||
}
|
||||
);
|
||||
const { data: userReqRes, isLoading: userReqLoading, refetch: refetchUserReq } = useFetch<any>(
|
||||
const {
|
||||
data: userReqRes,
|
||||
isLoading: userReqLoading,
|
||||
isFetching: userReqFetching,
|
||||
refetch: refetchUserReq,
|
||||
} = useFetch<any>(
|
||||
USER_REQUEST,
|
||||
undefined,
|
||||
{ enabled: user.role !== 'ORGANIZER' }
|
||||
myRequestsParams,
|
||||
{
|
||||
enabled:
|
||||
activeTab === 'My Requests' ||
|
||||
(user.role === 'ORGANIZER' && activeTab === 'Requests' && requestSubTab === 'My Requests'),
|
||||
...alwaysFreshFetchOptions,
|
||||
}
|
||||
);
|
||||
|
||||
const { mutate: updateRequest } = useUpdate<any, any>('');
|
||||
|
||||
const myGroups = useMemo(() => getListFromResponse<MyGroup>(myGroupsRes), [myGroupsRes]);
|
||||
const joinedGroups = useMemo(() => getListFromResponse<any>(joinedRes), [joinedRes]);
|
||||
const incomingRequests = useMemo(() => getListFromResponse<JoinRequest>(requestsRes), [requestsRes]);
|
||||
const myRequests = useMemo(() => getListFromResponse<any>(userReqRes), [userReqRes]);
|
||||
const myGroups = getListFromResponse<MyGroup>(myGroupsRes);
|
||||
const joinedGroups = getListFromResponse<any>(joinedRes);
|
||||
const incomingRequests = getListFromResponse<JoinRequest>(requestsRes);
|
||||
const myRequests = getListFromResponse<any>(userReqRes);
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const onRefresh = useCallback(() => {
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
Promise.all([
|
||||
refetchMy(),
|
||||
refetchJoined(),
|
||||
refetchRequests(),
|
||||
refetchUserReq(),
|
||||
]).finally(() => {
|
||||
const refreshTasks = [refetchJoined(), refetchUserReq()];
|
||||
|
||||
if (user.role === 'ORGANIZER') {
|
||||
refreshTasks.push(refetchMy(), refetchRequests());
|
||||
}
|
||||
|
||||
await Promise.all(refreshTasks).finally(() => {
|
||||
setRefreshing(false);
|
||||
});
|
||||
}, [refetchMy, refetchJoined, refetchRequests, refetchUserReq]);
|
||||
}, [refetchMy, refetchJoined, refetchRequests, refetchUserReq, user.role]);
|
||||
|
||||
const refetchActiveTab = useCallback(async () => {
|
||||
if (activeTab === 'My Groups' && user.role === 'ORGANIZER') {
|
||||
await refetchMy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTab === 'Joined Groups') {
|
||||
await refetchJoined();
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTab === 'Requests' && user.role === 'ORGANIZER') {
|
||||
if (requestSubTab === 'Join Requests') {
|
||||
await refetchRequests();
|
||||
} else {
|
||||
await refetchUserReq();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTab === 'My Requests') {
|
||||
await refetchUserReq();
|
||||
}
|
||||
}, [activeTab, requestSubTab, refetchMy, refetchJoined, refetchRequests, refetchUserReq, user.role]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
onRefresh();
|
||||
}, [])
|
||||
refetchActiveTab();
|
||||
}, [refetchActiveTab])
|
||||
);
|
||||
|
||||
const tabs: ActiveTab[] = useMemo(() =>
|
||||
const tabs: ActiveTab[] =
|
||||
user.role === 'ORGANIZER'
|
||||
? ['My Groups', 'Joined Groups', 'Requests']
|
||||
: ['Joined Groups', 'Your Requests'],
|
||||
[user.role]);
|
||||
: ['Joined Groups', 'My Requests'];
|
||||
|
||||
const handleApprove = (req: JoinRequest) => {
|
||||
updateRequest({ resource: `${APPROVE_REQUEST_URL}${req.requestId}/approve`, payload: {} } as any, {
|
||||
@@ -154,7 +229,7 @@ const ManagementScreen: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDecline = (req: JoinRequest) => {
|
||||
updateRequest({ resource: `${DECLINE_REQUEST_URL}${req.requestId}/reject?reason=/`, payload: {} } as any, {
|
||||
updateRequest({ resource: `${DECLINE_REQUEST_URL}${req.requestId}/reject?reason=no Reason`, payload: {} } as any, {
|
||||
onSuccess: () => {
|
||||
ToastAndroid.show('Request declined', ToastAndroid.SHORT);
|
||||
refetchRequests();
|
||||
@@ -177,19 +252,52 @@ const ManagementScreen: React.FC = () => {
|
||||
switch (activeTab) {
|
||||
case 'My Groups': return myGroups;
|
||||
case 'Joined Groups': return joinedGroups;
|
||||
case 'Requests': return incomingRequests;
|
||||
case 'Your Requests': return myRequests;
|
||||
case 'Requests':
|
||||
return requestSubTab === 'Join Requests' ? incomingRequests : myRequests;
|
||||
case 'My Requests': return myRequests;
|
||||
default: return [];
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = myLoading || joinedLoading || requestsLoading || userReqLoading;
|
||||
const isMyGroupsLoading = myLoading || myFetching;
|
||||
const isJoinedGroupsLoading = joinedLoading || joinedFetching;
|
||||
const isRequestsLoading = requestsLoading || requestsFetching;
|
||||
const isUserRequestsLoading = userReqLoading || userReqFetching;
|
||||
|
||||
const isActiveTabLoading =
|
||||
activeTab === 'My Groups'
|
||||
? isMyGroupsLoading
|
||||
: activeTab === 'Joined Groups'
|
||||
? isJoinedGroupsLoading
|
||||
: activeTab === 'Requests'
|
||||
? requestSubTab === 'Join Requests'
|
||||
? isRequestsLoading
|
||||
: isUserRequestsLoading
|
||||
: isUserRequestsLoading;
|
||||
|
||||
const listData = refreshing || isActiveTabLoading ? [] : getFilteredData();
|
||||
|
||||
return (
|
||||
<AppView paddingHorizontal={0}>
|
||||
<View style={styles.header}>
|
||||
<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 style={styles.headerTitleRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<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>
|
||||
<TouchableOpacity
|
||||
onPress={onRefresh}
|
||||
disabled={refreshing}
|
||||
style={styles.refreshBtn}
|
||||
>
|
||||
<LucideIcon
|
||||
name="sync-outline"
|
||||
size={20}
|
||||
color={theme.colors.textSecondary}
|
||||
style={refreshing ? { opacity: 0.5 } : {}}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.tabContainer, { backgroundColor: theme.colors.borderLight }]}>
|
||||
@@ -291,15 +399,62 @@ const ManagementScreen: React.FC = () => {
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{activeTab === 'Requests' && user.role === 'ORGANIZER' && (
|
||||
<View style={styles.statusFilterContainer}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.statusFilterRow}
|
||||
>
|
||||
{(['Join Requests', 'My Requests'] as RequestSubTab[]).map((tab, index) => {
|
||||
const isActive = requestSubTab === tab;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tab}
|
||||
onPress={() => setRequestSubTab(tab)}
|
||||
style={[
|
||||
styles.statusFilterChip,
|
||||
index !== (['Join Requests', 'My Requests'] as RequestSubTab[]).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,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{tab}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<FlatList
|
||||
data={getFilteredData()}
|
||||
data={listData}
|
||||
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} />;
|
||||
if (activeTab === 'Requests') {
|
||||
if (requestSubTab === 'Join Requests') {
|
||||
return <RequestCard item={item} onApprove={handleApprove} onDecline={handleDecline} />;
|
||||
} else {
|
||||
return <UserRequested item={item} />;
|
||||
}
|
||||
}
|
||||
if (activeTab === 'My Requests') return <UserRequested item={item} />;
|
||||
return null;
|
||||
}}
|
||||
refreshControl={
|
||||
@@ -310,7 +465,7 @@ const ManagementScreen: React.FC = () => {
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
!isLoading ? (
|
||||
!isActiveTabLoading && !refreshing ? (
|
||||
<View style={styles.emptyState}>
|
||||
<LucideIcon name="documents-outline" size={64} color={theme.colors.textMuted} />
|
||||
<Text style={[styles.emptyText, { color: theme.colors.textSecondary }]}>Nothing to show here</Text>
|
||||
@@ -329,6 +484,17 @@ const styles = StyleSheet.create({
|
||||
paddingBottom: 12,
|
||||
zIndex: 10,
|
||||
},
|
||||
headerTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
refreshBtn: {
|
||||
padding: 8,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: responsiveFontSize(3.2),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
|
||||
@@ -13,18 +13,18 @@ import {
|
||||
import LucideIcon from '../../components/common/LucideIcon';
|
||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||
import { responsiveFontSize } from 'react-native-responsive-dimensions';
|
||||
|
||||
import AppView from '../../components/AppView';
|
||||
import { useTheme } from '../../hooks/ui/useTheme';
|
||||
import { useFetch } from '../../hooks/core/useFetch';
|
||||
import { useUpdate } from '../../hooks/core/useUpdate';
|
||||
import { apiCall } from '../../utils/ApiHelper';
|
||||
import { BASE_URL } from '../../utils/ApiUrls';
|
||||
import FontsFamily from '../../constents/FontsFamily';
|
||||
import SkeletonLoader from '../../components/common/SkeletonLoader';
|
||||
import Header from '../../components/common/Header';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const NUM_COLUMNS = 3;
|
||||
const ITEM_SIZE = width / NUM_COLUMNS - 28;
|
||||
const ITEM_SIZE = width / NUM_COLUMNS - 24; // Increased size slightly
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -159,7 +159,7 @@ const RandomSelectUserScreen: React.FC = () => {
|
||||
const itemAnims = useRef<Animated.Value[]>([]).current;
|
||||
const winnerScaleAnim = useRef(new Animated.Value(1)).current;
|
||||
const winnerGlowAnim = useRef(new Animated.Value(0)).current;
|
||||
const bannerSlideAnim = useRef(new Animated.Value(100)).current;
|
||||
const bannerSlideAnim = useRef(new Animated.Value(200)).current;
|
||||
const bannerOpacity = useRef(new Animated.Value(0)).current;
|
||||
const headerPulseAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
@@ -173,13 +173,10 @@ const RandomSelectUserScreen: React.FC = () => {
|
||||
[itemAnims],
|
||||
);
|
||||
|
||||
// --- DATA FETCHING ---
|
||||
const { data: participantsRes, isLoading: isParticipantsLoading } = useFetch<any>(
|
||||
`${BASE_URL}fund/group/all/member?groupId=${groupId}`,
|
||||
{},
|
||||
{
|
||||
enabled: !!groupId,
|
||||
},
|
||||
{ enabled: !!groupId },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -194,35 +191,34 @@ const RandomSelectUserScreen: React.FC = () => {
|
||||
}
|
||||
}, [participantsRes, ensureAnims]);
|
||||
|
||||
// --- MUTATIONS ---
|
||||
const { mutate: selectWinnerApi } = useUpdate<any, any>(
|
||||
`${BASE_URL}fund/method/based/userSelect`,
|
||||
{
|
||||
onSuccess: (res: any) => {
|
||||
if (res.status === 'success' && res.data?.selectedUser?.id) {
|
||||
const winnerData = {
|
||||
id: res.data.selectedUser.id,
|
||||
name: `${res.data.selectedUser.firstName} ${res.data.selectedUser.lastName}`,
|
||||
};
|
||||
const winnerIndex = users.findIndex(u => u.id === winnerData.id);
|
||||
|
||||
if (winnerIndex !== -1) {
|
||||
triggerWinnerReveal(winnerData, winnerIndex);
|
||||
} else {
|
||||
ToastAndroid.show('Winner not found among participants', ToastAndroid.SHORT);
|
||||
resetAnimation();
|
||||
}
|
||||
const selectWinnerApi = async () => {
|
||||
try {
|
||||
const res = await apiCall<any>(
|
||||
`${BASE_URL}fund/method/based/userSelect`,
|
||||
'POST',
|
||||
{ groupId },
|
||||
);
|
||||
if (res?.status === 'success' && res?.data?.selectedUser?.id) {
|
||||
const winnerData = {
|
||||
id: res.data.selectedUser.id,
|
||||
name: `${res.data.selectedUser.firstName} ${res.data.selectedUser.lastName}`,
|
||||
};
|
||||
const winnerIndex = users.findIndex(u => u.id === winnerData.id);
|
||||
if (winnerIndex !== -1) {
|
||||
triggerWinnerReveal(winnerData, winnerIndex);
|
||||
} else {
|
||||
ToastAndroid.show('Failed to determine a winner', ToastAndroid.SHORT);
|
||||
ToastAndroid.show('Winner lookup failed', ToastAndroid.SHORT);
|
||||
resetAnimation();
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
ToastAndroid.show(err?.message || 'Selection failed', ToastAndroid.SHORT);
|
||||
} else {
|
||||
ToastAndroid.show('Failed to select winner', ToastAndroid.SHORT);
|
||||
resetAnimation();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
ToastAndroid.show(err?.message || 'Api Error', ToastAndroid.SHORT);
|
||||
resetAnimation();
|
||||
}
|
||||
};
|
||||
|
||||
const resetAnimation = () => {
|
||||
setIsAnimating(false);
|
||||
@@ -231,26 +227,17 @@ const RandomSelectUserScreen: React.FC = () => {
|
||||
setShowWinnerReveal(false);
|
||||
winnerScaleAnim.setValue(1);
|
||||
winnerGlowAnim.setValue(0);
|
||||
bannerSlideAnim.setValue(100);
|
||||
bannerSlideAnim.setValue(200);
|
||||
bannerOpacity.setValue(0);
|
||||
itemAnims.forEach(a => a.setValue(0));
|
||||
};
|
||||
|
||||
// --- HEADER PULSE during animation ---
|
||||
useEffect(() => {
|
||||
if (phase === 'scanning' || phase === 'slowing') {
|
||||
const pulse = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(headerPulseAnim, {
|
||||
toValue: 1.05,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(headerPulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(headerPulseAnim, { toValue: 1.05, duration: 600, useNativeDriver: true }),
|
||||
Animated.timing(headerPulseAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
|
||||
]),
|
||||
);
|
||||
pulse.start();
|
||||
@@ -260,100 +247,61 @@ const RandomSelectUserScreen: React.FC = () => {
|
||||
}
|
||||
}, [phase, headerPulseAnim]);
|
||||
|
||||
// --- WINNER REVEAL ANIMATION ---
|
||||
const triggerWinnerReveal = (winnerData: { id: string; name: string }, winnerIdx: number) => {
|
||||
setPhase('reveal');
|
||||
setWinner(winnerData);
|
||||
setHighlightedIndex(winnerIdx);
|
||||
setShowWinnerReveal(true);
|
||||
|
||||
// 1. Pop all items down except winner
|
||||
const itemAnimations = itemAnims.map((anim, i) => {
|
||||
if (i === winnerIdx) {
|
||||
return Animated.timing(anim, {
|
||||
toValue: 2, // special "winner" state
|
||||
duration: 400,
|
||||
toValue: 2,
|
||||
duration: 500,
|
||||
easing: Easing.out(Easing.back(1.5)),
|
||||
useNativeDriver: true,
|
||||
});
|
||||
}
|
||||
return Animated.timing(anim, {
|
||||
toValue: -1, // "dim" state
|
||||
toValue: -1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
});
|
||||
});
|
||||
|
||||
// 2. Winner scale + glow
|
||||
const winnerReveal = Animated.parallel([
|
||||
Animated.spring(winnerScaleAnim, {
|
||||
toValue: 1.2,
|
||||
friction: 4,
|
||||
tension: 80,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(winnerGlowAnim, {
|
||||
toValue: 1,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
// 3. Banner slide in
|
||||
const bannerReveal = Animated.parallel([
|
||||
Animated.spring(bannerSlideAnim, {
|
||||
toValue: 0,
|
||||
friction: 6,
|
||||
tension: 60,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(bannerOpacity, {
|
||||
toValue: 1,
|
||||
duration: 500,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
const revealGroup = Animated.parallel([
|
||||
Animated.spring(winnerScaleAnim, { toValue: 1.25, friction: 3, tension: 70, useNativeDriver: true }),
|
||||
Animated.timing(winnerGlowAnim, { toValue: 1, duration: 800, useNativeDriver: true }),
|
||||
Animated.spring(bannerSlideAnim, { toValue: 0, friction: 6, tension: 50, useNativeDriver: true }),
|
||||
Animated.timing(bannerOpacity, { toValue: 1, duration: 600, useNativeDriver: true }),
|
||||
]);
|
||||
|
||||
Animated.sequence([
|
||||
Animated.parallel(itemAnimations),
|
||||
Animated.delay(200),
|
||||
winnerReveal,
|
||||
Animated.delay(300),
|
||||
bannerReveal,
|
||||
Animated.delay(100),
|
||||
revealGroup,
|
||||
]).start(() => {
|
||||
setTimeout(() => {
|
||||
navigation.replace('EditGroup', { groupId });
|
||||
}, 2500);
|
||||
}, 3500);
|
||||
});
|
||||
};
|
||||
|
||||
// --- MAIN SELECTION ANIMATION (3-phase spotlight roulette) ---
|
||||
const handleStartSelection = () => {
|
||||
const availableUsers = users.filter(u => !u.selected);
|
||||
|
||||
if (availableUsers.length === 0 || isAnimating) {
|
||||
ToastAndroid.show(
|
||||
'All users are already selected or selection in progress',
|
||||
ToastAndroid.SHORT,
|
||||
);
|
||||
ToastAndroid.show('All members selected already', ToastAndroid.SHORT);
|
||||
return;
|
||||
}
|
||||
|
||||
resetAnimation();
|
||||
setWinner(null);
|
||||
setIsAnimating(true);
|
||||
setPhase('scanning');
|
||||
|
||||
// Phase 1: Fast random scanning (accelerate then sustain)
|
||||
let count = 0;
|
||||
const totalPhase1 = 18;
|
||||
const totalPhase2 = 15;
|
||||
const totalPhase1 = 15;
|
||||
const totalPhase2 = 12;
|
||||
|
||||
const runPhase = (
|
||||
phaseCount: number,
|
||||
getDelay: (i: number) => number,
|
||||
onComplete: () => void,
|
||||
) => {
|
||||
const runPhase = (phaseCount: number, getDelay: (i: number) => number, onComplete: () => void) => {
|
||||
let i = 0;
|
||||
const step = () => {
|
||||
if (i >= phaseCount) {
|
||||
@@ -365,14 +313,9 @@ const RandomSelectUserScreen: React.FC = () => {
|
||||
const origIdx = users.findIndex(u => u.id === user.id);
|
||||
setHighlightedIndex(origIdx);
|
||||
|
||||
// Animate the highlighted item
|
||||
ensureAnims(users.length);
|
||||
itemAnims.forEach((anim, idx) => {
|
||||
Animated.timing(anim, {
|
||||
toValue: idx === origIdx ? 1 : 0,
|
||||
duration: 60,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
Animated.timing(anim, { toValue: idx === origIdx ? 1 : 0, duration: 60, useNativeDriver: true }).start();
|
||||
});
|
||||
|
||||
i++;
|
||||
@@ -381,372 +324,152 @@ const RandomSelectUserScreen: React.FC = () => {
|
||||
step();
|
||||
};
|
||||
|
||||
// Phase 1: Fast scanning — starts moderate, gets fast
|
||||
runPhase(
|
||||
totalPhase1,
|
||||
i => Math.max(50, 150 - i * 6),
|
||||
() => {
|
||||
setPhase('slowing');
|
||||
// Phase 2: Dramatic slowdown — each step takes longer
|
||||
runPhase(
|
||||
totalPhase2,
|
||||
i => 100 + i * 40 + Math.pow(i, 1.6) * 8,
|
||||
() => {
|
||||
// Final api call to determine real winner
|
||||
selectWinnerApi({ groupId });
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
runPhase(totalPhase1, i => Math.max(50, 150 - i * 8), () => {
|
||||
setPhase('slowing');
|
||||
runPhase(totalPhase2, i => 100 + i * 50 + Math.pow(i, 1.5) * 10, () => {
|
||||
selectWinnerApi();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// --- RENDER ITEM ---
|
||||
const renderUserCard = (item: User, index: number) => {
|
||||
const isHighlighted = index === highlightedIndex;
|
||||
const isWinnerCard = winner?.id === item.id && phase === 'reveal';
|
||||
ensureAnims(users.length);
|
||||
const itemAnim = itemAnims[index] || new Animated.Value(0);
|
||||
|
||||
const isDark = theme.mode === 'dark';
|
||||
|
||||
// Dynamic colors
|
||||
const cardBg = isWinnerCard
|
||||
? isDark ? '#1a3d2a' : '#e8f5ee'
|
||||
: item.selected
|
||||
? theme.colors.surface
|
||||
: theme.colors.background;
|
||||
|
||||
const borderColor = isWinnerCard
|
||||
? theme.colors.success
|
||||
: isHighlighted
|
||||
? isDark ? '#818CF8' : '#6366F1'
|
||||
: theme.colors.borderLight;
|
||||
|
||||
const textColor = isWinnerCard
|
||||
? theme.colors.success
|
||||
: item.selected
|
||||
? theme.colors.textMuted
|
||||
: theme.colors.textPrimary;
|
||||
|
||||
const iconColor = isWinnerCard
|
||||
? theme.colors.success
|
||||
: isHighlighted
|
||||
? isDark ? '#818CF8' : '#6366F1'
|
||||
: item.selected
|
||||
? theme.colors.textMuted
|
||||
: theme.colors.textSecondary;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
key={item.id}
|
||||
style={[
|
||||
styles.gridItem,
|
||||
{
|
||||
backgroundColor: cardBg,
|
||||
borderColor: borderColor,
|
||||
borderWidth: isHighlighted || isWinnerCard ? 2.5 : 1,
|
||||
transform: [
|
||||
{
|
||||
scale: isWinnerCard
|
||||
? winnerScaleAnim
|
||||
: itemAnim.interpolate({
|
||||
inputRange: [-1, 0, 1],
|
||||
outputRange: [0.92, 1, 1.08],
|
||||
extrapolate: 'clamp',
|
||||
}),
|
||||
},
|
||||
],
|
||||
backgroundColor: isWinnerCard
|
||||
? theme.colors.success + '10'
|
||||
: item.selected ? theme.colors.surface : theme.colors.card,
|
||||
borderColor: isWinnerCard
|
||||
? theme.colors.success
|
||||
: isHighlighted ? theme.colors.primary : theme.colors.borderLight,
|
||||
borderWidth: isHighlighted || isWinnerCard ? 2 : 1,
|
||||
transform: [{
|
||||
scale: isWinnerCard ? winnerScaleAnim : itemAnim.interpolate({
|
||||
inputRange: [-1, 0, 1], outputRange: [0.94, 1, 1.1], extrapolate: 'clamp',
|
||||
}),
|
||||
}],
|
||||
opacity: itemAnim.interpolate({
|
||||
inputRange: [-1, 0, 1, 2],
|
||||
outputRange: [0.4, 1, 1, 1],
|
||||
extrapolate: 'clamp',
|
||||
inputRange: [-1, 0, 1], outputRange: [0.35, 1, 1], extrapolate: 'clamp',
|
||||
}),
|
||||
// Elevated shadow for highlighted / winner
|
||||
...(isHighlighted && !isWinnerCard
|
||||
? {
|
||||
elevation: 12,
|
||||
shadowColor: isDark ? '#818CF8' : '#6366F1',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 16,
|
||||
}
|
||||
: isWinnerCard
|
||||
? {
|
||||
elevation: 16,
|
||||
shadowColor: theme.colors.success,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.6,
|
||||
shadowRadius: 20,
|
||||
}
|
||||
: {
|
||||
elevation: 2,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 4,
|
||||
}),
|
||||
shadowColor: isWinnerCard ? theme.colors.success : theme.colors.primary,
|
||||
shadowOpacity: isHighlighted || isWinnerCard ? 0.3 : 0,
|
||||
elevation: isHighlighted || isWinnerCard ? 10 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Glow overlay for highlighted */}
|
||||
{isHighlighted && !isWinnerCard && (
|
||||
<View
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
{
|
||||
borderRadius: 16,
|
||||
backgroundColor: isDark ? 'rgba(129, 140, 248, 0.08)' : 'rgba(99, 102, 241, 0.06)',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Winner pulse rings */}
|
||||
{isWinnerCard && showWinnerReveal && (
|
||||
<View style={[StyleSheet.absoluteFill, { alignItems: 'center', justifyContent: 'center' }]}>
|
||||
<PulseRing delay={0} color={theme.colors.success} size={ITEM_SIZE} />
|
||||
<PulseRing delay={250} color={theme.colors.success} size={ITEM_SIZE} />
|
||||
<PulseRing delay={500} color={theme.colors.success} size={ITEM_SIZE} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Winner sparkle particles */}
|
||||
{isWinnerCard && showWinnerReveal && (
|
||||
<View style={[StyleSheet.absoluteFill, { alignItems: 'center', justifyContent: 'center' }]}>
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<SparkleParticle
|
||||
key={`sparkle-${i}`}
|
||||
delay={i * 60}
|
||||
startX={ITEM_SIZE / 2 - 3}
|
||||
startY={ITEM_SIZE / 2 - 3}
|
||||
color={
|
||||
i % 3 === 0
|
||||
? theme.colors.success
|
||||
: i % 3 === 1
|
||||
? isDark ? '#818CF8' : '#6366F1'
|
||||
: theme.colors.warning
|
||||
}
|
||||
size={4 + Math.random() * 3}
|
||||
/>
|
||||
<PulseRing delay={300} color={theme.colors.success} size={ITEM_SIZE} />
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<SparkleParticle key={i} delay={i * 80} startX={ITEM_SIZE / 2} startY={ITEM_SIZE / 2}
|
||||
color={i % 2 === 0 ? theme.colors.success : theme.colors.warning} size={3 + Math.random() * 4} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Winner crown badge */}
|
||||
{isWinnerCard && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.crownBadge,
|
||||
{
|
||||
backgroundColor: theme.colors.success,
|
||||
opacity: winnerGlowAnim,
|
||||
transform: [
|
||||
{
|
||||
scale: winnerGlowAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, 1],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Animated.View style={[styles.winnerIcon, { backgroundColor: theme.colors.success, opacity: winnerGlowAnim }]}>
|
||||
<LucideIcon name="trophy" size={12} color={theme.colors.white} />
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Already selected badge */}
|
||||
{item.selected && !isWinnerCard && (
|
||||
<View style={[styles.selectedBadge, { backgroundColor: theme.colors.surface }]}>
|
||||
<LucideIcon name="checkmark" size={10} color={theme.colors.textMuted} />
|
||||
<View style={[styles.disabledOverlay, { backgroundColor: theme.colors.surface + '80' }]}>
|
||||
<LucideIcon name="checkmark-circle" size={20} color={theme.colors.textMuted} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Avatar circle */}
|
||||
<View
|
||||
style={[
|
||||
styles.avatarCircle,
|
||||
{
|
||||
backgroundColor: isHighlighted && !isWinnerCard
|
||||
? isDark ? 'rgba(129, 140, 248, 0.15)' : 'rgba(99, 102, 241, 0.1)'
|
||||
: isWinnerCard
|
||||
? isDark ? 'rgba(59, 142, 98, 0.2)' : 'rgba(46, 138, 87, 0.1)'
|
||||
: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<LucideIcon
|
||||
name={isWinnerCard ? 'trophy' : 'person'}
|
||||
size={isWinnerCard ? 26 : 22}
|
||||
color={iconColor}
|
||||
/>
|
||||
<View style={[styles.avatarBox, {
|
||||
backgroundColor: isWinnerCard ? theme.colors.success + '20' : theme.colors.surface
|
||||
}]}>
|
||||
<LucideIcon name={isWinnerCard ? "trophy" : "person"} size={26} color={isWinnerCard ? theme.colors.success : theme.colors.textSecondary} />
|
||||
</View>
|
||||
|
||||
{/* Name */}
|
||||
<Text
|
||||
style={[
|
||||
styles.gridText,
|
||||
{
|
||||
color: textColor,
|
||||
fontFamily: isWinnerCard ? FontsFamily.nunitoBold : FontsFamily.nunitoMedium,
|
||||
},
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
<Text style={[styles.userName, {
|
||||
color: isWinnerCard ? theme.colors.success : item.selected ? theme.colors.textMuted : theme.colors.textPrimary,
|
||||
fontFamily: isWinnerCard ? FontsFamily.nunitoExtraBold : FontsFamily.nunitoBold,
|
||||
}]} numberOfLines={2}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
// --- STATUS TEXT ---
|
||||
const getPhaseText = () => {
|
||||
switch (phase) {
|
||||
case 'scanning':
|
||||
return 'Scanning participants...';
|
||||
case 'slowing':
|
||||
return 'Narrowing down...';
|
||||
case 'reveal':
|
||||
return '🎉 Winner Found!';
|
||||
default:
|
||||
return 'Tap below to start selection';
|
||||
}
|
||||
};
|
||||
|
||||
// --- LOADING STATE ---
|
||||
if (isParticipantsLoading) {
|
||||
return (
|
||||
<AppView style={{ backgroundColor: theme.colors.background }}>
|
||||
<View style={styles.header}>
|
||||
<SkeletonLoader width={220} height={36} style={{ marginBottom: 10, borderRadius: 8 }} />
|
||||
<SkeletonLoader width={140} height={18} style={{ borderRadius: 6 }} />
|
||||
</View>
|
||||
<View style={styles.skeletonGrid}>
|
||||
<AppView backgroundColor={theme.colors.background}>
|
||||
<Header title="Winner Selection" showBack={true} />
|
||||
<View style={styles.loadingContainer}>
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<SkeletonLoader key={i} width={ITEM_SIZE} height={ITEM_SIZE + 10} style={{ borderRadius: 16 }} />
|
||||
<SkeletonLoader key={i} width={ITEM_SIZE} height={ITEM_SIZE + 20} style={{ borderRadius: 20 }} />
|
||||
))}
|
||||
</View>
|
||||
</AppView>
|
||||
);
|
||||
}
|
||||
|
||||
// --- MAIN RENDER ---
|
||||
return (
|
||||
<AppView paddingHorizontal={0} style={{ backgroundColor: theme.colors.background }}>
|
||||
{/* Header */}
|
||||
<Animated.View style={[styles.header, { transform: [{ scale: headerPulseAnim }] }]}>
|
||||
<Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}>
|
||||
{phase === 'reveal' ? '🏆 Winner!' : '🎯 Winner Selection'}
|
||||
</Text>
|
||||
<View style={[styles.cycleBadge, { backgroundColor: theme.colors.surface }]}>
|
||||
<LucideIcon name="sync-outline" size={14} color={theme.colors.textSecondary} />
|
||||
<Text style={[styles.cycleText, { color: theme.colors.textSecondary }]}>
|
||||
Cycle #{cycleNumber}
|
||||
</Text>
|
||||
<AppView paddingHorizontal={0} backgroundColor={theme.colors.surface}>
|
||||
<Header title="Payout Draw" showBack={true} />
|
||||
|
||||
<Animated.View style={[styles.hero, { transform: [{ scale: headerPulseAnim }] }]}>
|
||||
<View style={[styles.tag, { backgroundColor: theme.colors.primarySoft }]}>
|
||||
<Text style={[styles.tagText, { color: theme.colors.primary }]}>Cycle #{cycleNumber}</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.phaseText,
|
||||
{
|
||||
color:
|
||||
phase === 'reveal'
|
||||
? theme.colors.success
|
||||
: phase !== 'idle'
|
||||
? theme.mode === 'dark' ? '#818CF8' : '#6366F1'
|
||||
: theme.colors.textMuted,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{getPhaseText()}
|
||||
<Text style={[styles.phaseTitle, { color: theme.colors.textPrimary }]}>
|
||||
{phase === 'reveal' ? 'Lucky Winner!' : phase === 'idle' ? 'Select Receiver' : 'Selecting...'}
|
||||
</Text>
|
||||
<Text style={[styles.phaseSub, { color: phase === 'reveal' ? theme.colors.success : theme.colors.textSecondary }]}>
|
||||
{phase === 'reveal' ? 'Fund receiver for this month' : phase !== 'idle' ? 'Determining the fair winner...' : 'Tap the button below to draw'}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* Participants Grid */}
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.grid}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.gridInner}>
|
||||
{users.map((user, index) => renderUserCard(user, index))}
|
||||
<ScrollView contentContainerStyle={styles.scrollContainer} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.userGrid}>
|
||||
{users.map((u, i) => renderUserCard(u, i))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer */}
|
||||
<View
|
||||
style={[
|
||||
styles.footer,
|
||||
{
|
||||
backgroundColor: theme.colors.background,
|
||||
borderTopColor: theme.colors.borderLight,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Winner Banner */}
|
||||
<View style={[styles.stickyFooter, { backgroundColor: theme.colors.card, borderTopColor: theme.colors.borderLight }]}>
|
||||
{winner && phase === 'reveal' && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.winnerBanner,
|
||||
{
|
||||
backgroundColor: theme.colors.success,
|
||||
transform: [{ translateY: bannerSlideAnim }],
|
||||
opacity: bannerOpacity,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.trophyCircle}>
|
||||
<LucideIcon name="trophy" size={20} color={theme.colors.success} />
|
||||
<Animated.View style={[styles.winnerFloat, { backgroundColor: theme.colors.success, opacity: bannerOpacity, transform: [{ translateY: bannerSlideAnim }] }]}>
|
||||
<View style={styles.glassCircle}>
|
||||
<LucideIcon name="trophy" size={20} color={theme.colors.white} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.winnerLabel, { color: 'rgba(255,255,255,0.7)' }]}>
|
||||
WINNER
|
||||
</Text>
|
||||
<Text style={[styles.winnerName, { color: theme.colors.white }]}>
|
||||
{winner.name}
|
||||
</Text>
|
||||
<Text style={styles.winnerFloatLabel}>WINNER</Text>
|
||||
<Text style={styles.winnerFloatName}>{winner.name}</Text>
|
||||
</View>
|
||||
<LucideIcon name="sparkles-outline" size={20} color="rgba(255,255,255,0.6)" />
|
||||
<LucideIcon name="sparkles-outline" size={24} color={theme.colors.white + '80'} />
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Action Button */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.actionButton,
|
||||
{
|
||||
backgroundColor: isAnimating
|
||||
? theme.colors.surface
|
||||
: phase === 'reveal'
|
||||
? theme.colors.success
|
||||
: theme.mode === 'dark' ? '#818CF8' : '#6366F1',
|
||||
},
|
||||
]}
|
||||
style={[styles.mainBtn, {
|
||||
backgroundColor: isAnimating ? theme.colors.surface : (winner ? theme.colors.success : theme.colors.primary)
|
||||
}]}
|
||||
onPress={handleStartSelection}
|
||||
disabled={isAnimating}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{isAnimating && phase !== 'reveal' ? (
|
||||
<View style={styles.buttonContent}>
|
||||
<LucideIcon
|
||||
name="sync-outline"
|
||||
size={18}
|
||||
color={theme.colors.textMuted}
|
||||
/>
|
||||
<Text style={[styles.actionButtonText, { color: theme.colors.textMuted }]}>
|
||||
Selecting...
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.buttonContent}>
|
||||
<LucideIcon
|
||||
name={winner ? 'sync-outline' : 'sparkles-outline'}
|
||||
size={18}
|
||||
color={theme.colors.white}
|
||||
/>
|
||||
<Text style={[styles.actionButtonText, { color: theme.colors.white }]}>
|
||||
{winner ? 'Select Again' : 'Start Selection'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.btnContent}>
|
||||
<LucideIcon
|
||||
name={isAnimating ? "sync-outline" : "sparkles-outline"}
|
||||
size={20}
|
||||
color={isAnimating ? theme.colors.reverseText : theme.colors.reverseText}
|
||||
/>
|
||||
<Text style={[styles.btnText, { color: isAnimating ? theme.colors.reverseText : theme.colors.reverseText }]}>
|
||||
{isAnimating ? 'Selecting...' : winner ? 'Select Again' : 'Shuffle & Select'}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</AppView>
|
||||
@@ -754,154 +477,30 @@ const RandomSelectUserScreen: React.FC = () => {
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 16,
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: responsiveFontSize(2.8),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
cycleBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 20,
|
||||
},
|
||||
cycleText: {
|
||||
fontSize: responsiveFontSize(1.5),
|
||||
fontFamily: FontsFamily.nunitoMedium,
|
||||
},
|
||||
phaseText: {
|
||||
fontSize: responsiveFontSize(1.6),
|
||||
fontFamily: FontsFamily.nunitoMedium,
|
||||
marginTop: 2,
|
||||
},
|
||||
grid: {
|
||||
paddingHorizontal: 12,
|
||||
paddingBottom: 160,
|
||||
},
|
||||
gridInner: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
skeletonGrid: {
|
||||
padding: 16,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
loadingContainer: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 12, padding: 16 },
|
||||
hero: { alignItems: 'center', paddingVertical: 24, paddingHorizontal: 20, gap: 6 },
|
||||
tag: { paddingHorizontal: 12, paddingVertical: 4, borderRadius: 20 },
|
||||
tagText: { fontSize: responsiveFontSize(1.4), fontFamily: FontsFamily.nunitoBold },
|
||||
phaseTitle: { fontSize: responsiveFontSize(3), fontFamily: FontsFamily.nunitoExtraBold },
|
||||
phaseSub: { fontSize: responsiveFontSize(1.6), fontFamily: FontsFamily.nunitoMedium },
|
||||
scrollContainer: { paddingBottom: 180, paddingHorizontal: 8 },
|
||||
userGrid: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center' },
|
||||
gridItem: {
|
||||
margin: 6,
|
||||
width: ITEM_SIZE,
|
||||
height: ITEM_SIZE + 10,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 16,
|
||||
padding: 8,
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
},
|
||||
avatarCircle: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
gridText: {
|
||||
textAlign: 'center',
|
||||
fontSize: responsiveFontSize(1.25),
|
||||
lineHeight: responsiveFontSize(1.7),
|
||||
},
|
||||
selectedBadge: {
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
right: 6,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
crownBadge: {
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
right: -6,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10,
|
||||
elevation: 8,
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
padding: 16,
|
||||
paddingBottom: 20,
|
||||
borderTopWidth: 1,
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
winnerBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 14,
|
||||
width: '100%',
|
||||
},
|
||||
trophyCircle: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
winnerLabel: {
|
||||
fontSize: responsiveFontSize(1.1),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
letterSpacing: 1.5,
|
||||
},
|
||||
winnerName: {
|
||||
fontSize: responsiveFontSize(1.9),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
marginTop: 1,
|
||||
},
|
||||
actionButton: {
|
||||
width: '100%',
|
||||
paddingVertical: 15,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 6,
|
||||
shadowColor: '#6366F1',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 12,
|
||||
},
|
||||
buttonContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
actionButtonText: {
|
||||
fontSize: responsiveFontSize(1.9),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
width: ITEM_SIZE, height: ITEM_SIZE + 20, margin: 6, borderRadius: 20,
|
||||
justifyContent: 'center', alignItems: 'center', padding: 10, position: 'relative'
|
||||
},
|
||||
winnerIcon: { position: 'absolute', top: -10, right: -10, width: 28, height: 28, borderRadius: 14, justifyContent: 'center', alignItems: 'center', zIndex: 10, elevation: 5 },
|
||||
disabledOverlay: { position: 'absolute', inset: 0, borderRadius: 20, justifyContent: 'center', alignItems: 'center', zIndex: 5 },
|
||||
avatarBox: { width: 48, height: 48, borderRadius: 24, justifyContent: 'center', alignItems: 'center', marginBottom: 8 },
|
||||
userName: { fontSize: responsiveFontSize(1.4), textAlign: 'center' },
|
||||
stickyFooter: { position: 'absolute', bottom: 0, left: 0, right: 0, padding: 16, borderTopWidth: 1 },
|
||||
winnerFloat: { flexDirection: 'row', alignItems: 'center', gap: 14, padding: 12, borderRadius: 16, marginBottom: 16, elevation: 8 },
|
||||
glassCircle: { width: 40, height: 40, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.2)', justifyContent: 'center', alignItems: 'center' },
|
||||
winnerFloatLabel: { fontSize: responsiveFontSize(1.1), fontFamily: FontsFamily.nunitoBold, color: 'rgba(255,255,255,0.7)', letterSpacing: 1.2 },
|
||||
winnerFloatName: { fontSize: responsiveFontSize(2), fontFamily: FontsFamily.nunitoExtraBold, color: '#fff' },
|
||||
mainBtn: { width: '100%', height: 56, borderRadius: 16, justifyContent: 'center', alignItems: 'center', elevation: 4 },
|
||||
btnContent: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
||||
btnText: { fontSize: responsiveFontSize(2), fontFamily: FontsFamily.nunitoBold },
|
||||
});
|
||||
|
||||
export default RandomSelectUserScreen;
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { StyleSheet, Text, View, Animated, ToastAndroid } from 'react-native';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { StyleSheet, Text, View, Animated, ToastAndroid, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import LucideIcon from '../../components/common/LucideIcon';
|
||||
import { responsiveFontSize } from 'react-native-responsive-dimensions';
|
||||
import CustomModal from '../../components/common/CustomModal';
|
||||
import { useTheme } from '../../hooks/ui/useTheme';
|
||||
import { useUpdate } from '../../hooks/core/useUpdate';
|
||||
import { BASE_URL } from '../../utils/ApiUrls';
|
||||
import FontsFamily from '../../constents/FontsFamily';
|
||||
import { apiCall } from '../../utils/ApiHelper';
|
||||
|
||||
const PAYMENT_MODES = [
|
||||
{ label: 'Cash', value: 'CASH', icon: 'cash-outline' },
|
||||
{ label: 'Online', value: 'ONLINE', icon: 'globe-outline' },
|
||||
|
||||
];
|
||||
|
||||
interface WinnerUserProps {
|
||||
data: {
|
||||
@@ -41,78 +47,73 @@ interface WinnerUserProps {
|
||||
|
||||
const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId }) => {
|
||||
const theme = useTheme();
|
||||
const [selectedMode, setSelectedMode] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const {
|
||||
withdrawalDone,
|
||||
selectionDone,
|
||||
currentCycle,
|
||||
totalParticipants,
|
||||
paidParticipants,
|
||||
pendingParticipants,
|
||||
allPaid,
|
||||
selectedUser,
|
||||
cycleStatus,
|
||||
selectionMethod,
|
||||
canProceedToWithdrawal,
|
||||
} = data || {};
|
||||
|
||||
console.log("data",data)
|
||||
const scaleAnim = useRef(new Animated.Value(0)).current;
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
friction: 5,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
Animated.spring(scaleAnim, { toValue: 1, friction: 5, useNativeDriver: true }).start();
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.05,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, { toValue: 1.05, duration: 800, useNativeDriver: true }),
|
||||
Animated.timing(pulseAnim, { toValue: 1, duration: 800, useNativeDriver: true }),
|
||||
])
|
||||
).start();
|
||||
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
Animated.timing(fadeAnim, { toValue: 1, duration: 1000, useNativeDriver: true }).start();
|
||||
} else {
|
||||
scaleAnim.setValue(0);
|
||||
pulseAnim.setValue(1);
|
||||
fadeAnim.setValue(0);
|
||||
setSelectedMode(null);
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [visible]);
|
||||
}, [fadeAnim, pulseAnim, scaleAnim, visible]);
|
||||
|
||||
const { mutate: withdrawFunds, isPending } = useUpdate<any, any>(
|
||||
`${BASE_URL}payments/withdraw`,
|
||||
{
|
||||
onSuccess: () => {
|
||||
ToastAndroid.show('Withdrawal successful', ToastAndroid.SHORT);
|
||||
const handleWithdraw = async () => {
|
||||
if (!canProceedToWithdrawal) { onClose(); return; }
|
||||
if (!selectedMode) {
|
||||
ToastAndroid.show('Please select a payment mode', ToastAndroid.SHORT);
|
||||
return;
|
||||
}
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const res = await apiCall<any>(
|
||||
`${BASE_URL}payments/withdraw`,
|
||||
'PUT',
|
||||
{
|
||||
groupId: Number(groupId),
|
||||
userId: selectedUser?.userId ?? Number(selectedUser?.id),
|
||||
cycleNumber: currentCycle,
|
||||
paymentMode: selectedMode,
|
||||
|
||||
}
|
||||
);
|
||||
if (res?.status === 'success' || res?.statusCode === 200 || res?.statusCode === 201) {
|
||||
ToastAndroid.show('Withdrawal successful!', ToastAndroid.SHORT);
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
ToastAndroid.show(err?.message || 'Withdrawal failed', ToastAndroid.SHORT);
|
||||
},
|
||||
} else {
|
||||
ToastAndroid.show(res?.message || 'Withdrawal failed', ToastAndroid.SHORT);
|
||||
}
|
||||
} catch (err: any) {
|
||||
ToastAndroid.show(err?.message || 'Withdrawal failed', ToastAndroid.SHORT);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
);
|
||||
|
||||
const handleWithdraw = () => {
|
||||
withdrawFunds({
|
||||
groupId,
|
||||
userId: selectedUser?.id,
|
||||
cycleNumber: currentCycle,
|
||||
});
|
||||
};
|
||||
|
||||
const renderRow = (icon: any, label: string, value: any, color?: string) => (
|
||||
@@ -129,15 +130,24 @@ const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId
|
||||
<CustomModal
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
confirmText={canProceedToWithdrawal ? (isPending ? 'Processing...' : 'Withdraw Funds') : 'Close'}
|
||||
onConfirm={canProceedToWithdrawal ? handleWithdraw : onClose}
|
||||
confirmButton={theme.colors.primary}
|
||||
confirmText={
|
||||
!canProceedToWithdrawal
|
||||
? 'Close'
|
||||
: isProcessing
|
||||
? 'Processing...'
|
||||
: selectedMode
|
||||
? 'Withdraw Funds'
|
||||
: 'Select Payment Mode'
|
||||
}
|
||||
onConfirm={handleWithdraw}
|
||||
confirmButton={canProceedToWithdrawal && selectedMode ? theme.colors.success : theme.colors.textMuted}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.trophyContainer, { backgroundColor: theme.colors.background }]}>
|
||||
<LucideIcon name="trophy" size={32} color={theme.colors.primary} />
|
||||
<View style={[styles.trophyContainer, { backgroundColor: theme.colors.success + '15' }]}>
|
||||
<LucideIcon name="trophy" size={32} color={theme.colors.success} />
|
||||
</View>
|
||||
<Text style={[styles.cycle, { color: theme.colors.textSecondary }]}>Cycle {currentCycle} Winner</Text>
|
||||
<Animated.Text style={[styles.name, { color: theme.colors.textPrimary, transform: [{ scale: pulseAnim }] }]}>
|
||||
@@ -145,12 +155,13 @@ const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId
|
||||
</Animated.Text>
|
||||
</View>
|
||||
|
||||
{/* Details */}
|
||||
<View style={[styles.detailsCard, { backgroundColor: theme.colors.background, borderColor: theme.colors.border }]}>
|
||||
{renderRow('call-outline', 'Mobile', selectedUser?.mobile)}
|
||||
{renderRow('mail-outline', 'Email', selectedUser?.email || 'N/A')}
|
||||
{renderRow('people-outline', 'Participants', `${paidParticipants}/${totalParticipants}`)}
|
||||
{renderRow('people-outline', 'Paid Members', `${paidParticipants}/${totalParticipants}`)}
|
||||
{renderRow(
|
||||
allPaid ? 'checkmark-circle-outline' : 'alert-circle-outline',
|
||||
allPaid ? 'checkmark-circle' : 'alert-circle-outline',
|
||||
'All Paid',
|
||||
allPaid ? 'Yes' : 'No',
|
||||
allPaid ? theme.colors.success : theme.colors.error
|
||||
@@ -163,9 +174,59 @@ const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Animated.Text style={[styles.congrats, { color: theme.colors.success, opacity: fadeAnim }]}>
|
||||
{selectionDone ? 'Congratulations!' : 'Selection Pending'}
|
||||
</Animated.Text>
|
||||
{/* Payment Mode Selector — only when withdrawal is pending */}
|
||||
{canProceedToWithdrawal && !withdrawalDone && (
|
||||
<View style={styles.modeSection}>
|
||||
<Text style={[styles.modeTitle, { color: theme.colors.textPrimary }]}>Select Payment Mode</Text>
|
||||
<Text style={[styles.modeHint, { color: theme.colors.textSecondary }]}>
|
||||
Ask the user how the withdrawal was paid and choose Cash or Online.
|
||||
</Text>
|
||||
<View style={styles.modeRow}>
|
||||
{PAYMENT_MODES.map((mode) => {
|
||||
const isActive = selectedMode === mode.value;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={mode.value}
|
||||
onPress={() => setSelectedMode(mode.value)}
|
||||
style={[
|
||||
styles.modeChip,
|
||||
isActive ? styles.modeChipActive : styles.modeChipInactive,
|
||||
{
|
||||
backgroundColor: isActive ? theme.colors.success + '15' : theme.colors.surface,
|
||||
borderColor: isActive ? theme.colors.success : theme.colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<LucideIcon
|
||||
name={mode.icon}
|
||||
size={18}
|
||||
color={isActive ? theme.colors.success : theme.colors.textSecondary}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.modeLabel,
|
||||
{ color: isActive ? theme.colors.success : theme.colors.textSecondary, fontFamily: isActive ? FontsFamily.nunitoBold : FontsFamily.nunitoMedium },
|
||||
]}>
|
||||
{mode.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Status label */}
|
||||
{!canProceedToWithdrawal && (
|
||||
<Animated.Text style={[styles.statusNote, { color: theme.colors.warning, opacity: fadeAnim }]}>
|
||||
{selectionDone ? 'Awaiting all payments before withdrawal' : 'Selection Pending'}
|
||||
</Animated.Text>
|
||||
)}
|
||||
{canProceedToWithdrawal && isProcessing && (
|
||||
<View style={styles.processingRow}>
|
||||
<ActivityIndicator size="small" color={theme.colors.success} />
|
||||
<Text style={[styles.processingText, { color: theme.colors.textSecondary }]}>Processing withdrawal...</Text>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</CustomModal>
|
||||
@@ -173,26 +234,21 @@ const WinnerUser: React.FC<WinnerUserProps> = ({ data, visible, onClose, groupId
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 10,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
container: { padding: 4 },
|
||||
header: { alignItems: 'center', marginBottom: 20 },
|
||||
trophyContainer: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
cycle: {
|
||||
fontSize: responsiveFontSize(1.6),
|
||||
fontSize: responsiveFontSize(1.5),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
letterSpacing: 1.2,
|
||||
marginBottom: 4,
|
||||
},
|
||||
name: {
|
||||
@@ -201,9 +257,10 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'center',
|
||||
},
|
||||
detailsCard: {
|
||||
padding: 16,
|
||||
padding: 14,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
marginBottom: 20,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
@@ -212,25 +269,53 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
rowLabelContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
rowLabelContainer: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
label: { fontSize: responsiveFontSize(1.6), fontFamily: FontsFamily.nunitoMedium },
|
||||
value: { fontSize: responsiveFontSize(1.6), fontFamily: FontsFamily.nunitoBold },
|
||||
// Payment mode
|
||||
modeSection: { marginBottom: 16 },
|
||||
modeTitle: {
|
||||
fontSize: responsiveFontSize(1.7),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
},
|
||||
label: {
|
||||
modeHint: {
|
||||
fontSize: responsiveFontSize(1.35),
|
||||
fontFamily: FontsFamily.nunitoMedium,
|
||||
marginTop: 4,
|
||||
marginBottom: 10,
|
||||
},
|
||||
modeRow: { flexDirection: 'row', gap: 8 },
|
||||
modeChip: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 10,
|
||||
borderRadius: 12,
|
||||
gap: 4,
|
||||
},
|
||||
modeChipActive: {
|
||||
borderWidth: 2,
|
||||
},
|
||||
modeChipInactive: {
|
||||
borderWidth: 1,
|
||||
},
|
||||
modeLabel: { fontSize: responsiveFontSize(1.35), textAlign: 'center' },
|
||||
// Status
|
||||
statusNote: {
|
||||
fontSize: responsiveFontSize(1.6),
|
||||
fontFamily: FontsFamily.nunitoMedium,
|
||||
},
|
||||
value: {
|
||||
fontSize: responsiveFontSize(1.6),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
},
|
||||
congrats: {
|
||||
fontSize: responsiveFontSize(2.2),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
textAlign: 'center',
|
||||
marginTop: 24,
|
||||
marginTop: 8,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
processingRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
},
|
||||
processingText: { fontSize: responsiveFontSize(1.5), fontFamily: FontsFamily.nunitoMedium },
|
||||
});
|
||||
|
||||
export default WinnerUser;
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
AppState,
|
||||
AppStateStatus,
|
||||
StyleSheet,
|
||||
Text,
|
||||
ToastAndroid,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||
import WebView, { WebViewNavigation } from 'react-native-webview';
|
||||
import { responsiveFontSize } from 'react-native-responsive-dimensions';
|
||||
import AppView from '../../components/AppView';
|
||||
import LucideIcon from '../../components/common/LucideIcon';
|
||||
import FontsFamily from '../../constents/FontsFamily';
|
||||
import { useTheme } from '../../hooks/ui/useTheme';
|
||||
import { apiCall } from '../../utils/ApiHelper';
|
||||
import { BASE_URL } from '../../utils/ApiUrls';
|
||||
|
||||
interface PaymentGatewayRouteParams {
|
||||
paymentUrl: string;
|
||||
paymentData?: any;
|
||||
}
|
||||
|
||||
const FINAL_TXN_STATUSES = ['SUCCESS', 'FAILED', 'REJECTED', 'CANCELLED'];
|
||||
const POLL_START_DELAY_MS = 30 * 1000; // 30 seconds
|
||||
const POLL_INTERVAL_MS = 5 * 1000; // 5 seconds
|
||||
|
||||
const PaymentGatewayWebViewScreen: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const navigation = useNavigation<any>();
|
||||
const route = useRoute<any>();
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
const autoCheckedUrlRef = useRef<string | null>(null);
|
||||
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
|
||||
const isCheckingStatusRef = useRef(false);
|
||||
const hasTerminalStatusRef = useRef(false);
|
||||
const pollStartTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const { paymentUrl, paymentData } = (route.params || {}) as PaymentGatewayRouteParams;
|
||||
|
||||
const [isPageLoading, setIsPageLoading] = useState(true);
|
||||
const [isCheckingStatus, setIsCheckingStatus] = useState(false);
|
||||
|
||||
const parsedPgRequest = useMemo(() => {
|
||||
if (!paymentData?.pgRequestJson) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(paymentData.pgRequestJson);
|
||||
} catch (error) {
|
||||
console.log('Unable to parse pgRequestJson:', error);
|
||||
return null;
|
||||
}
|
||||
}, [paymentData?.pgRequestJson]);
|
||||
|
||||
const pgOrderNo = paymentData?.pgOrderNo || paymentData?.orderNo || '';
|
||||
const completionUrls = useMemo(
|
||||
() => [parsedPgRequest?.callback_url, parsedPgRequest?.cancellation_url].filter(Boolean),
|
||||
[parsedPgRequest]
|
||||
);
|
||||
|
||||
const stopAutoPolling = useCallback(() => {
|
||||
if (pollStartTimeoutRef.current) {
|
||||
clearTimeout(pollStartTimeoutRef.current);
|
||||
pollStartTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const verifyPaymentStatus = useCallback(async ({
|
||||
fallbackMessage = 'Checking payment status...',
|
||||
silentPending = false,
|
||||
}: {
|
||||
fallbackMessage?: string;
|
||||
silentPending?: boolean;
|
||||
} = {}) => {
|
||||
if (!pgOrderNo || isCheckingStatusRef.current || hasTerminalStatusRef.current) {
|
||||
if (!pgOrderNo) {
|
||||
ToastAndroid.show('Payment reference is missing', ToastAndroid.SHORT);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isCheckingStatusRef.current = true;
|
||||
setIsCheckingStatus(true);
|
||||
const statusRes = await apiCall(
|
||||
`${BASE_URL}payments/pg/check-status/${pgOrderNo}`,
|
||||
'GET'
|
||||
) as any;
|
||||
|
||||
const txnStatus = statusRes?.data?.txnStatus;
|
||||
|
||||
if (txnStatus === 'SUCCESS') {
|
||||
hasTerminalStatusRef.current = true;
|
||||
stopAutoPolling();
|
||||
ToastAndroid.show('Payment successful!', ToastAndroid.SHORT);
|
||||
navigation.goBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (txnStatus && FINAL_TXN_STATUSES.includes(txnStatus)) {
|
||||
hasTerminalStatusRef.current = true;
|
||||
stopAutoPolling();
|
||||
ToastAndroid.show(
|
||||
statusRes?.data?.message || statusRes?.message || 'Payment was not completed.',
|
||||
ToastAndroid.LONG
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!silentPending) {
|
||||
ToastAndroid.show(
|
||||
statusRes?.data?.message || statusRes?.message || fallbackMessage,
|
||||
ToastAndroid.SHORT
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Payment status verification failed:', error);
|
||||
if (!silentPending) {
|
||||
ToastAndroid.show('Unable to verify payment right now', ToastAndroid.SHORT);
|
||||
}
|
||||
} finally {
|
||||
isCheckingStatusRef.current = false;
|
||||
setIsCheckingStatus(false);
|
||||
}
|
||||
}, [navigation, pgOrderNo, stopAutoPolling]);
|
||||
|
||||
const handleNavigationChange = useCallback((navState: WebViewNavigation) => {
|
||||
const nextUrl = navState.url || '';
|
||||
const matchedCompletionUrl = completionUrls.find((url) => nextUrl.startsWith(url));
|
||||
|
||||
if (matchedCompletionUrl && autoCheckedUrlRef.current !== nextUrl) {
|
||||
autoCheckedUrlRef.current = nextUrl;
|
||||
verifyPaymentStatus({
|
||||
fallbackMessage: 'Payment submitted. Waiting for confirmation...',
|
||||
});
|
||||
}
|
||||
}, [completionUrls, verifyPaymentStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pgOrderNo) {
|
||||
return;
|
||||
}
|
||||
|
||||
pollStartTimeoutRef.current = setTimeout(() => {
|
||||
verifyPaymentStatus({ silentPending: true });
|
||||
|
||||
pollingIntervalRef.current = setInterval(() => {
|
||||
verifyPaymentStatus({ silentPending: true });
|
||||
}, POLL_INTERVAL_MS);
|
||||
}, POLL_START_DELAY_MS);
|
||||
|
||||
return () => {
|
||||
stopAutoPolling();
|
||||
};
|
||||
}, [pgOrderNo, stopAutoPolling, verifyPaymentStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||
const wasInBackground = /inactive|background/.test(appStateRef.current);
|
||||
appStateRef.current = nextAppState;
|
||||
|
||||
if (wasInBackground && nextAppState === 'active') {
|
||||
verifyPaymentStatus({
|
||||
fallbackMessage: 'Checking payment status after app resume...',
|
||||
silentPending: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [verifyPaymentStatus]);
|
||||
|
||||
const renderLoadingOverlay = () => (
|
||||
<View style={[styles.loadingOverlay, { backgroundColor: theme.colors.background }]}>
|
||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: theme.colors.textSecondary }]}>
|
||||
Opening secure payment page...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!paymentUrl) {
|
||||
return (
|
||||
<AppView
|
||||
style={styles.screen}
|
||||
backgroundColor={theme.colors.background}
|
||||
>
|
||||
<View style={styles.emptyState}>
|
||||
<LucideIcon name="alert-circle-outline" size={56} color={theme.colors.error} />
|
||||
<Text style={[styles.emptyTitle, { color: theme.colors.textPrimary }]}>
|
||||
Payment link unavailable
|
||||
</Text>
|
||||
<Text style={[styles.emptyMessage, { color: theme.colors.textSecondary }]}>
|
||||
We could not open the payment page for this transaction.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={() => navigation.goBack()}
|
||||
style={[styles.primaryButton, { backgroundColor: theme.colors.primary }]}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>Go Back</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</AppView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppView
|
||||
style={styles.screen}
|
||||
backgroundColor={theme.colors.background}
|
||||
paddingHorizontal={0}
|
||||
keyboardSafe={false}
|
||||
dismissKeyboardOnTap={false}
|
||||
>
|
||||
<View style={styles.fullScreenContainer}>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={{ uri: paymentUrl }}
|
||||
style={styles.webView}
|
||||
onLoadStart={() => setIsPageLoading(true)}
|
||||
onLoadEnd={() => setIsPageLoading(false)}
|
||||
onNavigationStateChange={handleNavigationChange}
|
||||
onError={() => {
|
||||
setIsPageLoading(false);
|
||||
ToastAndroid.show('Unable to load the payment page', ToastAndroid.SHORT);
|
||||
}}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled
|
||||
sharedCookiesEnabled
|
||||
thirdPartyCookiesEnabled
|
||||
originWhitelist={['*']}
|
||||
startInLoadingState
|
||||
/>
|
||||
{isPageLoading && renderLoadingOverlay()}
|
||||
|
||||
{/* <View style={styles.topOverlay}>
|
||||
<View
|
||||
style={[
|
||||
styles.topOverlayCard,
|
||||
{
|
||||
backgroundColor: `${theme.colors.card}EE`,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.iconButton}
|
||||
>
|
||||
<LucideIcon name="arrow-back" size={20} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[styles.overlayTitle, { color: theme.colors.textPrimary }]}>
|
||||
Secure Payment
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.overlaySubtitle, { color: theme.colors.textSecondary }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{currencyCode} {amount.toLocaleString()} {pgOrderNo ? `| ${pgOrderNo}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={() => webViewRef.current?.reload()}
|
||||
style={styles.iconButton}
|
||||
>
|
||||
<LucideIcon name="refresh-outline" size={20} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View> */}
|
||||
|
||||
<View style={styles.bottomOverlay}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
disabled={isCheckingStatus}
|
||||
onPress={() => verifyPaymentStatus()}
|
||||
style={[
|
||||
styles.primaryButton,
|
||||
styles.bottomButton,
|
||||
isCheckingStatus && styles.primaryButtonDisabled,
|
||||
{
|
||||
backgroundColor: theme.colors.primary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{isCheckingStatus ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<>
|
||||
<LucideIcon name="shield-checkmark-outline" size={18} color="#fff" />
|
||||
<Text style={styles.primaryButtonText}>Check Status</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</AppView>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentGatewayWebViewScreen;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
},
|
||||
fullScreenContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
webView: {
|
||||
flex: 1,
|
||||
},
|
||||
topOverlay: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
},
|
||||
topOverlayCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderRadius: 18,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
titleContainer: {
|
||||
flex: 1,
|
||||
marginHorizontal: 10,
|
||||
},
|
||||
overlayTitle: {
|
||||
fontSize: responsiveFontSize(1.8),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
},
|
||||
overlaySubtitle: {
|
||||
fontSize: responsiveFontSize(1.2),
|
||||
fontFamily: FontsFamily.nunitoSemiBold,
|
||||
marginTop: 2,
|
||||
},
|
||||
bottomOverlay: {
|
||||
position: 'absolute',
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
},
|
||||
bottomButton: {
|
||||
borderRadius: 18,
|
||||
paddingHorizontal: 18,
|
||||
},
|
||||
iconButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: responsiveFontSize(1.5),
|
||||
fontFamily: FontsFamily.nunitoSemiBold,
|
||||
},
|
||||
primaryButton: {
|
||||
minHeight: 52,
|
||||
borderRadius: 14,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
primaryButtonDisabled: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: responsiveFontSize(1.6),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
gap: 12,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: responsiveFontSize(2.2),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyMessage: {
|
||||
fontSize: responsiveFontSize(1.5),
|
||||
fontFamily: FontsFamily.nunitoRegular,
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
marginBottom: 8,
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -8,7 +7,6 @@ import {
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Image,
|
||||
ToastAndroid,
|
||||
Modal,
|
||||
Easing,
|
||||
@@ -19,10 +17,9 @@ import LucideIcon from '../../components/common/LucideIcon';
|
||||
import AppView from '../../components/AppView';
|
||||
import { useTheme } from '../../hooks/ui/useTheme';
|
||||
import { apiCall } from '../../utils/ApiHelper';
|
||||
import { BASE_URL, CONTRIBUTE, PAYMENT_MODES_URL } from '../../utils/ApiUrls';
|
||||
import { BASE_URL, CONTRIBUTE, PAYMENT_MODES_URL, PAY_ALL_URL } from '../../utils/ApiUrls';
|
||||
import FontsFamily from '../../constents/FontsFamily';
|
||||
import Header from '../../components/common/Header';
|
||||
import { clampRGBA } from 'react-native-reanimated/lib/typescript/Colors';
|
||||
|
||||
interface PaymentMode {
|
||||
id: number;
|
||||
@@ -39,6 +36,18 @@ interface PaymentMode {
|
||||
iconUrl: string;
|
||||
}
|
||||
|
||||
interface PaymentModeRouteParams {
|
||||
groupId?: string | number;
|
||||
groupIds?: Array<string | number>;
|
||||
amount?: number;
|
||||
routeFrom?: string;
|
||||
routeData?: any;
|
||||
}
|
||||
|
||||
const isPaymentRequestSuccessful = (res: any) =>
|
||||
res?.status === true || res?.status === 'success' || res?.statusCode === 200;
|
||||
const WEBVIEW_PAYMENT_MODE_ID = 2;
|
||||
|
||||
// --- Extracted ModeCard Component (hooks must be in a component, not a render function) ---
|
||||
const getBrandColors = (modeCode: string): { bg: string; text: string } => {
|
||||
switch (modeCode) {
|
||||
@@ -68,7 +77,7 @@ const ModeCard: React.FC<ModeCardProps> = ({ item, index, isSelected, isDisabled
|
||||
delay: index * 100,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, []);
|
||||
}, [cardAnim, index]);
|
||||
|
||||
const brandColors = getBrandColors(item.modeCode);
|
||||
|
||||
@@ -130,10 +139,15 @@ const ModeCard: React.FC<ModeCardProps> = ({ item, index, isSelected, isDisabled
|
||||
// --- Main Screen ---
|
||||
const PaymentModeScreen: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const navigation = useNavigation();
|
||||
const navigation = useNavigation<any>();
|
||||
const route = useRoute<any>();
|
||||
const { groupId, routeFrom, routeData = null } = route.params || {};
|
||||
const user = useSelector((state: any) => state.user);
|
||||
const {
|
||||
groupId,
|
||||
groupIds = [],
|
||||
amount = 0,
|
||||
routeFrom,
|
||||
routeData = null,
|
||||
} = (route.params || {}) as PaymentModeRouteParams;
|
||||
|
||||
const [modes, setModes] = useState<PaymentMode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -153,29 +167,63 @@ const PaymentModeScreen: React.FC = () => {
|
||||
// Animations
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const slideAnim = useRef(new Animated.Value(30)).current;
|
||||
const normalizedGroupIds = useMemo(
|
||||
() =>
|
||||
(Array.isArray(groupIds) ? groupIds : []).map((id) => {
|
||||
const numericId = Number(id);
|
||||
return Number.isNaN(numericId) ? id : numericId;
|
||||
}),
|
||||
[groupIds]
|
||||
);
|
||||
const paymentSummary = useMemo(() => {
|
||||
switch (routeFrom) {
|
||||
case 'GroupDetails':
|
||||
return {
|
||||
label: 'Total Payable',
|
||||
currencyCode: payable?.currencyCode || 'UGX',
|
||||
value: Number(payable?.totalPayable || 0),
|
||||
};
|
||||
case 'GroupScreen':
|
||||
return {
|
||||
label: 'Entry Fee',
|
||||
currencyCode: routeData?.currencyCode || 'UGX',
|
||||
value: Number(routeData?.entryFee || amount || 0),
|
||||
};
|
||||
case 'UserProfileStatus':
|
||||
return {
|
||||
label: 'Selected Due',
|
||||
currencyCode: routeData?.currencyCode || 'UGX',
|
||||
value: Number(amount || routeData?.totalPayable || 0),
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [amount, payable, routeData, routeFrom]);
|
||||
|
||||
useEffect(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }),
|
||||
Animated.timing(slideAnim, { toValue: 0, duration: 500, useNativeDriver: true }),
|
||||
]).start();
|
||||
}, []);
|
||||
}, [fadeAnim, slideAnim]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
{ routeFrom === 'GroupDetails' && handleContribution(); }
|
||||
console.log("data", payable);
|
||||
}, []);
|
||||
const handleContribution = async () => {
|
||||
const handleContribution = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiCall(`${CONTRIBUTE}${groupId || route?.params?.groupId}/calculate-contribution`, 'GET') as any;
|
||||
const res = await apiCall(`${CONTRIBUTE}${groupId}/calculate-contribution`, 'GET') as any;
|
||||
setPayable(res.data);
|
||||
} catch (error) {
|
||||
console.log("Error in handleContribution: ", error);
|
||||
}
|
||||
};
|
||||
}, [groupId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (routeFrom === 'GroupDetails') {
|
||||
handleContribution();
|
||||
}
|
||||
}, [handleContribution, routeFrom]);
|
||||
|
||||
// Spin animation for processing modal
|
||||
const startSpinAnimation = () => {
|
||||
const startSpinAnimation = useCallback(() => {
|
||||
spinAnim.setValue(0);
|
||||
spinLoop.current = Animated.loop(
|
||||
Animated.timing(spinAnim, {
|
||||
@@ -186,13 +234,13 @@ const PaymentModeScreen: React.FC = () => {
|
||||
})
|
||||
);
|
||||
spinLoop.current.start();
|
||||
};
|
||||
}, [spinAnim]);
|
||||
|
||||
const stopSpinAnimation = () => {
|
||||
const stopSpinAnimation = useCallback(() => {
|
||||
spinLoop.current?.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cleanupPolling = () => {
|
||||
const cleanupPolling = useCallback(() => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
@@ -202,12 +250,12 @@ const PaymentModeScreen: React.FC = () => {
|
||||
countdownRef.current = null;
|
||||
}
|
||||
stopSpinAnimation();
|
||||
};
|
||||
}, [stopSpinAnimation]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => cleanupPolling();
|
||||
}, []);
|
||||
}, [cleanupPolling]);
|
||||
|
||||
const pollTransactionStatus = (pgOrderNo: string) => {
|
||||
setProcessingVisible(true);
|
||||
@@ -304,6 +352,31 @@ const PaymentModeScreen: React.FC = () => {
|
||||
useEffect(() => {
|
||||
fetchPaymentModes();
|
||||
}, [fetchPaymentModes]);
|
||||
|
||||
const handleSuccessfulPaymentResponse = (res: any, fallbackMessage: string) => {
|
||||
const paymentData = res?.data || res;
|
||||
const paymentUrl = paymentData?.paymentUrl;
|
||||
const pgOrderNo = paymentData?.pgOrderNo || res?.pgOrderNo;
|
||||
|
||||
if (selectedMode === WEBVIEW_PAYMENT_MODE_ID && paymentUrl) {
|
||||
setPaying(false);
|
||||
navigation.replace('PaymentGatewayWebView', {
|
||||
paymentUrl,
|
||||
paymentData,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pgOrderNo) {
|
||||
pollTransactionStatus(pgOrderNo);
|
||||
return;
|
||||
}
|
||||
|
||||
ToastAndroid.show(res?.message || fallbackMessage, ToastAndroid.SHORT);
|
||||
setPaying(false);
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
const handlePaymentForRouteFormGroupScreen = async () => {
|
||||
try {
|
||||
|
||||
@@ -319,19 +392,12 @@ const PaymentModeScreen: React.FC = () => {
|
||||
`${BASE_URL}payments/pg/entry-fee/${groupId}?groupId=${groupId}&paymentId=${selectedMode}&mobileNumber=`,
|
||||
'POST',
|
||||
payload
|
||||
) as any;
|
||||
) as any;
|
||||
|
||||
console.log("payment res",res)
|
||||
console.log("payment res", res)
|
||||
|
||||
if (res?.status === 'success' || res?.statusCode === 200) {
|
||||
const pgOrderNo = res?.data?.pgOrderNo;
|
||||
if (pgOrderNo) {
|
||||
// Start polling for transaction status
|
||||
pollTransactionStatus(pgOrderNo);
|
||||
} else {
|
||||
ToastAndroid.show('Payment initiated but no order number received', ToastAndroid.SHORT);
|
||||
setPaying(false);
|
||||
}
|
||||
if (isPaymentRequestSuccessful(res)) {
|
||||
handleSuccessfulPaymentResponse(res, 'Payment initiated successfully');
|
||||
} else {
|
||||
ToastAndroid.show(res?.data?.message || res?.message || 'Payment failed', ToastAndroid.SHORT);
|
||||
setPaying(false);
|
||||
@@ -358,19 +424,45 @@ const PaymentModeScreen: React.FC = () => {
|
||||
'POST',
|
||||
payload
|
||||
) as any;
|
||||
|
||||
console.log("payment payload ",payload)
|
||||
console.log("payment res ",res)
|
||||
if (res?.status === 'success' || res?.statusCode === 200) {
|
||||
|
||||
const pgOrderNo = res?.data?.pgOrderNo;
|
||||
if (pgOrderNo) {
|
||||
// Start polling for transaction status
|
||||
pollTransactionStatus(pgOrderNo);
|
||||
} else {
|
||||
ToastAndroid.show('Payment initiated but no order number received', ToastAndroid.SHORT);
|
||||
setPaying(false);
|
||||
}
|
||||
|
||||
console.log("payment payload ", payload)
|
||||
console.log("payment res ", res)
|
||||
if (isPaymentRequestSuccessful(res)) {
|
||||
handleSuccessfulPaymentResponse(res, 'Payment initiated successfully');
|
||||
} else {
|
||||
ToastAndroid.show(res?.data?.message || res?.message || 'Payment failed', ToastAndroid.SHORT);
|
||||
setPaying(false);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
ToastAndroid.show('An error occurred during payment', ToastAndroid.SHORT);
|
||||
setPaying(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaymentForUserProfileStatus = async () => {
|
||||
try {
|
||||
if (normalizedGroupIds.length === 0) {
|
||||
ToastAndroid.show('Please select at least one due payment', ToastAndroid.SHORT);
|
||||
return;
|
||||
}
|
||||
|
||||
setPaying(true);
|
||||
const payload = {
|
||||
groupIds: normalizedGroupIds,
|
||||
payAllDue: true,
|
||||
paymentId: selectedMode || 0,
|
||||
mobileNumber: '',
|
||||
};
|
||||
|
||||
const res = await apiCall(
|
||||
PAY_ALL_URL,
|
||||
'POST',
|
||||
payload
|
||||
) as any;
|
||||
|
||||
if (isPaymentRequestSuccessful(res)) {
|
||||
handleSuccessfulPaymentResponse(res, 'Payment initiated successfully');
|
||||
} else {
|
||||
ToastAndroid.show(res?.data?.message || res?.message || 'Payment failed', ToastAndroid.SHORT);
|
||||
setPaying(false);
|
||||
@@ -380,11 +472,15 @@ const PaymentModeScreen: React.FC = () => {
|
||||
setPaying(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmPayment = async () => {
|
||||
console.log("selectedMode", selectedMode)
|
||||
if (!selectedMode) {
|
||||
|
||||
ToastAndroid.show('Please select a payment method', ToastAndroid.SHORT);
|
||||
return;
|
||||
}
|
||||
console.log("routeFrom", routeFrom)
|
||||
switch (routeFrom) {
|
||||
case 'GroupDetails':
|
||||
handlePaymentForRouteFormGroupDetails();
|
||||
@@ -392,6 +488,9 @@ const PaymentModeScreen: React.FC = () => {
|
||||
case 'GroupScreen':
|
||||
handlePaymentForRouteFormGroupScreen();
|
||||
break;
|
||||
case 'UserProfileStatus':
|
||||
handlePaymentForUserProfileStatus();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -404,23 +503,19 @@ const PaymentModeScreen: React.FC = () => {
|
||||
|
||||
<Animated.View style={[styles.container, { opacity: fadeAnim, transform: [{ translateY: slideAnim }] }]}>
|
||||
{/* Amount Summary */}
|
||||
{routeFrom === 'GroupDetails' && <View style={[styles.amountCard, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}>
|
||||
{paymentSummary && <View style={[styles.amountCard, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}>
|
||||
<View style={styles.amountRow}>
|
||||
<LucideIcon name="wallet-outline" size={20} color={theme.colors.primary} />
|
||||
<Text style={[styles.amountLabel, { color: theme.colors.textSecondary }]}>Total Payable</Text>
|
||||
<Text style={[styles.amountLabel, { color: theme.colors.textSecondary }]}>{paymentSummary.label}</Text>
|
||||
</View>
|
||||
<Text style={[styles.amountValue, { color: theme.colors.textPrimary }]}>
|
||||
{payable?.currencyCode || 'UGX'} {Number(payable?.totalPayable || 0).toLocaleString()}
|
||||
</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()}
|
||||
{paymentSummary.currencyCode} {paymentSummary.value.toLocaleString()}
|
||||
</Text>
|
||||
{routeFrom === 'UserProfileStatus' && routeData?.groupCount ? (
|
||||
<Text style={[styles.amountMeta, { color: theme.colors.textMuted }]}>
|
||||
{routeData.groupCount} selected group{routeData.groupCount > 1 ? 's' : ''}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>}
|
||||
|
||||
{/* Payment Methods */}
|
||||
@@ -473,7 +568,7 @@ const PaymentModeScreen: React.FC = () => {
|
||||
) : (
|
||||
<>
|
||||
<LucideIcon name="shield-checkmark-outline" size={20} color={theme.colors.reverseText} style={{ marginRight: 8 }} />
|
||||
<Text style={[styles.confirmText, { color: theme.colors.black }]}>Confirm & Pay</Text>
|
||||
<Text style={[styles.confirmText, { color: theme.colors.reverseText }]}>Confirm & Pay</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
@@ -589,6 +684,11 @@ const styles = StyleSheet.create({
|
||||
fontSize: responsiveFontSize(3),
|
||||
fontFamily: FontsFamily.nunitoExtraBold,
|
||||
},
|
||||
amountMeta: {
|
||||
fontSize: responsiveFontSize(1.2),
|
||||
fontFamily: FontsFamily.nunitoSemiBold,
|
||||
marginTop: 6,
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: 100,
|
||||
gap: 10,
|
||||
|
||||
@@ -134,7 +134,7 @@ const BecomeOrg = () => {
|
||||
|
||||
<CustomInput
|
||||
label="ID Type"
|
||||
placeholder="e.g., Aadhaar, PAN Card"
|
||||
placeholder="e.g., National ID"
|
||||
value={idName}
|
||||
onChangeText={(val) => handleChange("idName", val)}
|
||||
icon="person-outline"
|
||||
|
||||
@@ -25,7 +25,7 @@ type BankFormErrors = {
|
||||
|
||||
const isValidBankName = (value: string) => /^[A-Za-z][A-Za-z\s.&-]{1,}$/.test(value.trim());
|
||||
const isValidAccountNumber = (value: string) => /^\d{9,18}$/.test(value.trim());
|
||||
const isValidIfsc = (value: string) => /^[A-Z]{4}0[A-Z0-9]{6}$/.test(value.trim().toUpperCase());
|
||||
const isValidIfsc = (value: string) => /^[A-Z0-9]{6}$/.test(value.trim().toUpperCase());
|
||||
|
||||
const EditBankDetails = () => {
|
||||
const theme = useTheme();
|
||||
@@ -79,7 +79,7 @@ const EditBankDetails = () => {
|
||||
nextErrors.bankAccountNumber = "Account number must be 9-18 digits.";
|
||||
}
|
||||
if (!isValidIfsc(branchCode)) {
|
||||
nextErrors.branchCode = "Enter a valid IFSC (e.g. HDFC0001234).";
|
||||
nextErrors.branchCode = "Enter a valid branch code.";
|
||||
}
|
||||
|
||||
setErrors(nextErrors);
|
||||
@@ -135,7 +135,7 @@ const EditBankDetails = () => {
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
label="Branch Code (IFSC)"
|
||||
label="Branch Code"
|
||||
placeholder="Enter the branch code"
|
||||
value={branchCode}
|
||||
onChangeText={(val) => handleChange("branchCode", val)}
|
||||
|
||||
@@ -39,6 +39,9 @@ import {
|
||||
USER_MOBILE_MONEY_URL,
|
||||
} from "../../utils/ApiUrls";
|
||||
import SwitchNavigator from "./SwitchNavigator";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { queryClient } from "../../../App";
|
||||
|
||||
interface UserProfile {
|
||||
id: string;
|
||||
@@ -183,9 +186,12 @@ const Profile = () => {
|
||||
refetchStatus();
|
||||
}, [refetchProfile, refetchStatus]);
|
||||
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoggingOut(true);
|
||||
|
||||
try {
|
||||
// 🔹 Remove FCM Token
|
||||
const fcmToken = await getFcmToken(user?.userId?.toString());
|
||||
if (fcmToken) {
|
||||
await fetch(`${BASE_URL}token/remove?fcmToken=${fcmToken}`, {
|
||||
@@ -194,15 +200,34 @@ const Profile = () => {
|
||||
Authorization: `Bearer ${(await Keychain.getGenericPassword() as any).password}`,
|
||||
},
|
||||
});
|
||||
|
||||
await deleteFcmToken(user?.userId?.toString());
|
||||
}
|
||||
|
||||
// 🔹 Clear Redux State
|
||||
dispatch(clearAllNotifications());
|
||||
await Keychain.resetGenericPassword();
|
||||
dispatch(clearUser());
|
||||
navigation.replace("Login");
|
||||
|
||||
// 🔹 Clear AsyncStorage (IMPORTANT 🚨)
|
||||
await AsyncStorage.clear();
|
||||
|
||||
// 🔹 Clear Keychain (secure storage)
|
||||
await Keychain.resetGenericPassword();
|
||||
|
||||
// 🔹 Clear React Query / API Cache (if using)
|
||||
if (queryClient) {
|
||||
queryClient.clear();
|
||||
}
|
||||
|
||||
// 🔹 Reset Navigation (avoid back navigation)
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [{ name: 'Login' }],
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error);
|
||||
} finally {
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13,93 +13,139 @@ import { TRANSACTION } from "../../utils/ApiUrls";
|
||||
import colors from "../../components/colors";
|
||||
import AppView from "../../components/AppView";
|
||||
import Header from "../../components/common/Header";
|
||||
import LucideIcon from "../../components/common/LucideIcon";
|
||||
import { useTheme } from "../../hooks/ui/useTheme";
|
||||
import FontsFamily from "../../constents/FontsFamily";
|
||||
import { responsiveFontSize } from "react-native-responsive-dimensions";
|
||||
|
||||
type Transaction = {
|
||||
id: string;
|
||||
fundGroupId: string;
|
||||
txnId: string;
|
||||
fundGroupId: number;
|
||||
fundGroupName: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
payerMobile?: string;
|
||||
transactionDate: number[];
|
||||
payerMobile: string;
|
||||
transactionDate: string;
|
||||
currency: string;
|
||||
orderNo: string;
|
||||
};
|
||||
|
||||
type ApiResponse = {
|
||||
message: string;
|
||||
status: string;
|
||||
statusCode: number;
|
||||
data: Transaction[];
|
||||
data: {
|
||||
content: Transaction[];
|
||||
};
|
||||
};
|
||||
|
||||
// Helper: convert the array-style date into a JS Date
|
||||
const parseTransactionDate = (arr: number[] | undefined): Date | null => {
|
||||
if (!arr || arr.length < 6) return null;
|
||||
const [year, month, day, hour = 0, minute = 0, second = 0, sub = 0] = arr;
|
||||
|
||||
let milli = 0;
|
||||
if (sub > 999999999) {
|
||||
milli = Math.floor(sub / 1e6);
|
||||
} else if (sub > 1000) {
|
||||
milli = Math.floor(sub / 1e6);
|
||||
} else {
|
||||
milli = sub;
|
||||
}
|
||||
|
||||
return new Date(year, month - 1, day, hour, minute, second, milli);
|
||||
};
|
||||
|
||||
const formatINR = (amount: number) => {
|
||||
const formatCurrency = (amount: number, currency: string = "UGX") => {
|
||||
try {
|
||||
return new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" }).format(amount);
|
||||
return new Intl.NumberFormat("en-UG", {
|
||||
style: "currency",
|
||||
currency: currency || "UGX",
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
} catch (e) {
|
||||
return `UGX${amount.toFixed(2)}`;
|
||||
return `${currency || "UGX"} ${amount.toLocaleString()}`;
|
||||
}
|
||||
};
|
||||
|
||||
const StatusBadge: React.FC<{ status?: string }> = ({ status }) => {
|
||||
const theme = useTheme();
|
||||
const text = status ?? "FAILED";
|
||||
const color =
|
||||
text === "SUCCESS" ? styles.statusSuccess : text === "PENDING" ? styles.statusPending : styles.statusFailed;
|
||||
|
||||
const getStatusColors = (s: string) => {
|
||||
switch (s) {
|
||||
case 'SUCCESS':
|
||||
return { bg: theme.colors.success + '20', text: theme.colors.success };
|
||||
case 'SENT_TO_PROCESSOR':
|
||||
case 'PENDING':
|
||||
return { bg: theme.colors.warning + '20', text: theme.colors.warning };
|
||||
default:
|
||||
return { bg: theme.colors.error + '20', text: theme.colors.error };
|
||||
}
|
||||
};
|
||||
|
||||
const colors = getStatusColors(text);
|
||||
|
||||
return (
|
||||
<View style={[styles.statusBadge, color]}>
|
||||
<Text style={styles.statusText}>{text}</Text>
|
||||
<View style={[styles.statusBadge, { backgroundColor: colors.bg }]}>
|
||||
<Text style={[styles.statusText, { color: colors.text }]}>{text}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const TransactionItem: React.FC<{ item: Transaction }> = ({ item }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const formatDate = (dateStr: string | null): string => {
|
||||
if (!dateStr) return 'N/A';
|
||||
|
||||
const date = new Date(dateStr);
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
|
||||
return `${day}-${month}-${year}`;
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return `${day} ${months[date.getMonth()]} ${date.getFullYear()}`;
|
||||
} catch (e) {
|
||||
return 'N/A';
|
||||
}
|
||||
};
|
||||
|
||||
const dateStr = item.transactionDate ? formatDate(item.transactionDate) : "N/A";
|
||||
const isSuccess = item.status === 'SUCCESS';
|
||||
|
||||
console.log("item of the transaction : ", item)
|
||||
return (
|
||||
<TouchableOpacity activeOpacity={0.8} style={styles.card}>
|
||||
<View style={styles.cardLeft}>
|
||||
<Text style={styles.fundName}>{item.fundGroupName}</Text>
|
||||
<Text style={styles.payer}>Payer: {item.payerMobile ?? "-"}</Text>
|
||||
<Text style={styles.date}>{dateStr}</Text>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
style={[styles.card, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[styles.fundName, { color: theme.colors.textPrimary }]}>{item.fundGroupName}</Text>
|
||||
<Text style={[styles.payer, { color: theme.colors.textSecondary }]}>
|
||||
by {item.payerMobile ?? "-"}
|
||||
</Text>
|
||||
</View>
|
||||
<StatusBadge status={item.status} />
|
||||
</View>
|
||||
|
||||
<View style={styles.cardRight}>
|
||||
<Text style={styles.amount}>{formatINR(item.amount)}</Text>
|
||||
<StatusBadge status={item.status} />
|
||||
<View style={[styles.infoRow, { borderColor: theme.colors.border }]}>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={[styles.infoLabel, { color: theme.colors.textSecondary }]}>Amount</Text>
|
||||
<Text style={[styles.infoValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(item.amount, item.currency)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={[styles.infoLabel, { color: theme.colors.textSecondary }]}>Order No</Text>
|
||||
<Text style={[styles.infoValue, { color: theme.colors.textPrimary }]} numberOfLines={1}>
|
||||
{item.orderNo}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.cardFooter}>
|
||||
<Text style={[styles.dateText, { color: theme.colors.textSecondary }]}>
|
||||
{formatDate(item.transactionDate)}
|
||||
</Text>
|
||||
|
||||
{isSuccess ? (
|
||||
<View style={[styles.successBadge, { backgroundColor: theme.colors.success + '18' }]}>
|
||||
<Text style={[styles.successText, { color: theme.colors.success }]}>✓ SUCCESS</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.typeBadge, { backgroundColor: theme.colors.primarySoft }]}>
|
||||
<Text style={[styles.typeText, { color: theme.colors.primary }]}>CONTRIBUTION</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const TransactionsList: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [message, setMessage] = useState("");
|
||||
@@ -109,7 +155,7 @@ const TransactionsList: React.FC = () => {
|
||||
const res = (await apiCall(TRANSACTION, "GET")) as ApiResponse;
|
||||
console.log("transaction list response : ", res);
|
||||
if (res.statusCode === 200) {
|
||||
setTransactions(res.data);
|
||||
setTransactions(res.data?.content || []);
|
||||
setMessage(res.message);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -134,18 +180,20 @@ const TransactionsList: React.FC = () => {
|
||||
|
||||
const renderEmpty = () => (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyTitle}>No transactions</Text>
|
||||
<Text style={styles.emptySubtitle}>Transactions will appear here when available.</Text>
|
||||
<LucideIcon name="receipt-outline" size={64} color={theme.colors.textMuted} />
|
||||
<Text style={[styles.emptyTitle, { color: theme.colors.textPrimary }]}>No transactions</Text>
|
||||
<Text style={[styles.emptySubtitle, { color: theme.colors.textSecondary }]}>Transactions will appear here when available.</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const isLoading = false; // Add placeholder or hook if needed
|
||||
|
||||
return (
|
||||
<AppView>
|
||||
<Header title="transactions" titleColor={colors.Home} arrowColor={colors.Home} />
|
||||
<Text style={styles.headerTitle}>{message}</Text>
|
||||
<AppView paddingHorizontal={0} backgroundColor={theme.colors.surface}>
|
||||
<Header title="Transactions" titleColor={theme.colors.textPrimary} arrowColor={theme.colors.textPrimary} />
|
||||
<FlatList
|
||||
data={transactions}
|
||||
keyExtractor={(item) => item.id}
|
||||
keyExtractor={(item) => item.txnId || item.orderNo}
|
||||
renderItem={({ item }) => <TransactionItem item={item} />}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
contentContainerStyle={transactions.length === 0 ? { flex: 1 } : undefined}
|
||||
@@ -158,44 +206,114 @@ const TransactionsList: React.FC = () => {
|
||||
export default TransactionsList;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: "#f7f7fb" },
|
||||
headerTitle: { fontSize: 18, fontWeight: "500", color: colors.primary },
|
||||
headerSubtitle: { marginTop: 4, color: "#6b7280" },
|
||||
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: FontsFamily.nunitoSemiBold,
|
||||
color: colors.primary
|
||||
},
|
||||
card: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
padding: 16,
|
||||
// marginHorizontal: 12,
|
||||
marginVertical: 8,
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 12,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.03,
|
||||
borderRadius: 16,
|
||||
padding: 14,
|
||||
marginBottom: 12,
|
||||
marginHorizontal: 14,
|
||||
borderWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
|
||||
cardLeft: { flex: 1 },
|
||||
fundName: { fontSize: 16, fontWeight: "600" },
|
||||
payer: { marginTop: 6, color: "#6b7280" },
|
||||
date: { marginTop: 6, color: "#9ca3af" },
|
||||
|
||||
cardRight: { alignItems: "flex-end", justifyContent: "space-between" },
|
||||
amount: { fontSize: 16, fontWeight: "700" },
|
||||
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 10,
|
||||
},
|
||||
titleContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
fundName: {
|
||||
fontSize: responsiveFontSize(2),
|
||||
fontFamily: FontsFamily.nunitoExtraBold,
|
||||
marginBottom: 2,
|
||||
},
|
||||
payer: {
|
||||
fontSize: responsiveFontSize(1.4),
|
||||
fontFamily: FontsFamily.nunitoRegular,
|
||||
},
|
||||
statusBadge: {
|
||||
marginTop: 8,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 999,
|
||||
borderRadius: 6,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: responsiveFontSize(1.2),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 10,
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
marginBottom: 10,
|
||||
},
|
||||
infoItem: {
|
||||
flex: 1,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: responsiveFontSize(1.3),
|
||||
fontFamily: FontsFamily.nunitoRegular,
|
||||
marginBottom: 2,
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: responsiveFontSize(1.6),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
},
|
||||
cardFooter: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
dateText: {
|
||||
fontSize: responsiveFontSize(1.3),
|
||||
fontFamily: FontsFamily.nunitoMedium,
|
||||
},
|
||||
typeBadge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
},
|
||||
typeText: {
|
||||
fontSize: responsiveFontSize(1.15),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
successBadge: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
},
|
||||
successText: {
|
||||
fontSize: responsiveFontSize(1.3),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 100
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: responsiveFontSize(2),
|
||||
fontFamily: FontsFamily.nunitoBold,
|
||||
marginTop: 16
|
||||
},
|
||||
emptySubtitle: {
|
||||
marginTop: 8,
|
||||
fontSize: responsiveFontSize(1.6),
|
||||
fontFamily: FontsFamily.nunitoRegular,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 40
|
||||
},
|
||||
statusText: { fontSize: 12, fontWeight: "600", color: "#fff" },
|
||||
statusSuccess: { backgroundColor: "#10b981" },
|
||||
statusPending: { backgroundColor: "#f59e0b" },
|
||||
statusFailed: { backgroundColor: "#ef4444" },
|
||||
|
||||
emptyContainer: { alignItems: "center", justifyContent: "center", },
|
||||
emptyTitle: { fontSize: 18, fontWeight: "600", color: "#374151" },
|
||||
emptySubtitle: { marginTop: 8, color: "#6b7280" },
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ export const JOIN_GROUP_URL = `${BASE_URL}fund/`;
|
||||
export const MY_GROUPS_URL = `${BASE_URL}fund/getAll/my/groups`
|
||||
export const REQUESTS_URL = `${BASE_URL}fund/group/pending/request`
|
||||
export const APPROVE_REQUEST_URL = `${BASE_URL}fund/requests/`
|
||||
export const DECLINE_REQUEST_URL = `${BASE_URL}fund/user/my/groups`
|
||||
export const DECLINE_REQUEST_URL = `${BASE_URL}fund/requests/`
|
||||
export const GET_JOINED_GROUP_URL = `${BASE_URL}fund/my/join/groups`
|
||||
export const PUT_USER_PROFILE_IMG = `${BASE_URL}user/update/userImg`
|
||||
export const PUT_USER_PROFILE = `${BASE_URL}user/profile/update`
|
||||
|
||||
@@ -161,7 +161,7 @@ export const displayNotification = async (title: string, body: string, data?: an
|
||||
pressAction: {
|
||||
id: 'default',
|
||||
},
|
||||
},
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error displaying notification:', error);
|
||||
|
||||