Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95d8e0a925 | ||
|
|
7853bab639 | ||
|
|
1d7646d938 | ||
|
|
9acec328a7 | ||
|
|
44e84ff6f1 | ||
|
|
dd672f39f5 | ||
|
|
32049d90bd | ||
|
|
66461370a2 | ||
|
|
c1ecc97e29 | ||
|
|
499e680404 | ||
|
|
080f1b57cd | ||
|
|
d9cb9952ad | ||
|
|
78a74472dd | ||
|
|
76ba2b66f7 | ||
|
|
feb1ee2707 | ||
|
|
7c949eb570 | ||
|
|
b6c927367d | ||
|
|
96d71d1011 | ||
|
|
b4b136e801 |
@@ -0,0 +1,31 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Unit tests
|
||||
run: npx jest --ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
# Errors are blocking; style warnings are still being burned down.
|
||||
# Tighten to --max-warnings=0 once `npx eslint .` exits 0 on main.
|
||||
- name: Lint
|
||||
run: npx eslint . --quiet
|
||||
@@ -27,6 +27,7 @@ DerivedData
|
||||
build/
|
||||
.idea
|
||||
.gradle
|
||||
.settings/
|
||||
local.properties
|
||||
*.iml
|
||||
*.hprof
|
||||
@@ -35,6 +36,14 @@ local.properties
|
||||
!debug.keystore
|
||||
.kotlin/
|
||||
|
||||
# Signing secrets — never commit keystores or their credentials
|
||||
android/app/appKeyStore
|
||||
android/keystore.properties
|
||||
keystore.properties
|
||||
|
||||
# SonarQube scanner workspace
|
||||
.scannerwork/
|
||||
|
||||
# node.js
|
||||
#
|
||||
node_modules/
|
||||
@@ -46,7 +55,7 @@ yarn-error.log
|
||||
# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
|
||||
# screenshots whenever they are needed.
|
||||
# For more information about the recommended setup visit:
|
||||
# https://docs.fastlane.tools/best-practices/source-control/
|
||||
# http://docs.fastlane.tools/best-practices/source-control/
|
||||
|
||||
**/fastlane/report.xml
|
||||
**/fastlane/Preview.html
|
||||
@@ -66,6 +75,13 @@ yarn-error.log
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# Editor / IDE
|
||||
.vscode/
|
||||
|
||||
# Generated / diagnostic files
|
||||
*.log
|
||||
rn-config-output.json
|
||||
|
||||
# Yarn
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
@@ -73,3 +89,6 @@ yarn-error.log
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# Local archives
|
||||
scripts.zip
|
||||
|
||||
@@ -1,39 +1,48 @@
|
||||
import React from 'react';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { LogBox, StatusBar, View } from 'react-native';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AlertNotificationRoot } from 'react-native-alert-notification';
|
||||
import { View } from 'react-native';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { PersistGate } from 'redux-persist/integration/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { persistor, store } from './src/Redux/Store.js';
|
||||
import Routes from './src/Navigation/Routes.jsx';
|
||||
import { persistor, store } from './src/Redux/Store';
|
||||
import Routes from './src/Navigation/Routes';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import colors from './src/Constant/Color';
|
||||
import { TextEncoder } from 'text-encoding';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
LogBox.ignoreAllLogs(true);
|
||||
import AppStatusBar from './src/components/AppStatusBar';
|
||||
import { ApiToastBridge, AppToastHost } from './src/components/AppToast';
|
||||
import { ErrorBoundary } from './src/core/errors/ErrorBoundary';
|
||||
import { queryClient } from './src/core/query/queryClient';
|
||||
import AuthSessionGuard from './src/Navigation/AuthSessionGuard';
|
||||
import { navigationRef } from './src/Navigation/NavigationService';
|
||||
import { applyGuardForCurrentRoute } from './src/core/security/screenGuard';
|
||||
|
||||
const App = () => {
|
||||
const queryClient = new QueryClient();
|
||||
const insets = useSafeAreaInsets()
|
||||
global.TextEncoder = TextEncoder;
|
||||
const insets = useSafeAreaInsets();
|
||||
return (
|
||||
<NavigationContainer >
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<AlertNotificationRoot>
|
||||
<ErrorBoundary>
|
||||
<NavigationContainer
|
||||
ref={navigationRef}
|
||||
onReady={applyGuardForCurrentRoute}
|
||||
onStateChange={applyGuardForCurrentRoute}
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Provider store={store}>
|
||||
<PersistGate persistor={persistor}>
|
||||
<View style={{ flex: 1, backgroundColor: colors.white, paddingBottom: insets.bottom }}>
|
||||
<StatusBar barStyle="dark-content" />
|
||||
<AppStatusBar />
|
||||
<AuthSessionGuard />
|
||||
<Routes />
|
||||
<ApiToastBridge />
|
||||
<AppToastHost />
|
||||
</View>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
</AlertNotificationRoot>
|
||||
</GestureHandlerRootView>
|
||||
</QueryClientProvider>
|
||||
</NavigationContainer>
|
||||
</GestureHandlerRootView>
|
||||
</QueryClientProvider>
|
||||
</NavigationContainer>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
source 'https://rubygems.org'
|
||||
source 'http://rubygems.org'
|
||||
|
||||
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
|
||||
# You may use http://rbenv.org/ or http://rvm.io/ to install and use this version
|
||||
ruby ">= 2.6.10"
|
||||
|
||||
# Exclude problematic versions of cocoapods and activesupport that causes build failures.
|
||||
|
||||
@@ -1,97 +1,149 @@
|
||||
This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli).
|
||||
# ProcureLinc — Mobile App
|
||||
|
||||
# Getting Started
|
||||
ProcureLinc is a React Native mobile application for an **e-procurement platform** that lets registered contractors take part in **e-auctions** and **e-tenders** from their phone. It covers the full journey: contractor onboarding with eKYC, browsing live auctions/tenders, purchasing/EMD payments, document submission, and secure bidding.
|
||||
|
||||
> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding.
|
||||
- **Platform:** Android & iOS (React Native 0.79, React 19)
|
||||
- **App ID:** `com.procure`
|
||||
- **Backend:** REST + WebSocket APIs (base URL configured in [src/Services/ApiCall/ApiUrls.tsx](src/Services/ApiCall/ApiUrls.tsx))
|
||||
|
||||
## Step 1: Start Metro
|
||||
---
|
||||
|
||||
First, you will need to run **Metro**, the JavaScript build tool for React Native.
|
||||
## Features
|
||||
|
||||
To start the Metro dev server, run the following command from the root of your React Native project:
|
||||
- **Contractor registration** — multi-stage onboarding (Stage 1–3) including partnership details.
|
||||
- **eKYC verification** — Organization PAN, Aadhaar (OTP), and GST verification flows; KYC web view.
|
||||
- **Authentication & security** — login, reset password, two-factor auth, **device binding**, **DSC (Digital Signature Certificate) binding**, and request/response encryption.
|
||||
- **e-Auctions** — live auction listings, forward & reverse auctions, item-wise & auction-wise EMD documents, technical document templates, purchase + transaction status.
|
||||
- **e-Tenders** — live tender listings, tender details, proceed-to-bid, and **Envelope A / B / C** plus **QCBS / LCS** submission with file uploads.
|
||||
- **Real-time bidding** — STOMP over SockJS WebSocket updates.
|
||||
- **Dashboard & profile** — auction/tender dashboard counts, contractor profile view/edit, purchased tenders & auctions.
|
||||
- **Smooth UX** — native-stack screen transitions, animated custom bottom tab bar, Lottie animations, and theming (light/dark via `useTheme`).
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Area | Library |
|
||||
|------|---------|
|
||||
| Framework | React Native 0.79, React 19 |
|
||||
| Navigation | `@react-navigation/native`, `native-stack`, `bottom-tabs`, `drawer` |
|
||||
| State | Redux Toolkit, `react-redux`, `redux-persist` |
|
||||
| Data fetching | TanStack React Query, Axios |
|
||||
| Forms & validation | Formik, Yup |
|
||||
| Real-time | `@stomp/stompjs`, `sockjs-client` |
|
||||
| Animation / UI | Reanimated, Gesture Handler, Lottie, Lucide icons, SVG, Linear Gradient |
|
||||
| Security | `crypto-js`, `jsencrypt`, `react-native-device-info` |
|
||||
| Documents | `exceljs`, `xlsx`, `react-native-blob-util`, `react-native-fs`, document picker |
|
||||
| Device | Permissions, Geolocation, Haptic Feedback, WebView |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── Axios/ # Axios instance & interceptors
|
||||
├── Component/ # Shared components
|
||||
├── components/ # App-level components (e.g. AppStatusBar)
|
||||
├── Constant/ # Colors, NavigationString, static config
|
||||
├── Helper/ # Helper utilities
|
||||
├── Hooks/ # Custom hooks (e.g. useTheme)
|
||||
├── Images/ # Image assets
|
||||
├── Navigation/ # MainStack, Bottom tabs, Drawer, NavigationService
|
||||
├── Permission/ # Runtime permission handling
|
||||
├── Redux/ # Store, slices, persistence
|
||||
├── Screens/ # Feature screens (Auction, Tender, Bidding, Login, etc.)
|
||||
├── Services/ # API calls & endpoint URLs
|
||||
├── Styles/ # Shared styles
|
||||
├── Utils/ # Utilities (encryption, formatting, etc.)
|
||||
├── api/ # API helpers
|
||||
├── lottie/ # Lottie animation files
|
||||
├── socket/ # WebSocket (STOMP/SockJS) setup
|
||||
└── theme/ # Typography & theme tokens
|
||||
```
|
||||
|
||||
Navigation entry points:
|
||||
- [App.tsx](App.tsx) → [src/Navigation/Routes.jsx](src/Navigation/Routes.jsx) → [src/Navigation/MainStack.jsx](src/Navigation/MainStack.jsx)
|
||||
- Bottom tabs (Home / Auction / Tender / Profile): [src/Navigation/Bottom.tsx](src/Navigation/Bottom.tsx)
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
> **Note:** Complete the [React Native environment setup](https://reactnative.dev/docs/set-up-your-environment) first.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js **>= 18**
|
||||
- JDK 17, Android SDK / Android Studio (for Android)
|
||||
- Xcode + CocoaPods (for iOS, macOS only)
|
||||
|
||||
### 1. Install dependencies
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Configure the backend URL
|
||||
|
||||
Set the active `BASE_URL` in [src/Services/ApiCall/ApiUrls.tsx](src/Services/ApiCall/ApiUrls.tsx). The default points to the demo environment:
|
||||
|
||||
```ts
|
||||
const BASE_URL = `https://eproc.godemo.in/`;
|
||||
```
|
||||
|
||||
### 3. Start Metro
|
||||
|
||||
```sh
|
||||
# Using npm
|
||||
npm start
|
||||
|
||||
# OR using Yarn
|
||||
yarn start
|
||||
```
|
||||
|
||||
## Step 2: Build and run your app
|
||||
### 4. Run the app
|
||||
|
||||
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:
|
||||
|
||||
### Android
|
||||
**Android**
|
||||
|
||||
```sh
|
||||
# Using npm
|
||||
npm run android
|
||||
|
||||
# OR using Yarn
|
||||
yarn android
|
||||
```
|
||||
|
||||
### 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:
|
||||
|
||||
```sh
|
||||
bundle install
|
||||
```
|
||||
|
||||
Then, and every time you update your native dependencies, run:
|
||||
**iOS** (first run / after native dependency changes, install pods):
|
||||
|
||||
```sh
|
||||
bundle install # first time only
|
||||
bundle exec pod install
|
||||
```
|
||||
|
||||
For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html).
|
||||
|
||||
```sh
|
||||
# Using npm
|
||||
npm run ios
|
||||
|
||||
# OR using Yarn
|
||||
yarn ios
|
||||
```
|
||||
|
||||
If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device.
|
||||
---
|
||||
|
||||
This is one way to run your app — you can also build it directly from Android Studio or Xcode.
|
||||
## Available Scripts
|
||||
|
||||
## Step 3: Modify your app
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `npm start` | Start the Metro dev server |
|
||||
| `npm run android` | Build & run on Android |
|
||||
| `npm run ios` | Build & run on iOS |
|
||||
| `npm run lint` | Run ESLint |
|
||||
| `npm test` | Run Jest tests |
|
||||
| `npm run create:users` | Run the user-seeding script (`scripts/create-users.js`) |
|
||||
|
||||
Now that you have successfully run the app, let's make changes!
|
||||
> If a transition or native change doesn't appear, restart Metro with a clean cache: `npx react-native start --reset-cache`.
|
||||
|
||||
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).
|
||||
---
|
||||
|
||||
When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload:
|
||||
## Repository
|
||||
|
||||
- **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.
|
||||
This project is hosted on the internal Git server:
|
||||
|
||||
## Congratulations! :tada:
|
||||
```
|
||||
http://git.lincpay/EProcurement/ProcureLinc-Mobile-App.git
|
||||
```
|
||||
|
||||
You've successfully run and modified your React Native App. :partying_face:
|
||||
---
|
||||
|
||||
### Now what?
|
||||
## Troubleshooting
|
||||
|
||||
- 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.
|
||||
- **Metro stale cache** — `npx react-native start --reset-cache`.
|
||||
- **Android build issues** — `cd android && ./gradlew clean`, then rebuild.
|
||||
- **iOS pods out of date** — re-run `bundle exec pod install`.
|
||||
- General help: [React Native Troubleshooting](https://reactnative.dev/docs/troubleshooting).
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
arguments=--init-script C\:\\Users\\Lincpay\\AppData\\Roaming\\Code\\User\\globalStorage\\redhat.java\\1.51.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.51.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)
|
||||
connection.project.dir=
|
||||
eclipse.preferences.version=1
|
||||
gradle.user.home=
|
||||
java.home=C\:/Program Files/Microsoft/jdk-17.0.14.7-hotspot
|
||||
jvm.arguments=
|
||||
offline.mode=false
|
||||
override.workspace.settings=true
|
||||
show.console.view=true
|
||||
show.executions.view=true
|
||||
@@ -40,7 +40,7 @@ react {
|
||||
// entryFile = file("../js/MyApplication.android.js")
|
||||
//
|
||||
// A list of extra flags to pass to the 'bundle' commands.
|
||||
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
||||
// See http://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
||||
// extraPackagerArgs = []
|
||||
|
||||
/* Hermes Commands */
|
||||
@@ -72,7 +72,22 @@ def enableProguardInReleaseBuilds = false
|
||||
*/
|
||||
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
|
||||
|
||||
android {
|
||||
/**
|
||||
* Release signing credentials live in android/keystore.properties (gitignored).
|
||||
* See android/keystore.properties.example. In CI, write the file from secrets
|
||||
* before building. Local dev builds without it fall back to the debug key and
|
||||
* MUST NOT be distributed.
|
||||
*/
|
||||
def keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
def keystoreProperties = new Properties()
|
||||
def hasReleaseSigning = keystorePropertiesFile.exists()
|
||||
if (hasReleaseSigning) {
|
||||
keystorePropertiesFile.withInputStream { keystoreProperties.load(it) }
|
||||
} else {
|
||||
logger.warn("WARNING: android/keystore.properties not found — release build will be DEBUG-SIGNED and must not be distributed.")
|
||||
}
|
||||
|
||||
android {
|
||||
ndkVersion rootProject.ext.ndkVersion
|
||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
@@ -92,25 +107,50 @@ android {
|
||||
keyAlias 'androiddebugkey'
|
||||
keyPassword 'android'
|
||||
}
|
||||
if (hasReleaseSigning) {
|
||||
release {
|
||||
storeFile file(keystoreProperties['storeFile'])
|
||||
storePassword keystoreProperties['storePassword']
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
release {
|
||||
// Caution! In production, you need to generate your own keystore file.
|
||||
// see https://reactnative.dev/docs/signed-apk-android.
|
||||
signingConfig signingConfigs.debug
|
||||
signingConfig hasReleaseSigning ? signingConfigs.release : signingConfigs.debug
|
||||
minifyEnabled enableProguardInReleaseBuilds
|
||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||
}
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += [
|
||||
"META-INF/DEPENDENCIES",
|
||||
"META-INF/LICENSE",
|
||||
"META-INF/LICENSE.txt",
|
||||
"META-INF/license.txt",
|
||||
"META-INF/NOTICE",
|
||||
"META-INF/NOTICE.txt",
|
||||
"META-INF/notice.txt",
|
||||
"META-INF/*.kotlin_module",
|
||||
"META-INF/versions/**",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
implementation("com.facebook.react:react-android")
|
||||
|
||||
// Native Apache POI backend for metadata-preserving BOQ editing.
|
||||
implementation("org.apache.poi:poi-ooxml:5.2.5")
|
||||
|
||||
if (hermesEnabled.toBoolean()) {
|
||||
implementation("com.facebook.react:hermes-android")
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Debug-only override: allow all cleartext so Metro / localhost / LAN dev IPs work.
|
||||
Release uses the restrictive config in src/main/res/xml. -->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
@@ -14,6 +15,7 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:allowBackup="false"
|
||||
android:theme="@style/AppTheme"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.procure
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import com.facebook.react.ReactActivity
|
||||
import com.facebook.react.ReactActivityDelegate
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||
@@ -7,15 +9,31 @@ import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||
|
||||
class MainActivity : ReactActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// FLAG_SECURE fail-closed baseline: the app displays sealed-bid amounts,
|
||||
// Aadhaar OTPs and other regulated PII. Release builds start fully secured so
|
||||
// nothing leaks before JS boots (or if the ScreenGuard bridge fails to load).
|
||||
// Once running, per-screen protection is driven from JS — the central
|
||||
// controller (core/security/screenGuard.ts, wired via NavigationContainer)
|
||||
// CLEARS this flag for non-sensitive screens and re-applies it for the
|
||||
// protected ones. Debug builds start capturable for QA and are likewise
|
||||
// toggled per screen by the same controller.
|
||||
if (!BuildConfig.DEBUG) {
|
||||
window.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the main component registered from JavaScript. This is used to schedule
|
||||
* rendering of the component.
|
||||
* Returns the name of the main component registered from JavaScript.
|
||||
*/
|
||||
override fun getMainComponentName(): String = "procure"
|
||||
|
||||
/**
|
||||
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
|
||||
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
|
||||
* Returns the instance of the [ReactActivityDelegate].
|
||||
*/
|
||||
override fun createReactActivityDelegate(): ReactActivityDelegate =
|
||||
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
|
||||
|
||||
@@ -11,6 +11,8 @@ import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
|
||||
import com.facebook.react.defaults.DefaultReactNativeHost
|
||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||
import com.facebook.soloader.SoLoader
|
||||
import com.procure.excel.ExcelPackage
|
||||
import com.procure.security.ScreenGuardPackage
|
||||
|
||||
class MainApplication : Application(), ReactApplication {
|
||||
|
||||
@@ -18,8 +20,8 @@ class MainApplication : Application(), ReactApplication {
|
||||
object : DefaultReactNativeHost(this) {
|
||||
override fun getPackages(): List<ReactPackage> =
|
||||
PackageList(this).packages.apply {
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// add(MyReactNativePackage())
|
||||
add(ExcelPackage())
|
||||
add(ScreenGuardPackage())
|
||||
}
|
||||
|
||||
override fun getJSMainModuleName(): String = "index"
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
package com.procure.excel
|
||||
|
||||
import android.util.Base64
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import org.apache.poi.ss.usermodel.BorderStyle
|
||||
import org.apache.poi.ss.usermodel.Cell
|
||||
import org.apache.poi.ss.usermodel.CellType
|
||||
import org.apache.poi.ss.usermodel.DataValidation
|
||||
import org.apache.poi.ss.usermodel.DataValidationConstraint.ValidationType
|
||||
import org.apache.poi.ss.usermodel.DateUtil
|
||||
import org.apache.poi.ss.usermodel.HorizontalAlignment
|
||||
import org.apache.poi.ss.usermodel.Row
|
||||
import org.apache.poi.ss.usermodel.Sheet
|
||||
import org.apache.poi.ss.usermodel.SheetVisibility
|
||||
import org.apache.poi.ss.usermodel.VerticalAlignment
|
||||
import org.apache.poi.ss.usermodel.Workbook
|
||||
import org.apache.poi.ss.util.CellRangeAddress
|
||||
import org.apache.poi.xssf.usermodel.XSSFCellStyle
|
||||
import org.apache.poi.xssf.usermodel.XSSFColor
|
||||
import org.apache.poi.xssf.usermodel.XSSFFont
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* ExcelModule — the native Apache POI bridge.
|
||||
*
|
||||
* Holds one workbook per session. `open` parses xlsx bytes and returns a
|
||||
* WorkbookDTO (values, formulas, styles, merges, hidden lines, protection,
|
||||
* lock flags, validations). `save` applies ONLY edited values back onto the
|
||||
* retained workbook — preserving every other feature — and returns the new
|
||||
* bytes. Formula recalculation is deferred to Excel/HyperFormula.
|
||||
*/
|
||||
class ExcelModule(private val reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
private var workbook: Workbook? = null
|
||||
|
||||
override fun getName(): String = "ExcelModule"
|
||||
|
||||
/* ------------------------------- open ------------------------------- */
|
||||
|
||||
@ReactMethod
|
||||
fun open(base64: String, promise: Promise) {
|
||||
try {
|
||||
close()
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val wb = XSSFWorkbook(ByteArrayInputStream(bytes))
|
||||
workbook = wb
|
||||
promise.resolve(serializeWorkbook(wb).toString())
|
||||
} catch (e: Throwable) {
|
||||
promise.reject("E_OPEN", "Failed to open workbook: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun serializeWorkbook(wb: Workbook): JSONObject {
|
||||
val root = JSONObject()
|
||||
root.put("activeSheet", wb.activeSheetIndex.coerceAtLeast(0))
|
||||
val sheets = JSONArray()
|
||||
for (i in 0 until wb.numberOfSheets) {
|
||||
sheets.put(serializeSheet(wb, i))
|
||||
}
|
||||
root.put("sheets", sheets)
|
||||
return root
|
||||
}
|
||||
|
||||
private fun serializeSheet(wb: Workbook, index: Int): JSONObject {
|
||||
val sheet = wb.getSheetAt(index)
|
||||
val out = JSONObject()
|
||||
out.put("name", wb.getSheetName(index))
|
||||
out.put("index", index)
|
||||
out.put(
|
||||
"state",
|
||||
when (wb.getSheetVisibility(index)) {
|
||||
SheetVisibility.HIDDEN -> "hidden"
|
||||
SheetVisibility.VERY_HIDDEN -> "veryHidden"
|
||||
else -> "visible"
|
||||
},
|
||||
)
|
||||
out.put("protected", sheet.protect)
|
||||
|
||||
// Frozen panes.
|
||||
var frozenRows = 0
|
||||
var frozenCols = 0
|
||||
try {
|
||||
sheet.paneInformation?.let { pane ->
|
||||
if (pane.isFreezePane) {
|
||||
frozenRows = pane.horizontalSplitPosition.toInt()
|
||||
frozenCols = pane.verticalSplitPosition.toInt()
|
||||
}
|
||||
}
|
||||
} catch (_: Throwable) { /* ignore pane read errors */ }
|
||||
out.put("frozenRows", frozenRows)
|
||||
out.put("frozenCols", frozenCols)
|
||||
|
||||
val lastRow = sheet.lastRowNum
|
||||
var maxCol = 0
|
||||
|
||||
val hiddenRows = JSONArray()
|
||||
val rowHeights = JSONObject()
|
||||
val cells = JSONArray()
|
||||
|
||||
// Precompute per-cell data validations.
|
||||
val dvMap = buildValidationMap(sheet)
|
||||
|
||||
for (r in 0..lastRow) {
|
||||
val row = sheet.getRow(r) ?: continue
|
||||
if (row.zeroHeight) hiddenRows.put(r)
|
||||
rowHeights.put(r.toString(), pointsToPx(row.heightInPoints))
|
||||
for (cell in row) {
|
||||
val c = cell.columnIndex
|
||||
if (c > maxCol) maxCol = c
|
||||
val dto = serializeCell(cell, dvMap["$r,$c"]) ?: continue
|
||||
cells.put(dto)
|
||||
}
|
||||
}
|
||||
|
||||
val colCount = maxCol + 1
|
||||
out.put("rowCount", lastRow + 1)
|
||||
out.put("colCount", colCount)
|
||||
|
||||
val hiddenCols = JSONArray()
|
||||
val colWidths = JSONObject()
|
||||
for (c in 0 until colCount) {
|
||||
if (sheet.isColumnHidden(c)) hiddenCols.put(c)
|
||||
try {
|
||||
colWidths.put(c.toString(), charUnitsToPx(sheet.getColumnWidth(c)))
|
||||
} catch (_: Throwable) { /* default width */ }
|
||||
}
|
||||
|
||||
out.put("hiddenRows", hiddenRows)
|
||||
out.put("hiddenColumns", hiddenCols)
|
||||
out.put("columnWidths", colWidths)
|
||||
out.put("rowHeights", rowHeights)
|
||||
|
||||
val merges = JSONArray()
|
||||
for (region: CellRangeAddress in sheet.mergedRegions) {
|
||||
merges.put(region.formatAsString())
|
||||
}
|
||||
out.put("merges", merges)
|
||||
out.put("cells", cells)
|
||||
return out
|
||||
}
|
||||
|
||||
private fun serializeCell(cell: Cell, dv: JSONObject?): JSONObject? {
|
||||
val style = cell.cellStyle
|
||||
val locked = style?.locked ?: true
|
||||
val numFmt = style?.dataFormatString
|
||||
val comment = try {
|
||||
cell.cellComment?.string?.string
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
val styleJson = serializeStyle(cell)
|
||||
|
||||
val isFormula = cell.cellType == CellType.FORMULA
|
||||
var value: Any? = null
|
||||
var formula: String? = null
|
||||
var type = "text"
|
||||
|
||||
try {
|
||||
when (cell.cellType) {
|
||||
CellType.FORMULA -> {
|
||||
formula = cell.cellFormula
|
||||
when (cell.cachedFormulaResultType) {
|
||||
CellType.NUMERIC -> {
|
||||
value = cell.numericCellValue
|
||||
type = if (DateUtil.isCellDateFormatted(cell)) "date" else typeFromNumFmt(numFmt)
|
||||
}
|
||||
CellType.BOOLEAN -> { value = cell.booleanCellValue; type = "boolean" }
|
||||
CellType.STRING -> { value = cell.stringCellValue; type = "text" }
|
||||
CellType.ERROR -> { value = "#ERROR!"; type = "text" }
|
||||
else -> { value = null }
|
||||
}
|
||||
}
|
||||
CellType.NUMERIC -> {
|
||||
value = cell.numericCellValue
|
||||
type = if (DateUtil.isCellDateFormatted(cell)) "date" else typeFromNumFmt(numFmt)
|
||||
}
|
||||
CellType.STRING -> { value = cell.stringCellValue; type = "text" }
|
||||
CellType.BOOLEAN -> { value = cell.booleanCellValue; type = "boolean" }
|
||||
CellType.ERROR -> { value = "#ERROR!"; type = "text" }
|
||||
else -> { value = null }
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
value = null
|
||||
}
|
||||
|
||||
// Prefer a validation-derived dropdown type.
|
||||
if (dv != null && dv.optString("kind") == "list") type = "dropdown"
|
||||
|
||||
val hasContent = value != null || formula != null
|
||||
val hasMeta = styleJson != null || comment != null || dv != null || !locked
|
||||
if (!hasContent && !hasMeta) return null
|
||||
|
||||
val obj = JSONObject()
|
||||
obj.put("r", cell.rowIndex)
|
||||
obj.put("c", cell.columnIndex)
|
||||
obj.put("v", value ?: JSONObject.NULL)
|
||||
obj.put("f", formula ?: JSONObject.NULL)
|
||||
obj.put("t", type)
|
||||
if (numFmt != null && numFmt != "General") obj.put("numFmt", numFmt)
|
||||
obj.put("locked", locked)
|
||||
if (comment != null) obj.put("comment", comment)
|
||||
if (styleJson != null) obj.put("style", styleJson)
|
||||
if (dv != null) obj.put("dv", dv)
|
||||
return obj
|
||||
}
|
||||
|
||||
private fun serializeStyle(cell: Cell): JSONObject? {
|
||||
val style = cell.cellStyle ?: return null
|
||||
val json = JSONObject()
|
||||
var any = false
|
||||
|
||||
try {
|
||||
val font = cell.sheet.workbook.getFontAt(style.fontIndexAsInt)
|
||||
if (font.bold) { json.put("bold", true); any = true }
|
||||
if (font.italic) { json.put("italic", true); any = true }
|
||||
if (font.underline.toInt() != 0) { json.put("underline", true); any = true }
|
||||
if (font.strikeout) { json.put("strike", true); any = true }
|
||||
if (font.fontHeightInPoints.toInt() != 11) { json.put("fontSize", font.fontHeightInPoints.toInt()); any = true }
|
||||
if (font is XSSFFont) {
|
||||
font.xssfColor?.let { hexFromColor(it)?.let { hex -> json.put("fontColor", hex); any = true } }
|
||||
}
|
||||
} catch (_: Throwable) { /* font read best-effort */ }
|
||||
|
||||
try {
|
||||
if (style is XSSFCellStyle) {
|
||||
val fill = style.fillForegroundColorColor
|
||||
if (fill is XSSFColor) hexFromColor(fill)?.let { json.put("bgColor", it); any = true }
|
||||
}
|
||||
} catch (_: Throwable) { /* fill read best-effort */ }
|
||||
|
||||
try {
|
||||
when (style.alignment) {
|
||||
HorizontalAlignment.LEFT -> json.put("hAlign", "left")
|
||||
HorizontalAlignment.CENTER -> json.put("hAlign", "center")
|
||||
HorizontalAlignment.RIGHT -> json.put("hAlign", "right")
|
||||
HorizontalAlignment.JUSTIFY -> json.put("hAlign", "justify")
|
||||
else -> {}
|
||||
}
|
||||
when (style.verticalAlignment) {
|
||||
VerticalAlignment.TOP -> json.put("vAlign", "top")
|
||||
VerticalAlignment.CENTER -> json.put("vAlign", "middle")
|
||||
VerticalAlignment.BOTTOM -> json.put("vAlign", "bottom")
|
||||
else -> {}
|
||||
}
|
||||
if (style.wrapText) json.put("wrap", true)
|
||||
if (style.alignment != HorizontalAlignment.GENERAL || style.wrapText) any = true
|
||||
} catch (_: Throwable) { /* alignment best-effort */ }
|
||||
|
||||
try {
|
||||
if (style.borderTop != BorderStyle.NONE) { json.put("borderTop", true); any = true }
|
||||
if (style.borderRight != BorderStyle.NONE) { json.put("borderRight", true); any = true }
|
||||
if (style.borderBottom != BorderStyle.NONE) { json.put("borderBottom", true); any = true }
|
||||
if (style.borderLeft != BorderStyle.NONE) { json.put("borderLeft", true); any = true }
|
||||
} catch (_: Throwable) { /* border best-effort */ }
|
||||
|
||||
return if (any) json else null
|
||||
}
|
||||
|
||||
private fun buildValidationMap(sheet: Sheet): Map<String, JSONObject> {
|
||||
val map = HashMap<String, JSONObject>()
|
||||
try {
|
||||
for (dv: DataValidation in sheet.dataValidations) {
|
||||
val constraint = dv.validationConstraint
|
||||
val json = JSONObject()
|
||||
when (constraint.validationType) {
|
||||
ValidationType.LIST -> {
|
||||
json.put("kind", "list")
|
||||
val values = constraint.explicitListValues
|
||||
if (values != null) {
|
||||
val arr = JSONArray()
|
||||
values.forEach { arr.put(it) }
|
||||
json.put("list", arr)
|
||||
}
|
||||
}
|
||||
ValidationType.INTEGER -> json.put("kind", "whole")
|
||||
ValidationType.DECIMAL -> json.put("kind", "decimal")
|
||||
ValidationType.TEXT_LENGTH -> json.put("kind", "textLength")
|
||||
ValidationType.DATE -> json.put("kind", "date")
|
||||
else -> continue
|
||||
}
|
||||
constraint.formula1?.toDoubleOrNull()?.let { json.put("min", it) }
|
||||
constraint.formula2?.toDoubleOrNull()?.let { json.put("max", it) }
|
||||
dv.errorBoxText?.let { json.put("errorMessage", it) }
|
||||
|
||||
for (region in dv.regions.cellRangeAddresses) {
|
||||
for (r in region.firstRow..region.lastRow) {
|
||||
for (c in region.firstColumn..region.lastColumn) {
|
||||
map["$r,$c"] = json
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Throwable) { /* validations best-effort */ }
|
||||
return map
|
||||
}
|
||||
|
||||
/* ------------------------------- save ------------------------------- */
|
||||
|
||||
@ReactMethod
|
||||
fun save(saveRequestJson: String, promise: Promise) {
|
||||
val wb = workbook
|
||||
if (wb == null) {
|
||||
promise.reject("E_NO_WORKBOOK", "No workbook is open.")
|
||||
return
|
||||
}
|
||||
try {
|
||||
val request = JSONObject(saveRequestJson)
|
||||
val edits = request.optJSONArray("edits") ?: JSONArray()
|
||||
for (i in 0 until edits.length()) {
|
||||
applyEdit(wb, edits.getJSONObject(i))
|
||||
}
|
||||
// Recompute every formula so its CACHED <v> reflects the new inputs.
|
||||
// Excel/LibreOffice would recalc on open, but cache-only consumers
|
||||
// (the web BOQ viewer reads the stored value and never recalculates)
|
||||
// otherwise see the template's stale result. Per-cell + try/catch so a
|
||||
// single POI-unsupported function can't blank the whole workbook.
|
||||
recalculateFormulas(wb)
|
||||
// Belt-and-suspenders for recalc-capable consumers.
|
||||
wb.setForceFormulaRecalculation(true)
|
||||
|
||||
val bos = ByteArrayOutputStream()
|
||||
wb.write(bos)
|
||||
val base64 = Base64.encodeToString(bos.toByteArray(), Base64.NO_WRAP)
|
||||
promise.resolve(base64)
|
||||
} catch (e: Throwable) {
|
||||
promise.reject("E_SAVE", "Failed to save workbook: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate all formula cells in-place so their cached results are written to
|
||||
* the file. Runs per-cell in a try/catch: POI cannot evaluate every Excel
|
||||
* function, and a NotImplemented failure on one cell must not abort the save
|
||||
* or clear the other results.
|
||||
*/
|
||||
private fun recalculateFormulas(wb: Workbook) {
|
||||
val evaluator = wb.creationHelper.createFormulaEvaluator()
|
||||
for (sheet in wb) {
|
||||
for (row in sheet) {
|
||||
for (cell in row) {
|
||||
if (cell.cellType == CellType.FORMULA) {
|
||||
try {
|
||||
evaluator.evaluateFormulaCell(cell)
|
||||
} catch (_: Throwable) {
|
||||
/* leave this cell's cached value as-is */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyEdit(wb: Workbook, edit: JSONObject) {
|
||||
val sheetIndex = edit.getInt("sheet")
|
||||
if (sheetIndex < 0 || sheetIndex >= wb.numberOfSheets) return
|
||||
val sheet = wb.getSheetAt(sheetIndex)
|
||||
val r = edit.getInt("r")
|
||||
val c = edit.getInt("c")
|
||||
val row: Row = sheet.getRow(r) ?: sheet.createRow(r)
|
||||
val cell: Cell = row.getCell(c) ?: row.createCell(c)
|
||||
|
||||
when {
|
||||
edit.optBoolean("clear", false) -> cell.setBlank()
|
||||
!edit.isNull("f") -> cell.cellFormula = edit.getString("f")
|
||||
else -> {
|
||||
when (val v = edit.opt("v")) {
|
||||
is Boolean -> cell.setCellValue(v)
|
||||
is Int -> cell.setCellValue(v.toDouble())
|
||||
is Long -> cell.setCellValue(v.toDouble())
|
||||
is Double -> cell.setCellValue(v)
|
||||
is String -> cell.setCellValue(v)
|
||||
null, JSONObject.NULL -> cell.setBlank()
|
||||
else -> cell.setCellValue(v.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------ close ------------------------------- */
|
||||
|
||||
@ReactMethod
|
||||
fun close(promise: Promise) {
|
||||
close()
|
||||
promise.resolve(null)
|
||||
}
|
||||
|
||||
private fun close() {
|
||||
try {
|
||||
workbook?.close()
|
||||
} catch (_: Throwable) {
|
||||
} finally {
|
||||
workbook = null
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------ helpers ----------------------------- */
|
||||
|
||||
private fun hexFromColor(color: XSSFColor?): String? {
|
||||
if (color == null) return null
|
||||
return try {
|
||||
val argb = color.argbHex ?: return null // "FFRRGGBB"
|
||||
if (argb.length == 8) "#${argb.substring(2)}" else "#$argb"
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun typeFromNumFmt(fmt: String?): String {
|
||||
if (fmt != null) {
|
||||
val stripped = fmt.replace(Regex("\\[[^\\]]*\\]"), "").replace(Regex("\"[^\"]*\""), "")
|
||||
if (fmt.contains("%")) return "percentage"
|
||||
if (Regex("[dmy]", RegexOption.IGNORE_CASE).containsMatchIn(stripped) && !Regex("[#0]").containsMatchIn(stripped)) return "date"
|
||||
if (Regex("[$€£₹¥]").containsMatchIn(fmt)) return "currency"
|
||||
}
|
||||
return "number"
|
||||
}
|
||||
|
||||
private fun charUnitsToPx(width256: Int): Int {
|
||||
val chars = width256 / 256.0
|
||||
return (chars * 7.5 + 8).toInt().coerceIn(48, 420)
|
||||
}
|
||||
|
||||
private fun pointsToPx(points: Float): Int {
|
||||
return (points * 1.3333).toInt().coerceIn(28, 240)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.procure.excel
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
/**
|
||||
* Registers [ExcelModule] with React Native. Added manually in
|
||||
* MainApplication.getPackages(). Works under both the old and new architecture
|
||||
* (the JS ExcelBridge resolves it via TurboModuleRegistry first, then the
|
||||
* legacy NativeModules registry).
|
||||
*/
|
||||
class ExcelPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
||||
listOf(ExcelModule(reactContext))
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
||||
emptyList()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.procure.security
|
||||
|
||||
import android.view.WindowManager
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
|
||||
/**
|
||||
* ScreenGuard — runtime FLAG_SECURE toggle.
|
||||
*
|
||||
* FLAG_SECURE blocks screenshots, screen recording and the app-switcher
|
||||
* thumbnail for the whole Activity window. The app is single-Activity, so
|
||||
* flipping the flag as the user navigates gives per-screen protection. WHICH
|
||||
* screens are protected lives entirely in JS (see core/security/screenGuard.ts);
|
||||
* this module only applies the decision. Window flags must be mutated on the UI
|
||||
* thread.
|
||||
*/
|
||||
class ScreenGuardModule(private val reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
override fun getName(): String = "ScreenGuard"
|
||||
|
||||
@ReactMethod
|
||||
fun setSecure(enable: Boolean) {
|
||||
val activity = currentActivity ?: return
|
||||
activity.runOnUiThread {
|
||||
val window = activity.window ?: return@runOnUiThread
|
||||
if (enable) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.procure.security
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
/**
|
||||
* Registers [ScreenGuardModule] with React Native. Added manually in
|
||||
* MainApplication.getPackages(); accessible from JS via NativeModules.ScreenGuard.
|
||||
*/
|
||||
class ScreenGuardPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
||||
listOf(ScreenGuardModule(reactContext))
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
||||
emptyList()
|
||||
}
|
||||
@@ -22,13 +22,13 @@
|
||||
|
||||
<selector>
|
||||
<!--
|
||||
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
|
||||
This file is a copy of abc_edit_text_material (http://bit.ly/3k8fX7I).
|
||||
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
|
||||
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
|
||||
|
||||
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||
|
||||
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
|
||||
For more info, see http://bit.ly/3CdLStv (react-native/pull/29452) and http://bit.ly/3nxOMoR.
|
||||
-->
|
||||
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Cleartext (http://) is DENIED by default. It is permitted only for the
|
||||
specific backend hosts below that are not yet served over HTTPS.
|
||||
TODO(backend): once eproc-backend.lincpay terminates TLS, delete its
|
||||
domain-config so the entire app is HTTPS-only, and add certificate pinning.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
|
||||
<!-- Production REST backend — currently HTTP-only. Remove when HTTPS lands. -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">eproc-backend.lincpay</domain>
|
||||
</domain-config>
|
||||
|
||||
<!-- Local development hosts (Metro / LAN backends / emulator loopback). -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||
<domain includeSubdomains="false">192.168.6.194</domain>
|
||||
<domain includeSubdomains="false">192.168.29.226</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -1,7 +1,7 @@
|
||||
buildscript {
|
||||
ext {
|
||||
buildToolsVersion = "35.0.0"
|
||||
minSdkVersion = 24
|
||||
minSdkVersion = 26
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
ndkVersion = "27.1.12297006"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
||||
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
@@ -19,13 +19,13 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
# http://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
|
||||
# Use this property to specify which architecture you want to build.
|
||||
# You can also override it from the CLI using
|
||||
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
||||
reactNativeArchitectures=armeabi-v7a,arm64-v8a
|
||||
|
||||
# Use this property to enable support to the new architecture.
|
||||
# This will allow you to use TurboModules and the Fabric render in
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@@ -57,10 +57,10 @@
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# http://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
# You can find Gradle at http://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
@@ -85,7 +85,7 @@ done
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
# Discard cd standard output in case $CDPATH is set (http://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem http://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Copy to android/keystore.properties (gitignored) and fill in real values.
|
||||
# The release keystore itself must live OUTSIDE the repository or be gitignored.
|
||||
#
|
||||
# IMPORTANT: the previous keystore (android/app/appKeyStore) was committed to
|
||||
# git history — treat it as compromised. Generate a NEW keystore:
|
||||
# keytool -genkeypair -v -storetype PKCS12 -keystore procurelinc-release.keystore \
|
||||
# -alias procurelinc -keyalg RSA -keysize 2048 -validity 10000
|
||||
storeFile=../procurelinc-release.keystore
|
||||
storePassword=CHANGE_ME
|
||||
keyAlias=procurelinc
|
||||
keyPassword=CHANGE_ME
|
||||
@@ -1,6 +1,13 @@
|
||||
module.exports = {
|
||||
presets: ['module:@react-native/babel-preset'],
|
||||
plugins: [
|
||||
'react-native-reanimated/plugin',
|
||||
],
|
||||
plugins: [
|
||||
// Must remain the LAST entry in this list.
|
||||
'react-native-reanimated/plugin',
|
||||
],
|
||||
env: {
|
||||
// Release bundles: strip every console.* so no PII/debug output ships.
|
||||
production: {
|
||||
plugins: ['transform-remove-console'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# ProcureLinc — Architecture Guide (as-built)
|
||||
|
||||
This is the **as-built** companion to [`ARCHITECTURE_AUDIT.md`](./ARCHITECTURE_AUDIT.md).
|
||||
The audit is the blueprint (what was wrong, what to build); this document is the
|
||||
map of what now exists and the rules for working within it. Read this before
|
||||
adding a feature or "fixing" something that looks odd — several things that look
|
||||
like smells are deliberate, documented below under **Deferred / do not touch blindly**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Layering
|
||||
|
||||
```
|
||||
Presentation src/Screens/**, src/components/** (UI only)
|
||||
│
|
||||
Application src/Services/**, feature hooks (use-cases, orchestration)
|
||||
│
|
||||
Domain slices, reducers, pattern configs (business rules, pure)
|
||||
│
|
||||
Infrastructure src/core/** (http, storage, crypto, events)
|
||||
│
|
||||
Platform react-native, native modules
|
||||
```
|
||||
|
||||
Dependencies point **inward**. `src/core/**` must never import from `Screens`,
|
||||
`Services`, or `Redux`. It reaches outward only through injection/events
|
||||
(see §3).
|
||||
|
||||
---
|
||||
|
||||
## 2. The `core/` layer — the single source of infrastructure truth
|
||||
|
||||
| Module | Responsibility | Rule |
|
||||
|--------|----------------|------|
|
||||
| `core/config/env.ts` | All hosts/base URLs, selected by build type | **No hardcoded URLs anywhere else.** |
|
||||
| `core/http/client.ts` | THE axios instance; `request<T>()` | Throws `AppError`; never returns a sentinel. All new network code uses this. |
|
||||
| `core/http/AppError.ts` | Normalized typed error | Catch with `isAppError()`; branch on `.kind`. |
|
||||
| `core/observability/logger.ts` | The only sanctioned log sink | **Never `console.*` in app code.** DEV-only; release-stripped. |
|
||||
| `core/observability/redact.ts` | PII/secret masking | Every log payload is redacted; extend `SENSITIVE_KEY_PATTERN` when adding sensitive fields. |
|
||||
| `core/storage/secureStorage.ts` | Keychain-backed persist storage for the auth session | Token is never in plaintext AsyncStorage. |
|
||||
| `core/events/appEvents.ts` | Typed cross-layer event bus | How infra signals up without importing the store. |
|
||||
| `core/query/queryClient.ts` | Module-singleton React Query client | Never construct a QueryClient in a component. |
|
||||
| `core/errors/ErrorBoundary.tsx` | App-root render-error guard | Wraps the tree in `App.tsx`. |
|
||||
| `core/platform/polyfills.ts` | Buffer / TextEncoder | Imported first in `index.js`; nothing may depend on load order otherwise. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Hard rules (enforced by review; some by CI)
|
||||
|
||||
1. **Networking:** use `request()` from `core/http/client`. It injects the JWT,
|
||||
handles 401 (emits `auth/unauthorized`), times out, and throws `AppError`.
|
||||
`src/Axios/Axios.ts` (`apiCall`) is a **legacy compatibility adapter** that
|
||||
preserves the old never-throws `{success:false}` envelope for un-migrated
|
||||
call sites; its contract is pinned by tests. Do not add new `apiCall` sites.
|
||||
2. **Auth token:** read only via `selectAuthJwt(state)`. The transport reads it
|
||||
through a provider registered in `Redux/Store.ts` — never `import { store }`
|
||||
into `core`.
|
||||
3. **Logout:** call `performLogout()` from `Services/Auth/session.ts`. It clears
|
||||
redux + keychain + the query cache in one place. `AuthSessionGuard` (mounted
|
||||
in `App.tsx`) resets navigation to Login whenever the session ends.
|
||||
4. **Post-auth routing:** `Services/Auth/registrationRouting.ts` is the single
|
||||
decision function. Splash and Login both call it. Do not re-implement.
|
||||
5. **Logging:** `logger.<level>(scope, message, data?)`. Never log key material,
|
||||
plaintext bids, OTPs, or full PII (the redactor is a safety net, not a licence).
|
||||
6. **Theme/colors:** use `useTheme()`. `Constant/Color.tsx` is **deprecated**
|
||||
(see §6). There is one brand orange, sourced from `theme/colors`.
|
||||
7. **Components:** one root — `src/components/**`. `src/Component/` no longer exists.
|
||||
8. **Navigation:** route param shapes live in `src/Navigation/types.ts`
|
||||
(`RootStackParamList`). `NavigationString` is the `as const` route-name registry.
|
||||
9. **Forward/Reverse bidding:** every behavioural difference lives in
|
||||
`Screens/Bidding/biddingPattern.ts`. The screen/card contain zero
|
||||
`if (pattern === ...)`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Feature anatomy (pattern to copy)
|
||||
|
||||
The tender-bidding and registration features show the intended shape — logic is
|
||||
extracted out of the screen into pure, testable units:
|
||||
|
||||
```
|
||||
Screens/Tender/tenderBidding/
|
||||
TenderBidding.tsx presentation + state only
|
||||
envelopeTypes.ts types
|
||||
envelopeCrypto.ts AES/RSA (never logs key material)
|
||||
envelopePayload.ts pure payload build + validation ← unit-tested
|
||||
envelopeSubmission.ts API calls
|
||||
components/* memoized, module-scope sub-components
|
||||
|
||||
Screens/Ragistration/
|
||||
Ragistration.tsx orchestration + render
|
||||
registrationState.ts typed useReducer (was ~20 useStates) ← unit-tested
|
||||
registrationValidation.ts pure per-stage validation ← unit-tested
|
||||
```
|
||||
|
||||
When you build a feature: keep the screen thin; put decisions in pure functions;
|
||||
test the pure functions.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing & CI
|
||||
|
||||
- `npm test` (jest). 75 tests across 11 suites, concentrated on the
|
||||
money/crypto/auth/storage paths: `envelopePayload`, `biddingPattern`,
|
||||
`registrationState`/`registrationValidation`, `registrationRouting`,
|
||||
`Token` slice, `apiCall` envelope contract, `AppError`, `redact`, `appEvents`,
|
||||
and the `secureStorage` keychain-migration path.
|
||||
- CI (`.github/workflows/ci.yml`) runs **install → typecheck → lint → test**,
|
||||
all **blocking**. `tsc --noEmit` is green; `eslint . --quiet` (errors) is green.
|
||||
Style *warnings* still exist and are being burned down; tighten to
|
||||
`--max-warnings=0` once clean.
|
||||
- New pure logic should ship with tests. UI can follow with RNTL later.
|
||||
|
||||
---
|
||||
|
||||
## 6. Deferred / do not touch blindly
|
||||
|
||||
These look like leftovers but were left deliberately. Read before "fixing".
|
||||
|
||||
- **`Constant/Color.tsx`** is deprecated but still imported by ~20 files for
|
||||
neutral grays. Its brand tokens are now sourced from the theme, but its neutral
|
||||
values differ from `theme/colors` in several places. A blind find/replace to
|
||||
`useTheme()` **will change appearance** — migrate screen-by-screen with visual
|
||||
review.
|
||||
- **`EnvelopeCExcelPreview.tsx`** still uses raw axios (`responseType:
|
||||
'arraybuffer'`) and `react-native-fs` with `'ascii'` write encoding. The RNFS →
|
||||
`react-native-blob-util` dedupe and the axios → `core/http` migration are
|
||||
deferred because this sealed-bid document flow can't be runtime-verified here
|
||||
and the encodings aren't 1:1. Needs a device.
|
||||
- **Sealed-bid crypto** is AES-ECB with the symmetric key echoed to the server
|
||||
(`randomKeyGenerated`). This is a backend-contract weakness (audit §11); fixing
|
||||
it requires a coordinated backend change, not a client edit.
|
||||
- **`ResetPassword`** sends the new password as a URL query param. Migrated to
|
||||
`core/http` (so it's redacted in logs) but the query-param placement is a
|
||||
backend contract — see the `TODO(backend)` at the call site.
|
||||
- **`PurchaseTenderTile` "details" tap** (in `PurchaseTenderAllList`) now
|
||||
navigates to the registered `LiveTenderDetail` route, passing the flat tender
|
||||
`item` that screen reads from `route.params` — matching `LiveTenderTile`'s
|
||||
working pattern. It previously targeted an unregistered `'TenderDetail'` route
|
||||
(a latent bug that no-op'd with a dev warning).
|
||||
|
||||
---
|
||||
|
||||
## 7. Open work (gated on device or backend/ops)
|
||||
|
||||
- **Device/emulator needed:** full Auth/App two-stack navigator split
|
||||
(`AuthSessionGuard` + `registrationRouting` are the groundwork); release
|
||||
minify/shrink + App Bundle + ABI splits; verifying the Envelope C flow.
|
||||
- **Backend/ops needed:** HTTPS for `eproc-backend.lincpay` (then remove its
|
||||
cleartext exemption in `network_security_config.xml` and add pinning); rotate
|
||||
the previously-committed keystore + Sonar token and purge git history.
|
||||
- **Incremental:** finish `Constant/Color` → `useTheme`; per-screen typed
|
||||
navigation adoption (`useNavigation<NativeStackNavigationProp<RootStackParamList>>`);
|
||||
broaden accessibility labels; i18n scaffolding.
|
||||
@@ -0,0 +1,363 @@
|
||||
# ProcureLinc — Principal-Level Architectural Audit & Redesign
|
||||
|
||||
**Subject:** ProcureLinc React Native mobile app (e-procurement: auctions + tenders + eKYC + sealed-bid bidding)
|
||||
**Stack:** React Native 0.79.0, React 19, Redux Toolkit + redux-persist, React Query, STOMP/SockJS, Axios, Formik/Yup, Hermes + New Architecture (Fabric/TurboModules) enabled.
|
||||
**Scope:** Full-project architectural review — no code was modified. Every claim below is anchored to `file:line` evidence.
|
||||
**Verdict:** The app *works*, but it is at **prototype maturity carrying production-grade risk**. It is not currently safe to ship to a real user base handling money and Aadhaar/PAN PII, and it is not structured to scale to a large team or user count without significant rework. This document is the redesign blueprint.
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
ProcureLinc is a functional e-procurement client with genuinely good *ideas* in places (a clean `AppText` typography primitive, a correct paginated-list template in `LiveTender.tsx`, a working dark-mode theme system, STOMP-based live bidding). But the codebase is in a state common to fast-moving startups: **multiple half-finished architectural attempts layered on top of each other, none fully adopted, with security and correctness debt that compounds as it grows.**
|
||||
|
||||
The five findings that matter most, in priority order:
|
||||
|
||||
| # | Finding | Severity | Where |
|
||||
|---|---------|----------|-------|
|
||||
| 1 | **A binary signing keystore (`appKeyStore`) is committed to git**, and **release builds are signed with the public debug keystore** (`storePassword 'android'`). Anyone with repo access can forge signed ProcureLinc APKs. | 🔴 Critical | `android/app/appKeyStore` (git-tracked), `android/app/build.gradle:103` |
|
||||
| 2 | **A global Axios interceptor logs every request/response — full URLs, JWT headers, login passwords, Aadhaar/PAN/OTP, and sealed-bid amounts — to logcat, ungated by `__DEV__`, in release builds.** | 🔴 Critical | `src/Axios/Axios.ts:47-99` |
|
||||
| 3 | **The data layer is four competing API stacks across three different backend hosts**, and the dominant one (`apiCall`) *swallows every error and returns `{success:false}` cast to the success type* — defeating TypeScript, React Query error/retry/cache, and forcing brittle status-code checks everywhere. | 🔴 Critical (architecture) | `Axios.ts:124-141`, `api/client.ts`, `apis.ts`, `Constant/Variable.tsx` |
|
||||
| 4 | **App-wide cleartext HTTP is permitted in release, with no SSL pinning**; auth JWT + full login response (incl. PII) is persisted **unencrypted** in AsyncStorage; device binding is client-spoofable and DSC "binding" is UI-only. | 🔴 Critical (security) | `network_security_config.xml`, `Redux/Store.js:20-24`, `DeviceBinding.tsx`, `DscBinding.tsx` |
|
||||
| 5 | **God-components with mixed concerns and massive duplication** — `TenderBidding.tsx` is 1205 lines (crypto + REST + upload + validation + UI); `StartBidding`/`StartReverseBidding` and their card components are ~90% copy-paste forks. | 🟠 High (maintainability) | `TenderBidding.tsx`, `Bidding/*` |
|
||||
|
||||
**Bottom line:** before any new feature work, the project needs (a) a security remediation sprint (secrets, transport, logging, storage), and (b) a data-layer consolidation onto a single typed client + properly-configured React Query. Everything else — modularization, design-system unification, testing, CI/CD — follows from those two.
|
||||
|
||||
**On the "AI-powered operating system" vision:** I am challenging the premise directly (see §16). The current codebase contains **zero AI, agent, RAG, voice, or knowledge-graph subsystems**. That is not a criticism — an e-procurement app doesn't need them today. But the audit cannot review AI architecture that doesn't exist; instead §16 lays out what an AI-native evolution would *actually* require and why the current foundation must be fixed first.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current Architecture Overview
|
||||
|
||||
**Shape:** A single-module React Native app. No feature-module boundaries; organized by loose type-folders (`Screens/`, `Component/` **and** `components/`, `Hooks/`, `Services/`, `Redux/`, `socket/`, `theme/` **and** `Constant/Color`, `Styles/`).
|
||||
|
||||
**Runtime composition** (`App.tsx:19-41`): `NavigationContainer → QueryClientProvider → GestureHandlerRootView → AlertNotificationRoot → Redux Provider → PersistGate → Routes`. `LogBox.ignoreAllLogs(true)` is set at module load (`App.tsx:15`).
|
||||
|
||||
**Navigation** (`Navigation/MainStack.jsx`): one flat native-stack with 23 sibling screens; `Home` hosts a custom animated `BottomTab` (4 tabs). No logged-in/out navigator split — auth gating is a one-shot imperative redirect inside `Splash.tsx`.
|
||||
|
||||
**State:** Redux Toolkit, 6 slices (`token`, `auction`, `selectPurchaseAuction`, `selectPurchaseTender`, `tender`, `theme`), only `token` + `theme` persisted. `serializableCheck:false` globally (`Store.js:30`).
|
||||
|
||||
**Data:** Four coexisting mechanisms — `apiCall` wrapper (dominant), a second `apiClient` axios instance, raw `axios` calls in `apis.ts`, and raw `axios` in screens — hitting **three** hardcoded hosts: `eproc.godemo.in` (REST), `procurelinc.in` (sockets), `procurelinc.com` (dead `client.ts`).
|
||||
|
||||
**Real-time:** STOMP-over-SockJS (`socket/Socket.tsx`) for receiving bid updates; bids are *placed* over REST, not STOMP.
|
||||
|
||||
**Crypto:** JS-side AES (ECB mode, SHA-256 key truncated to 128 bits) + RSA (jsencrypt) for sealed-bid envelopes (`Services/Encryption/encrypt.tsx`).
|
||||
|
||||
**Native:** No custom Java/Kotlin — stock RN host. Hermes on, New Architecture on. No env abstraction, no tests, no CI/CD.
|
||||
|
||||
Total: ~26k LOC of TS/JS across 138 source files.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architectural Strengths (worth preserving)
|
||||
|
||||
An honest audit names what's good so it isn't lost in a refactor:
|
||||
|
||||
- **`components/AppText.tsx`** — a proper typographic primitive: variant + theme color + Android-safe font-family merging. This is the seed of a real design system. Keep and expand.
|
||||
- **`useTheme` + `ThemeSlice`** — a working light/dark/system theme with a user switcher (`CustomDrawer.tsx`). Solid foundation; the problem is only that half the app bypasses it.
|
||||
- **`LiveTender.tsx`** — the *correct* FlatList pattern: memoized `renderItem`/`renderFooter`, `keyExtractor`, infinite-scroll pagination via React Query `fetchNextPage`/`hasNextPage`, and tuned perf props (`initialNumToRender`, `windowSize`, `removeClippedSubviews`). **This should be the template every list screen follows.**
|
||||
- **`socket/Socket.tsx`** — a reasonable `useReducer`-based STOMP abstraction with a pending-subscription queue and reconnect/heartbeat config. The core is sound; the consumers misuse it.
|
||||
- **Modern baseline** — RN 0.79, Hermes, New Architecture, React Query, Redux Toolkit, Formik/Yup are all present. The *ingredients* of a good architecture exist; they are simply half-wired.
|
||||
- **`android:allowBackup="false"`** is correctly set — limits `adb backup` extraction of the (unfortunately plaintext) token.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architectural Weaknesses
|
||||
|
||||
Grouped by subsystem. Severity: 🔴 Critical, 🟠 High, 🟡 Medium.
|
||||
|
||||
### 4.1 Data / API layer
|
||||
- 🔴 **Four parallel API stacks, three hosts, two auth-header names.** `apiCall` sends `Authentication: Bearer` (note: not `Authorization`) reading `token.token` (`Axios.ts:118`); `apiClient` sends `Authorization` reading `token.token.token` (`client.ts:17`); screens send raw axios with `Authentication: Bearer ${Token.token}` (`BiddingCard.tsx:82`). There is no single place to add retry, refresh, tracing, or error handling.
|
||||
- 🔴 **`apiCall` swallows errors and casts `{success:false}` to `R`** (`Axios.ts:124-141`). This is the single most damaging decision in the codebase — it defeats the type system, makes React Query believe every call succeeded (`isError` never fires, retries never happen, bad responses get cached as valid), and forces defensive `res?.statusCode===200` checks in every consumer. `try/catch` blocks around `apiCall` (e.g. `apis.ts:76-82`) are dead code because it never throws.
|
||||
- 🟠 **React Query is present but mis-configured.** `new QueryClient()` with no defaults (`App.tsx:20`) ⇒ `staleTime:0`, refetch-on-mount/focus ⇒ refetch storms. It's also instantiated *inside the component body*, so it's recreated on every re-render. Broken query keys: `useAucitonItemDetail` uses `queryKey:[]` (all instances collide), `useGetAuciton` uses static `['getAuction']` ignoring the id. Missing `enabled` guards fire `?tenderId=undefined` calls. No `invalidateQueries` anywhere — lists go stale after writes.
|
||||
- 🟡 **Dead/broken config.** `Constant/AllUrls.tsx` builds ~30 URLs from `${Variable.Main_Base}` — but `Main_Base` doesn't exist, so every URL is `undefined/...`. This whole file is leftover from a different (e-commerce) app. The generic hooks (`useFetch/useUpdate/useDelete`) are imported nowhere.
|
||||
|
||||
### 4.2 State management
|
||||
- 🟠 **The token slice lies about its type.** Declared `{token:''}` (`Token.tsx:3`) but at runtime holds a full object (`Login.tsx:212`). No `RootState`/`AppDispatch` types exist; every `useSelector` is `(state:any)`. Zero compile-time safety on auth state.
|
||||
- 🟡 **Two inconsistent logout paths** — `removeToken()` (sets `''`) vs `setToken({})` (leaves a truthy empty object) vs direct `AsyncStorage.removeItem('persist:root')`. These can desync in-memory state from disk and confuse the splash auth check.
|
||||
- 🟡 **`serializableCheck:false` globally** hides genuine non-serializable leaks, and the slices store whole API response objects.
|
||||
|
||||
### 4.3 UI / screens
|
||||
- 🟠 **God-components:** `TenderBidding.tsx` (1205), `ProfileTab.tsx` (1031), `Login.tsx` (792), `Ragistration.tsx` (725, ~25 `useState`), `Emd.tsx` (608). Networking + crypto + validation + sub-components + rendering all in one file.
|
||||
- 🟠 **Copy-paste forks:** `StartBidding`≈`StartReverseBidding` (~90%), `BiddingCard`≈`ReverseBiddingCard` (identical StyleSheets) — diverging subtly (one card is `memo`'d, the other isn't; wrong color import in reverse). Every fix must be made twice, and they *will* drift.
|
||||
- 🟠 **Split-brain design system:** `src/Component/` (old) vs `src/components/` (new); two `MyInput`, two `PrimaryButton`, three side-drawers (one dead), two color palettes with **two different brand oranges** (`Constant/Color:#F97316` vs `theme/colors:#e39546ff`).
|
||||
|
||||
### 4.4 Navigation
|
||||
- 🟠 **No route type safety** (`createNavigationContainerRef<any>()`, `useNavigation<any>()`, `useRoute<any>()` everywhere), **no route guards** (every screen reachable), **no deep linking** (`NavigationContainer` has no `linking`).
|
||||
|
||||
### 4.5 Real-time
|
||||
- 🟠 **`subscribe()` called in the render body** (`StartBidding.tsx:137`, `StartReverseBidding.tsx:171`), re-registering the STOMP callback every render; **no `disconnect()`/`unsubscribe()` on unmount** ⇒ leaked connections that keep reconnecting (5s) and draining battery/data. Bids split across REST *and* STOMP for the same feature.
|
||||
|
||||
---
|
||||
|
||||
## 5. Design Smells
|
||||
|
||||
- **Misspelled, leaked-through names as public API:** folder `Ragistration/`, components `Serach/SeacrhInput`, props `isVarify`/`maxlength`, constant `FORARD_AUCTION_ITEM`, string `'Failed load cities'`. Names are the cheapest documentation; these signal no review gate.
|
||||
- **Duplicate constants pointing at the same endpoint** (`SELECTED_AUCTION_ITEM` == `EMD_CHECK`).
|
||||
- **`useState` used as a value holder**: `const tenderId = useState(route.params?.tenderId)` captures the `[state,setState]` tuple and never uses it (`TenderBidding.tsx:71`).
|
||||
- **Sub-components declared inside a parent's render body** (`InfoRow`, `EnvelopeButton`, `renderEnvelopeItem` in `TenderBidding.tsx:113-309`) — recreated every render, defeating memoization and remounting subtrees.
|
||||
- **A test/debug screen wired into production** (`TestScreen/DecryptionScreen.tsx`) with a hardcoded real AES key copyable to clipboard.
|
||||
- **`LogBox.ignoreAllLogs(true)`** — silencing all warnings is hiding the smoke detector.
|
||||
|
||||
## 6. Anti-Patterns
|
||||
|
||||
- **Error-swallowing service layer** (`apiCall` returning `{success:false} as R`) — the "exceptions considered harmful, return sentinels typed as success" anti-pattern.
|
||||
- **Effect logic in render** (`subscribe()` outside `useEffect`).
|
||||
- **Fat controller / god object** (the 1205-line screen).
|
||||
- **Shotgun surgery by construction** (forward/reverse duplication guarantees every change touches ≥2 files).
|
||||
- **Config-by-comment** (base URLs selected by commenting/uncommenting lines in `ApiUrls.tsx`).
|
||||
- **Boolean-flip refetch** (`setAllBoolean(reload:!prev.reload)` to trigger a parent `useEffect` instead of query invalidation).
|
||||
- **Primitive obsession in state** (~25 discrete boolean `useState`s in `Ragistration.tsx` instead of a reducer/state machine).
|
||||
|
||||
## 7. Bottlenecks
|
||||
|
||||
- **Refetch storms** from `staleTime:0` + refetch-on-focus on dashboards/lists.
|
||||
- **Whole-list re-renders** on every live bid tick (inline `renderItem`, unmemoized reverse card) — worsens as bidder lists grow.
|
||||
- **Per-second `setState` timers** (`CountdownTimer` *and* the parallel `AuctionTimer`) re-rendering timer subtrees every second; two implementations doing it.
|
||||
- **Nested non-virtualized `FlatList scrollEnabled={false}` inside `ScrollView`** (`TenderBidding.tsx:1056,1099`) renders all rows eagerly.
|
||||
- **Synchronous blocking API call on the Splash screen** (registration-state fetch gates app entry for tender users, `Splash.tsx:45-107`).
|
||||
- **Startup polyfills** (`Buffer`, `TextEncoder`) set synchronously; `QueryClient` recreated per render.
|
||||
|
||||
## 8. Tight Coupling
|
||||
|
||||
- **Transport layer knows about UI and navigation:** `apiCall` dispatches `setToken({})` on 401 (`Axios.ts:129`); `Socket.placeBid` calls `showSuccessAlert`/`showErrorAlert` from inside the service (`Socket.tsx:200-207`). Data/transport should emit events, not drive UI.
|
||||
- **Screens coupled directly to the crypto implementation** (`TenderBidding` calls `encrypt`/`encryptWithRSA` inline with hardcoded ECB assumptions).
|
||||
- **Redux token shape coupled across ≥3 readers** with different nesting (`token.token` vs `token.token.token`) — change one, break the others silently.
|
||||
- **Components coupled to two different color modules**, so a rebrand touches both.
|
||||
|
||||
## 9. Hidden Dependencies
|
||||
|
||||
- **`jsencrypt` is in `devDependencies`** (`package.json:91`) but imported by runtime code (`encrypt.tsx`). A production install with `--production` (or a clean CI install) **breaks the sealed-bid crypto path**. This is a latent production outage.
|
||||
- **`global.Buffer` / `global.TextEncoder`** monkey-patched at startup (`index.js:9-10`, `App.tsx:22`) — any code depending on them has an invisible ordering dependency on app bootstrap.
|
||||
- **Socket token captured at hook-init** (`StartBidding.tsx:55`) — if the token refreshes mid-session, the socket keeps the stale one with no re-read.
|
||||
- **`text-encoding`, `react-native-get-random-values`** are crypto polyfills whose absence would silently weaken/break encryption.
|
||||
|
||||
## 10. Single Points of Failure
|
||||
|
||||
- **The one signing key** — committed to git, so its secrecy is already lost; simultaneously the *only* thing standing between the app and forged updates.
|
||||
- **`apiCall` / the default axios instance** — every feature funnels through it; its error-swallowing behavior means one backend contract change produces silent `undefined`s app-wide rather than a visible failure.
|
||||
- **The single flat navigator + imperative Splash gate** — if Splash's registration-state call fails, the recovery path *wipes persisted state and forces re-login* (`Splash.tsx:103`); a backend hiccup logs everyone out.
|
||||
- **`Constant/Variable.tsx` socket host** — hardcoded to a single origin; no failover.
|
||||
|
||||
---
|
||||
|
||||
## 11. Security Risks
|
||||
|
||||
> Full evidence in the security appendix; the highest-priority items:
|
||||
|
||||
| Severity | Risk | Evidence |
|
||||
|----------|------|----------|
|
||||
| 🔴 Critical | **Signing keystore committed to git** (`.gitignore` `*.keystore` missed it — no extension) + **release signed with debug key** (`storePassword 'android'`). Forgeable/ tamperable APKs. | `android/app/appKeyStore` (git-tracked, verified), `build.gradle:88-104` |
|
||||
| 🔴 Critical | **Global request/response logger dumps JWTs, passwords, Aadhaar/PAN/OTP, and sealed-bid values to logcat in release** (no `__DEV__` gate). 378 `console.*` calls across 34 files, none stripped (no `transform-remove-console`). | `Axios/Axios.ts:47-99`, `Login.tsx:213-216`, `encrypt.tsx:30-155`, `TenderBidding.tsx:474,561` |
|
||||
| 🔴 Critical | **Committed SonarQube auth token.** | `sonar-project.properties:13` (verified git-tracked) |
|
||||
| 🟠 High | **App-wide cleartext HTTP permitted in release + no SSL pinning.** A hardcoded `http://` base with attached Bearer token also lives in `client.ts`. | `network_security_config.xml`, `AndroidManifest.xml:19`, `api/client.ts:6` |
|
||||
| 🟠 High | **JWT + full login response persisted unencrypted in AsyncStorage.** Readable on rooted/backed-up devices. No Keychain/Keystore/EncryptedStorage. | `Redux/Store.js:20-24`, `Login.tsx:212` |
|
||||
| 🟠 High | **Device binding is client-spoofable** (`getUniqueId()`/`getIpAddress()`, no Play Integrity attestation) and **silently falls back to fabricated GPS coords** on permission denial. This is the *fraud control* for bidding. | `DeviceBinding.tsx:47-86` |
|
||||
| 🟠 High | **Weak sealed-bid crypto:** AES-**ECB** mode, SHA-256 key **truncated to 128 bits**, and the symmetric key is **echoed back to the server in plaintext alongside the ciphertext** (`randomKeyGenerated`) — adding no real confidentiality. | `encrypt.tsx:9-71`, `TenderBidding.tsx:546` |
|
||||
| 🟡 Medium | **WebView KYC success detected by page-title string match** (spoofable); loads **raw server-supplied HTML** with JS enabled; logs Aadhaar callback URLs. | `KycWebViewScreen.tsx:85-143` |
|
||||
| 🟡 Medium | **No root/tamper/Frida detection, no `FLAG_SECURE`** (sealed bids & Aadhaar OTP are screenshot-/screen-recordable and appear in the app-switcher thumbnail), **no ProGuard/R8** minification. | grep-confirmed absent; `build.gradle:60,104` |
|
||||
| 🟡 Medium | **`loginWithoutDecryption` endpoints** + **`ResetPassword` sends new password as a URL query param** (`?newPassword=...`) — lands in server/proxy logs. | `apis.ts:29-46`, `ResetPassword.tsx:220` |
|
||||
| 🟡 Medium | **Repo-committed bulk-user script with default password `P@ssword123`** targeting the live backend. | `scripts/create-users.js:10` |
|
||||
|
||||
## 12. Privacy Risks
|
||||
|
||||
- **Regulated PII logged in the clear.** Full Aadhaar number, PAN, GST, DOB are `console.log`-ed (`Ragistration.tsx:363`) and the **Aadhaar OTP is displayed in an `Alert` dialog** (`Ragistration.tsx:422`). Under UIDAI/Aadhaar Act and India's DPDP Act, logging full Aadhaar is a compliance exposure, not just a bug.
|
||||
- **PII travels over a transport that permits cleartext** (§11) and through the global logger (§11) on every request.
|
||||
- **No data-minimization or masking layer** anywhere — the app has no concept of "sensitive field."
|
||||
- **Unjustified permissions:** `READ_MEDIA_AUDIO` (no audio feature) and always-on FINE+COARSE location whose only use is a device-binding geo-tag that fabricates coordinates anyway.
|
||||
- **Cached profile PII** (from `getcontractorfulldetails`) may persist unencrypted via React Query + any persisted slice.
|
||||
|
||||
## 13. Scalability Risks
|
||||
|
||||
- **Team scalability** is the nearer-term problem than user scalability: no module boundaries, god-components, and duplicated screens mean two developers *cannot* work on bidding without colliding, and every change risks the forward/reverse drift.
|
||||
- **No env/config abstraction** ⇒ dev/staging/prod require source edits and recompiles; you cannot run a QA build against staging without a code change. This blocks a release pipeline.
|
||||
- **The error-swallowing data layer** means production incidents are invisible (no error surfaced, bad data cached as good) — you scale the user base but lose the ability to see failures.
|
||||
- **No observability** (no crash reporting, no analytics, no structured logging) — at 10k+ users you'd be flying blind.
|
||||
- **Backend coupling by string concatenation** (`${BASE_URL}path?query=`) with no client SDK/contract means backend changes break the app silently.
|
||||
|
||||
## 14. Performance & Battery Risks
|
||||
|
||||
- Refetch storms (§7), per-second timer re-renders (§7), whole-list re-renders on bid ticks (§7).
|
||||
- **Leaked STOMP connections** (no unmount disconnect) keep reconnecting every 5s with 1s heartbeats — a measurable battery/data drain when a user leaves a bidding screen without killing the app.
|
||||
- **No `getItemLayout`** on any list — every list pays layout measurement cost.
|
||||
- **No release minification/shrinkResources** — larger APK, and no ABI splits/App Bundle so users download all architectures.
|
||||
- **Duplicate heavy libs shipped:** `exceljs` **and** `xlsx`; `react-native-fs` **and** `react-native-blob-util`; two icon systems — dead weight in the bundle.
|
||||
|
||||
## 15. Maintainability Risks
|
||||
|
||||
- **~26k LOC, zero tests, no CI, lint/format enforced only by developer discipline** (no husky/lint-staged). There is no automated safety net for any refactor.
|
||||
- **`.jsx`/`.js` files bypass TypeScript** entirely (`Store.js`, `MainStack.jsx`, `Routes.jsx`, `ImagePath.jsx`).
|
||||
- **Duplication tax:** every bidding change is a 2-file (often 4-file) edit; the two color palettes and two component roots triple the surface for any visual change.
|
||||
- **`any` everywhere** (navigation, selectors, socket callbacks, `apiCall` generics) — the compiler cannot help.
|
||||
- **Dead code** (`AllUrls.tsx`, `DrawerStack`, `CustomDrawer`, generic hooks, `useLogin`, `userLogin`) increases cognitive load and hides which path is live.
|
||||
|
||||
---
|
||||
|
||||
## 16. AI System Evaluation
|
||||
|
||||
**Finding: there is no AI subsystem in this codebase.** No LLM/model integration, no agent/orchestration layer, no intent recognition, no task planning, no RAG/retrieval, no knowledge graph, no vector/embedding index, no voice pipeline, no local or cloud inference. Grep confirms none of the associated libraries or patterns are present.
|
||||
|
||||
I'm flagging this plainly because the brief framed the review around an "AI-powered operating system," and a Principal Engineer's job is to challenge the premise rather than invent findings. **An e-procurement bidding app does not need any of that to succeed, and bolting an "AI OS" onto the current foundation would be a strategic mistake** — you'd be building a second story on a cracked foundation (committed keys, cleartext transport, error-swallowing data layer).
|
||||
|
||||
If AI capabilities *are* a genuine roadmap goal, here is the honest prerequisite ordering and what each would require:
|
||||
|
||||
1. **Fix the foundation first** (§17–19). An AI feature that calls a cloud LLM over the current cleartext-permitted transport, logging prompts (which would contain tender/bid PII) through the global logger, is a data-leak generator.
|
||||
2. **Realistic, high-value AI features for *this* domain** (in priority order):
|
||||
- **Tender discovery & ranking** — semantic search over live tenders (embeddings + a vector index server-side; the app just calls a ranked-search endpoint). Low risk, high user value.
|
||||
- **Document intelligence** — extract/summarize tender documents and envelope requirements (server-side LLM + RAG over the tender corpus; app renders results).
|
||||
- **Bid-assist / anomaly detection** — flag unusual bid patterns or missing mandatory documents before submission (server-side).
|
||||
- **Conversational support agent** — scoped, tool-using assistant answering "what tenders match my class?" via typed tool calls to existing endpoints.
|
||||
3. **Architectural requirement:** all of the above should be **server-mediated** (model keys never on-device; PII never sent to a third-party model without consent + DPA). The mobile app's role is a thin, typed client to an AI *gateway* service — which is exactly the clean data-layer boundary §17 prescribes. **Build the clean API/domain layer, and AI features become just more endpoints behind it.**
|
||||
|
||||
**Verdict:** AI is a *future* concern gated on foundation work. Do not start it now.
|
||||
|
||||
---
|
||||
|
||||
## 17. Android System Evaluation
|
||||
|
||||
- **Build config is not release-ready.** No release keystore (debug-signed release), no minify/shrink/ProGuard, no ABI splits or App Bundle, hardcoded `versionCode 1`/`versionName 1.0` with no bump automation.
|
||||
- **New Architecture + Hermes are on** (good, modern), and confirmed in native host code (`MainApplication.kt`). No custom native modules — everything is JS + autolinked libs, which keeps the native surface small but means device-binding/DSC/crypto get *no* native security guarantees (no Keystore-backed keys, no attestation).
|
||||
- **Manifest:** app-wide cleartext, no orientation lock, single exported `MainActivity` with only MAIN/LAUNCHER (no deep links). `allowBackup=false` is correct.
|
||||
- **Cross-platform:** iOS project exists and is set up (`ios/` with Podfile, AppDelegate.swift, PrivacyInfo.xcprivacy) — this is *not* Android-only, so all remediation must be applied per-platform (e.g. ATS on iOS mirrors the network-security-config fix).
|
||||
- **Tooling gaps:** no tests, no CI/CD (`.github` contains only a Java-upgrade tooling artifact), stock ESLint/Prettier with no enforcement, committed Sonar token pointing at `localhost:9000` scanning `node_modules`.
|
||||
|
||||
## 18. Data Architecture Review
|
||||
|
||||
**Current:** string-concatenated endpoints off a hardcoded base, four transport styles, error-swallowing wrapper, mis-configured React Query, plaintext persisted auth, three backend hosts.
|
||||
|
||||
**Target (see §20):** one typed API client → a **domain/repository layer** (feature-scoped: `auth`, `tenders`, `auctions`, `bidding`, `kyc`, `profile`) → React Query hooks with correct keys/`staleTime`/invalidation → screens. Errors *thrown* and normalized once. Secrets in secure storage. Env-driven base URLs. This is the highest-leverage change in the whole document.
|
||||
|
||||
## 19. UX Architecture Review
|
||||
|
||||
- **Strong primitives, inconsistent application.** `AppText` + `useTheme` are good, but per-element inline styles + `color=` overrides dilute the tokens, and half the app uses the *other* palette/components.
|
||||
- **No accessibility** (zero `accessibilityLabel`/`Role`/`Hint` — icon-only touchables and steppers are unusable with TalkBack), **no i18n** (hardcoded English + inline `₹`), **no responsive scaling** (fixed pixels), **no error boundary** (an unhandled render error crashes the app; warnings are globally silenced).
|
||||
- **Inconsistent loading/empty/error states** — no shared wrapper; main lists lack `ListEmptyComponent`; most `catch` blocks only `console.log`, so React Query `isError` never reaches the user.
|
||||
- **Forms are backwards:** the *simplest* screens (Login, SignUp) use Formik+Yup; the *hardest* (multi-step registration, envelope bidding) use hand-rolled `useState` state machines.
|
||||
|
||||
---
|
||||
|
||||
## 20. Recommended Target Architecture
|
||||
|
||||
**Principle:** feature-modular, layered, with dependencies pointing inward (Clean/Hexagonal). The mobile app is a *thin typed client* over well-defined domain boundaries.
|
||||
|
||||
```
|
||||
app/
|
||||
├─ core/ # cross-cutting, no feature knowledge
|
||||
│ ├─ config/ env.ts # ← react-native-config; NO hardcoded hosts
|
||||
│ ├─ http/ client.ts # ONE axios instance; throws on error
|
||||
│ │ interceptors # auth (single token read), tracing, refresh
|
||||
│ ├─ storage/ secure.ts # react-native-keychain for token; MMKV/AsyncStorage for prefs
|
||||
│ ├─ crypto/ envelope.ts # AES-GCM (not ECB), key never echoed to server
|
||||
│ ├─ realtime/ stomp.ts # subscribe in useEffect; disconnect on unmount
|
||||
│ ├─ errors/ AppError.ts # normalized error type + ErrorBoundary
|
||||
│ ├─ observability/ # crash reporting + structured, PII-scrubbed logging (DEV-gated)
|
||||
│ └─ ui/ # ONE design system: AppText, Button, Input, Modal, List, tokens
|
||||
├─ features/
|
||||
│ ├─ auth/ { api, hooks, screens, state }
|
||||
│ ├─ tenders/ { api, hooks, screens }
|
||||
│ ├─ auctions/ { api, hooks, screens }
|
||||
│ ├─ bidding/ { api, hooks, screens } # forward+reverse UNIFIED via config/props
|
||||
│ ├─ kyc/ { api, hooks, screens }
|
||||
│ └─ profile/ { api, hooks, screens }
|
||||
├─ navigation/ RootNavigator # typed ParamList; Auth vs App stack split; deep linking
|
||||
└─ App.tsx # QueryClient (module-const, tuned defaults) + ErrorBoundary + providers
|
||||
```
|
||||
|
||||
**Key decisions & justifications:**
|
||||
|
||||
| Decision | Replaces | Principle |
|
||||
|----------|----------|-----------|
|
||||
| **One `apiClient` that throws typed `AppError`** | 4 stacks + error-swallowing | Single Responsibility; lets React Query do retry/error/cache; restores TypeScript safety |
|
||||
| **`react-native-config` env files** (dev/staging/prod) | Hardcoded/commented hosts | 12-factor config; enables CI + QA builds without code edits |
|
||||
| **`react-native-keychain` for token**, MMKV for prefs | Plaintext AsyncStorage | Defense in depth for auth/PII |
|
||||
| **Typed navigation** (`RootStackParamList`) + **Auth/App stack split** | `any` + imperative Splash gate | Compile-time route safety; declarative auth gating; enables deep links |
|
||||
| **Feature modules** (`api/hooks/screens` per domain) | Type-folders + god-components | High cohesion, low coupling; two devs work without collision |
|
||||
| **Unify forward/reverse bidding** behind one config-driven screen | 90% duplicated forks | DRY; one place to fix bugs |
|
||||
| **One design system** (`core/ui`), delete `src/Component` + one palette | Split-brain components/colors | Single source of visual truth |
|
||||
| **AES-GCM, key wrapped by server RSA public key, never echoed back** | AES-ECB + echoed key | Actual confidentiality for sealed bids |
|
||||
| **`transform-remove-console` + PII-scrubbing logger, DEV-gated** | 378 raw console calls | Privacy/compliance |
|
||||
| **Server-mediated integrity** (Play Integrity / SafetyNet) for device binding | Client-spoofable IDs | Real anti-fraud |
|
||||
| **ErrorBoundary + shared Loading/Empty/Error wrappers** | Per-screen ad-hoc + crashes | Consistent, resilient UX |
|
||||
|
||||
---
|
||||
|
||||
## 21. Migration Roadmap
|
||||
|
||||
Sequenced so each phase is shippable and de-risks the next. **Phase 0 is non-negotiable and blocks release.**
|
||||
|
||||
### Phase 0 — Security stop-the-bleed (days, do first)
|
||||
1. **Rotate and remove signing keys from git.** Purge `appKeyStore` + `debug.keystore` from history (`git filter-repo`), rotate on Play Console if `appKeyStore` was the release key, move signing to `keystore.properties` (gitignored) / CI secrets. Create a real release signing config.
|
||||
2. **Rotate the leaked Sonar token**, remove it from `sonar-project.properties`, fix `sonar.sources` to exclude `node_modules`.
|
||||
3. **Gate/remove the global logger** behind `__DEV__` + add `babel-plugin-transform-remove-console` for release; scrub PII. Remove `TestScreen/DecryptionScreen` and the hardcoded key.
|
||||
4. **Disable cleartext** (`usesCleartextTraffic=false`, restrictive network-security-config for release; iOS ATS) once backends are HTTPS. Fix/remove the `http://` `client.ts`.
|
||||
5. **Move the token to Keychain**; stop persisting the full login response.
|
||||
6. **Move `jsencrypt` to `dependencies`** (latent prod outage).
|
||||
|
||||
### Phase 1 — Data-layer consolidation (1–2 sprints)
|
||||
7. Introduce `core/config` env + a **single throwing `apiClient`**; migrate feature by feature off `apiCall`/raw axios/`client.ts`. Delete `AllUrls.tsx` and the dead generic hooks.
|
||||
8. Tune `QueryClient` defaults (`staleTime`, `retry`), fix query keys, add `enabled` guards and `invalidateQueries` on mutations. Move `QueryClient` to a module const.
|
||||
9. Add typed `RootState`/`AppDispatch`; fix the token slice type; unify logout on `persistor.purge()` + Keychain clear + socket disconnect + query cache clear.
|
||||
|
||||
### Phase 2 — Structure & correctness (2–3 sprints)
|
||||
10. Typed navigation + **Auth/App stack split**; move Splash gating into declarative navigator logic.
|
||||
11. **Unify forward/reverse bidding** and the two card components into one config-driven module; move `subscribe` into `useEffect` with cleanup; disconnect on unmount.
|
||||
12. Break up `TenderBidding`/`ProfileTab`/`Ragistration`: extract crypto, API, and validation into `core`/feature modules; convert registration to a reducer/state machine (or Formik).
|
||||
13. Fix crypto (AES-GCM, no echoed key); add `FLAG_SECURE` to bidding/KYC; add Play Integrity to device binding.
|
||||
|
||||
### Phase 3 — Consistency & platform (2 sprints)
|
||||
14. Collapse to **one design system + one palette**; delete `src/Component` and `Constant/Color` after migration. Add ErrorBoundary + shared state wrappers.
|
||||
15. Add responsive scaling + accessibility labels; introduce i18n scaffolding (even if only `en` at first).
|
||||
16. Remove duplicate deps (`xlsx` or `exceljs`; `fs` or `blob-util`; one icon lib); enable release minify/shrink + App Bundle + ABI splits; automate version bumps.
|
||||
|
||||
### Phase 4 — Quality gates (ongoing, start in Phase 1)
|
||||
17. CI (GitHub Actions): install → typecheck → lint → test → build. Add husky + lint-staged.
|
||||
18. Testing: unit tests for `core/http`, crypto, and domain hooks; component tests for the design system; a Detox smoke test for login→bid. Add crash reporting + analytics.
|
||||
|
||||
## 22. Prioritized Refactoring Plan (single ranked backlog)
|
||||
|
||||
| Rank | Item | Effort | Risk if skipped |
|
||||
|------|------|--------|-----------------|
|
||||
| 1 | Purge & rotate committed keys; real release signing | S | Critical — forgeable app |
|
||||
| 2 | DEV-gate/strip logging; scrub PII | S | Critical — credential/PII leak |
|
||||
| 3 | Rotate Sonar token | XS | Critical — exposed secret |
|
||||
| 4 | HTTPS-only + kill cleartext + fix `client.ts` | S | High — MITM |
|
||||
| 5 | Token → Keychain; stop persisting login response | S | High — PII at rest |
|
||||
| 6 | `jsencrypt` → dependencies | XS | High — prod crypto outage |
|
||||
| 7 | Single throwing `apiClient` + env config | M | Critical — invisible failures, no envs |
|
||||
| 8 | React Query defaults + keys + invalidation | M | High — stale data, refetch storms |
|
||||
| 9 | Typed navigation + Auth/App split | M | High — no auth enforcement |
|
||||
| 10 | Unify bidding forward/reverse + socket cleanup | M | High — drift + leaks |
|
||||
| 11 | Break up god-components | L | High — unmaintainable |
|
||||
| 12 | Crypto (AES-GCM) + FLAG_SECURE + Play Integrity | M | High — weak sealed bids/fraud |
|
||||
| 13 | One design system + one palette | L | Medium — visual drift |
|
||||
| 14 | ErrorBoundary + shared states + a11y + i18n | M | Medium — UX/compliance |
|
||||
| 15 | Dedup deps + release optimization + CI/CD + tests | M | Medium — quality/velocity |
|
||||
|
||||
## 23. Risk Assessment (residual, if nothing is done)
|
||||
|
||||
- **Compliance/legal:** logging full Aadhaar + storing PII in plaintext over cleartext-capable transport is a DPDP/UIDAI exposure — **the highest business risk**, not merely technical.
|
||||
- **Financial fraud:** spoofable device binding + UI-only DSC + weak sealed-bid crypto undermine the integrity guarantees a tendering platform legally depends on.
|
||||
- **Supply-chain/integrity:** committed + debug signing key means a malicious actor with repo access (or the public debug key) can distribute a trojaned "ProcureLinc."
|
||||
- **Operational:** error-swallowing + no observability means production incidents are silent; you learn about outages from users, not dashboards.
|
||||
- **Velocity:** duplication + god-components + no tests means feature velocity *decreases* as the app grows, and every bidding change risks a regression in the un-fixed twin.
|
||||
|
||||
## 24. Future Vision
|
||||
|
||||
Once the foundation is fixed, ProcureLinc is well-positioned:
|
||||
|
||||
- **Short term (foundation solid):** a secure, observable, typed, feature-modular app where two teams can build tenders and auctions in parallel; QA runs against staging without code edits; releases are automated and signed correctly.
|
||||
- **Medium term:** offline-first for tender/auction browsing (React Query persistence + optimistic bid queueing over a resilient socket), full accessibility + i18n for a national user base, and a real design system that supports white-labeling for multiple procurement authorities.
|
||||
- **Long term (only after the above):** the AI features in §16 — semantic tender discovery, document intelligence, bid-assist — added as *endpoints behind the clean data layer*, with all model access server-mediated and PII-governed. The "AI-powered OS" framing is better realized as **an intelligent procurement platform with a thin, secure mobile client**, not an AI runtime on the device.
|
||||
|
||||
**The single most important sentence in this report:** *Fix the security foundation and consolidate the data layer first; every other improvement — modularity, design system, testing, and eventually AI — compounds cleanly on top of those two, and none of them is safe or durable without them.*
|
||||
|
||||
---
|
||||
|
||||
### Appendix A — Evidence index (key files)
|
||||
- Data/transport: `src/Axios/Axios.ts`, `src/api/client.ts`, `src/Services/ApiCall/{apis.ts,ApiUrls.tsx,Hooks.ts}`, `src/Hooks/use{Fetch,Create,Update,Delete}.ts`, `src/Constant/{Variable.tsx,AllUrls.tsx}`
|
||||
- State: `src/Redux/Store.js`, `src/Redux/Slices/*`
|
||||
- Realtime/crypto: `src/socket/Socket.tsx`, `src/Services/Encryption/encrypt.tsx`
|
||||
- UI: `src/Screens/Tender/tenderBidding/TenderBidding.tsx`, `src/Screens/Bidding/{StartBidding,StartReverseBidding,Emd}.tsx` + `Component/{BiddingCard,ReverseBiddingCard}.tsx`, `src/Screens/Ragistration/Ragistration.tsx`, `src/Screens/Tender/LiveTender.tsx` (good template), `src/components/AppText.tsx`
|
||||
- Navigation: `src/Navigation/{MainStack.jsx,Bottom.tsx,NavigationService.ts}`, `src/Screens/Splash/Splash.tsx`
|
||||
- Android/build: `android/app/build.gradle`, `android/gradle.properties`, `android/app/src/main/{AndroidManifest.xml,res/xml/network_security_config.xml}`, `android/app/appKeyStore` (remove), `sonar-project.properties` (remove token)
|
||||
- Security-sensitive: `src/Screens/Home/KycWebViewScreen.tsx`, `src/Screens/Tender/{DeviceBinding,DscBinding}.tsx`, `src/Screens/TestScreen/DecryptionScreen.tsx` (remove), `scripts/create-users.js`
|
||||
@@ -0,0 +1,15 @@
|
||||
# Mobile bid encryption contract
|
||||
|
||||
The mobile client uses the server-issued envelope key `K` for all bid
|
||||
submissions. For text values, encrypt with AES-128/ECB/PKCS5 using the first
|
||||
16 bytes of SHA-256(`K`) and send Base64 ciphertext. For the Envelope-C BOQ,
|
||||
encrypt the raw workbook bytes with AES-128/ECB/PKCS5 using the first 16 bytes
|
||||
of SHA-1(`K`) and upload the raw ciphertext bytes as the multipart `file`.
|
||||
|
||||
The same `K` must be sent as `randomKeyGenerated` and RSA-wrapped with every
|
||||
officer/contractor public key in `saveEnvelopesOfficersDetailsResDTO`.
|
||||
|
||||
Envelope-C `requestDto` includes `deviceType: "Mobile"`, `fieldValue`,
|
||||
`fieldValueInWord`, `hashValue` (SHA-256 of the unencrypted workbook bytes),
|
||||
`fileName`, and the wrapped officer keys. The workbook editor preserves the
|
||||
source workbook structure and only applies permitted cell edits.
|
||||
@@ -2,12 +2,14 @@
|
||||
* @format
|
||||
*/
|
||||
|
||||
import './src/core/platform/polyfills';
|
||||
// Installs the single DEV console switch before the app and its screens load.
|
||||
import './src/core/observability/logger';
|
||||
import { AppRegistry } from 'react-native';
|
||||
import App from './App';
|
||||
import { name as appName } from './app.json';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { Buffer } from 'buffer';
|
||||
global.Buffer = Buffer;
|
||||
|
||||
const Root = () => (
|
||||
<SafeAreaProvider>
|
||||
<App />
|
||||
|
||||
@@ -24,7 +24,7 @@ target 'procure' do
|
||||
)
|
||||
|
||||
post_install do |installer|
|
||||
# https://github.com/facebook/react-native/blob/main/packages/react-native/scripts/react_native_pods.rb#L197-L202
|
||||
# http://github.com/facebook/react-native/blob/main/packages/react-native/scripts/react_native_pods.rb#L197-L202
|
||||
react_native_post_install(
|
||||
installer,
|
||||
config[:reactNativePath],
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string/>
|
||||
<string>We need your location to securely bind this device for tender bidding verification.</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
module.exports = {
|
||||
preset: 'react-native',
|
||||
// The RN preset only transpiles react-native packages; these ESM-only deps
|
||||
// (pulled in by Redux Toolkit) must be transformed too or jest can't parse them.
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(jest-)?react-native|@react-native(-community)?|@react-navigation|@reduxjs/toolkit|immer|redux|redux-persist)/',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const path = require("path");
|
||||
const { getDefaultConfig, mergeConfig } = require("@react-native/metro-config");
|
||||
|
||||
const defaultConfig = getDefaultConfig(__dirname);
|
||||
@@ -5,7 +6,7 @@ const { assetExts, sourceExts } = defaultConfig.resolver;
|
||||
|
||||
/**
|
||||
* Metro configuration
|
||||
* https://reactnative.dev/docs/metro
|
||||
* http://reactnative.dev/docs/metro
|
||||
*
|
||||
* @type {import('metro-config').MetroConfig}
|
||||
*/
|
||||
@@ -17,7 +18,14 @@ const config = {
|
||||
},
|
||||
resolver: {
|
||||
assetExts: assetExts.filter((ext) => ext !== "svg"),
|
||||
sourceExts: [...sourceExts, "svg"]
|
||||
sourceExts: [...sourceExts, "svg"],
|
||||
blockList: [
|
||||
// Exclude native Android build artifacts that are created/deleted
|
||||
// during Gradle builds, which crash Metro's file watcher (ENOENT)
|
||||
/node_modules\/.*\/android\/\.cxx\/.*/,
|
||||
/node_modules\/.*\/android\/build\/.*/,
|
||||
/node_modules\/.*\/android\/\.gradle\/.*/,
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -7,80 +7,93 @@
|
||||
"ios": "react-native run-ios",
|
||||
"lint": "eslint .",
|
||||
"start": "react-native start",
|
||||
"test": "jest"
|
||||
"test": "jest",
|
||||
"create:users": "node scripts/create-users.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-native-community/blur": "^4.4.1",
|
||||
"@react-native-documents/picker": "^11.0.3",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native-vector-icons/material-icons": "^12.4.0",
|
||||
"@react-navigation/bottom-tabs": "^7.9.0",
|
||||
"@react-navigation/native": "^7.1.26",
|
||||
"@react-navigation/stack": "^7.6.13",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@stomp/stompjs": "^7.2.1",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"axios": "^1.13.2",
|
||||
"buffer": "^6.0.3",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-native": "^5.0.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"formik": "^2.4.9",
|
||||
"lottie-react-native": "^7.3.4",
|
||||
"lucide-react-native": "^0.562.0",
|
||||
"moment": "^2.30.1",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/blur": "4.4.1",
|
||||
"@react-native-documents/picker": "11.0.3",
|
||||
"@react-native-masked-view/masked-view": "0.2.9",
|
||||
"@react-native-vector-icons/material-icons": "12.4.0",
|
||||
"@react-navigation/bottom-tabs": "7.9.0",
|
||||
"@react-navigation/drawer": "^7.8.1",
|
||||
"@react-navigation/native": "^7.1.28",
|
||||
"@react-navigation/native-stack": "^7.17.3",
|
||||
"@react-navigation/stack": "7.6.13",
|
||||
"@reduxjs/toolkit": "2.11.2",
|
||||
"@shopify/flash-list": "^1.7.6",
|
||||
"@stomp/stompjs": "7.2.1",
|
||||
"@tanstack/react-query": "5.90.12",
|
||||
"axios": "1.13.2",
|
||||
"buffer": "6.0.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-native": "5.0.0",
|
||||
"exceljs": "^3.4.0",
|
||||
"formik": "2.4.9",
|
||||
"hyperformula": "^2.7.1",
|
||||
"jsencrypt": "^3.5.4",
|
||||
"lottie-react-native": "7.3.4",
|
||||
"lucide-react-native": "0.562.0",
|
||||
"moment": "2.30.1",
|
||||
"react": "19.0.0",
|
||||
"react-native": "0.79.0",
|
||||
"react-native-alert-notification": "^0.4.2",
|
||||
"react-native-app-intro-slider": "^4.0.4",
|
||||
"react-native-blob-util": "^0.24.6",
|
||||
"react-native-bouncy-checkbox": "^4.1.2",
|
||||
"react-native-date-picker": "^5.0.13",
|
||||
"react-native-element-dropdown": "^2.12.4",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-app-intro-slider": "4.0.4",
|
||||
"react-native-blob-util": "0.24.6",
|
||||
"react-native-bouncy-checkbox": "4.1.2",
|
||||
"react-native-date-picker": "5.0.13",
|
||||
"react-native-device-info": "^15.0.2",
|
||||
"react-native-element-dropdown": "2.12.4",
|
||||
"react-native-fs": "2.20.0",
|
||||
"react-native-geolocation-service": "^5.3.1",
|
||||
"react-native-gesture-handler": "^2.30.0",
|
||||
"react-native-gifted-charts": "^1.4.70",
|
||||
"react-native-haptic-feedback": "^2.3.3",
|
||||
"react-native-get-random-values": "1.11.0",
|
||||
"react-native-gifted-charts": "1.4.70",
|
||||
"react-native-haptic-feedback": "2.3.3",
|
||||
"react-native-keychain": "^10.0.0",
|
||||
"react-native-linear-gradient": "^2.8.3",
|
||||
"react-native-modal": "^14.0.0-rc.1",
|
||||
"react-native-permissions": "^5.4.4",
|
||||
"react-native-permissions": "5.4.4",
|
||||
"react-native-reanimated": "^3.18.0",
|
||||
"react-native-responsive-screen": "^1.4.2",
|
||||
"react-native-safe-area-context": "^5.6.2",
|
||||
"react-native-screens": "^4.12.0",
|
||||
"react-native-skeleton-placeholder": "^5.2.4",
|
||||
"react-native-svg": "^15.12.0",
|
||||
"react-native-svg-transformer": "^1.5.1",
|
||||
"react-native-webview": "^13.16.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"redux-persist": "^6.0.0",
|
||||
"sockjs-client": "^1.6.1",
|
||||
"text-encoding": "^0.7.0",
|
||||
"react-native-responsive-screen": "1.4.2",
|
||||
"react-native-safe-area-context": "5.6.2",
|
||||
"react-native-screens": "4.12.0",
|
||||
"react-native-skeleton-placeholder": "5.2.4",
|
||||
"react-native-svg": "15.12.0",
|
||||
"react-native-svg-transformer": "1.5.1",
|
||||
"react-native-webview": "13.16.0",
|
||||
"react-redux": "9.2.0",
|
||||
"redux-persist": "6.0.0",
|
||||
"sockjs-client": "1.6.1",
|
||||
"text-encoding": "0.7.0",
|
||||
"uuid": "13.0.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"yup": "^1.7.1"
|
||||
"yup": "1.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-env": "^7.25.3",
|
||||
"@babel/runtime": "^7.25.0",
|
||||
"@react-native-community/cli": "^18.0.1",
|
||||
"@react-native-community/cli-platform-android": "18.0.0",
|
||||
"@babel/core": "7.25.2",
|
||||
"@babel/preset-env": "7.25.3",
|
||||
"@babel/runtime": "7.25.0",
|
||||
"@react-native-community/cli": "18.0.1",
|
||||
"@react-native-community/cli-platform-android": "^18.0.0",
|
||||
"@react-native-community/cli-platform-ios": "18.0.0",
|
||||
"@react-native/babel-preset": "0.79.0",
|
||||
"@react-native/eslint-config": "0.79.0",
|
||||
"@react-native/metro-config": "0.79.0",
|
||||
"@react-native/typescript-config": "0.79.0",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-test-renderer": "^19.0.0",
|
||||
"@types/sockjs-client": "^1.5.4",
|
||||
"@types/text-encoding": "^0.0.40",
|
||||
"eslint": "^8.19.0",
|
||||
"jest": "^29.6.3",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/jest": "29.5.13",
|
||||
"@types/react": "19.0.0",
|
||||
"@types/react-test-renderer": "19.0.0",
|
||||
"@types/sockjs-client": "1.5.4",
|
||||
"@types/text-encoding": "0.0.40",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"eslint": "^8.57.0",
|
||||
"jest": "29.6.3",
|
||||
"prettier": "2.8.8",
|
||||
"react-native-bundle-visualizer": "^3.1.3",
|
||||
"react-native-bundle-visualizer": "3.1.3",
|
||||
"react-test-renderer": "19.0.0",
|
||||
"typescript": "5.0.4"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const axios = require('axios');
|
||||
const { BASE_URL } = require('../src/Services/ApiCall/ApiUrls');
|
||||
|
||||
const REGISTER_URL =
|
||||
`${BASE_URL}contractor/registerContractorStage1`;
|
||||
const DESIGNATION_URL =
|
||||
`${BASE_URL}contractor/getAllDesignationContractor`;
|
||||
const DEFAULT_PASSWORD = 'P@ssword123';
|
||||
|
||||
function getArgValue(name, fallback) {
|
||||
const prefix = `--${name}=`;
|
||||
const arg = process.argv.find(item => item.startsWith(prefix));
|
||||
if (!arg) return fallback;
|
||||
return arg.slice(prefix.length);
|
||||
}
|
||||
|
||||
function toPositiveInt(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function buildMobile(seed, index) {
|
||||
// Keep 10 digits and start with 9 to match app-side validation.
|
||||
const dynamicPart = (seed * 1000 + index) % 999999999;
|
||||
return String(9000000000 + dynamicPart).padStart(10, '9').slice(0, 10);
|
||||
}
|
||||
|
||||
function toAlphabeticSuffix(index) {
|
||||
let n = index;
|
||||
let label = '';
|
||||
while (n > 0) {
|
||||
const rem = (n - 1) % 26;
|
||||
label = String.fromCharCode(65 + rem) + label;
|
||||
n = Math.floor((n - 1) / 26);
|
||||
}
|
||||
return label || 'A';
|
||||
}
|
||||
|
||||
async function getDefaultDesignation() {
|
||||
try {
|
||||
const { data } = await axios.get(DESIGNATION_URL, { timeout: 15000 });
|
||||
const list = data?.data;
|
||||
if (Array.isArray(list) && list.length > 0) {
|
||||
const firstId = Number(list[0]?.designationid);
|
||||
if (Number.isFinite(firstId) && firstId > 0) return firstId;
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.message || error?.message || 'Unknown error';
|
||||
console.warn(`Failed to fetch designation list, using fallback id=1. ${message}`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
async function registerUser({
|
||||
designation,
|
||||
emailPrefix,
|
||||
firstNamePrefix,
|
||||
seed,
|
||||
index,
|
||||
password,
|
||||
}) {
|
||||
const serial = seed * 1000 + index;
|
||||
const emailAdd = `${emailPrefix}${serial}@example.com`;
|
||||
const alphaSuffix = toAlphabeticSuffix(index);
|
||||
const payload = {
|
||||
firstName: `${firstNamePrefix}${alphaSuffix}`,
|
||||
middleName: '',
|
||||
lastName: 'Auto',
|
||||
dialCode: '+91',
|
||||
mobNo: buildMobile(seed, index),
|
||||
emailAdd,
|
||||
contractorType: 'Indian',
|
||||
designation,
|
||||
password,
|
||||
otherDesignation: null,
|
||||
conPassword: password,
|
||||
device: 'mobileApp',
|
||||
};
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(REGISTER_URL, payload, { timeout: 20000 });
|
||||
const status = String(data?.status ?? data?.statusCode ?? '');
|
||||
const ok = status === '201' || status === '200' || status === 'SUCCESS';
|
||||
return {
|
||||
ok,
|
||||
email: emailAdd,
|
||||
mobile: payload.mobNo,
|
||||
message: data?.message || (ok ? 'Success' : 'Unexpected response'),
|
||||
rawStatus: status || 'unknown',
|
||||
details: data,
|
||||
};
|
||||
} catch (error) {
|
||||
const responseData = error?.response?.data;
|
||||
const apiMessage =
|
||||
responseData?.message ||
|
||||
responseData?.error ||
|
||||
error?.message ||
|
||||
'Request failed';
|
||||
return {
|
||||
ok: false,
|
||||
email: emailAdd,
|
||||
mobile: payload.mobNo,
|
||||
message: apiMessage,
|
||||
rawStatus: String(error?.response?.status || 'network_error'),
|
||||
details: responseData,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const count = toPositiveInt(getArgValue('count', '100'), 100);
|
||||
const concurrency = toPositiveInt(getArgValue('concurrency', '5'), 5);
|
||||
const startIndex = toPositiveInt(getArgValue('start', '1'), 1);
|
||||
const password = getArgValue('password', DEFAULT_PASSWORD);
|
||||
const emailPrefix = getArgValue('emailPrefix', 'bulkuser');
|
||||
const firstNamePrefix = getArgValue('firstNamePrefix', 'BulkUser');
|
||||
const seed = Number(String(Date.now()).slice(-6));
|
||||
const designation = await getDefaultDesignation();
|
||||
|
||||
console.log(
|
||||
`Creating ${count} users (start=${startIndex}, concurrency=${concurrency}, designation=${designation})...`,
|
||||
);
|
||||
|
||||
const indexes = Array.from({ length: count }, (_, i) => i + startIndex);
|
||||
let cursor = 0;
|
||||
const results = [];
|
||||
|
||||
async function worker() {
|
||||
while (true) {
|
||||
const current = cursor;
|
||||
cursor += 1;
|
||||
if (current >= indexes.length) break;
|
||||
const index = indexes[current];
|
||||
const result = await registerUser({
|
||||
designation,
|
||||
emailPrefix,
|
||||
firstNamePrefix,
|
||||
seed,
|
||||
index,
|
||||
password,
|
||||
});
|
||||
results.push(result);
|
||||
const marker = result.ok ? 'OK' : 'FAIL';
|
||||
console.log(`[${marker}] #${index} ${result.email} -> ${result.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
||||
|
||||
const success = results.filter(item => item.ok).length;
|
||||
const failed = results.length - success;
|
||||
|
||||
console.log('\nSummary');
|
||||
console.log(`Total: ${results.length}`);
|
||||
console.log(`Success: ${success}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('\nFailed entries (up to 20):');
|
||||
results
|
||||
.filter(item => !item.ok)
|
||||
.slice(0, 20)
|
||||
.forEach(item => {
|
||||
console.log(`- ${item.email} (${item.mobile}) [${item.rawStatus}] ${item.message}`);
|
||||
if (item.details) {
|
||||
console.log(` details: ${JSON.stringify(item.details)}`);
|
||||
}
|
||||
});
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('Bulk user creation crashed:', error?.message || error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -4,10 +4,13 @@ sonar.projectName=ProcureLinc
|
||||
sonar.projectVersion=1.0
|
||||
|
||||
# Source configuration
|
||||
sonar.sources=.
|
||||
sonar.sources=src
|
||||
sonar.exclusions=**/node_modules/**,**/android/**,**/ios/**,**/__tests__/**,**/assets/**,**/lottie/**
|
||||
|
||||
# SonarQube server
|
||||
sonar.host.url=http://localhost:9000
|
||||
|
||||
# Authentication token
|
||||
sonar.login=sqp_da89e447c964b6e91e142777097e25fa2f17a1cc
|
||||
# Authentication: NEVER commit a token here.
|
||||
# Pass it via the environment instead: SONAR_TOKEN=... npx sonar-scanner
|
||||
# The previously committed token must be revoked in SonarQube
|
||||
# (Administration > Security > Users > Tokens).
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
/**
|
||||
* LEGACY COMPATIBILITY ADAPTER.
|
||||
*
|
||||
* `apiCall` preserves the historical contract — it never throws, returning a
|
||||
* `{ success:false, message, ...errorBody }` envelope on failure — because
|
||||
* dozens of call sites still branch on `res?.statusCode === 200`.
|
||||
*
|
||||
* All transport concerns (auth header, 401 handling, timeouts, tracing) now
|
||||
* live in `src/core/http/client`. New code must use `request()` from there
|
||||
* directly; migrate call sites off `apiCall` feature by feature, then delete
|
||||
* this file.
|
||||
*/
|
||||
import axios from 'axios';
|
||||
import { setToken } from '../Redux/Slices/Token';
|
||||
import { store } from '../Redux/Store';
|
||||
import { request } from '../core/http/client';
|
||||
import { isAppError } from '../core/http/AppError';
|
||||
import { logger } from '../core/observability/logger';
|
||||
import { attachDevHttpLogging } from '../core/observability/httpLogging';
|
||||
|
||||
// A few screens still issue raw axios.* calls; keep them traced in DEV.
|
||||
attachDevHttpLogging(axios);
|
||||
|
||||
export const apiCall = async <R, D = {}>(
|
||||
method: 'get' | 'post' | 'patch' | 'delete' | 'put',
|
||||
@@ -10,39 +27,31 @@ export const apiCall = async <R, D = {}>(
|
||||
contentType?: string
|
||||
): Promise<R> => {
|
||||
try {
|
||||
const token: any = store.getState().token.token;
|
||||
|
||||
const response = await axios({
|
||||
return await request<R>({
|
||||
method,
|
||||
url,
|
||||
data,
|
||||
params,
|
||||
headers: {
|
||||
Authentication: `Bearer ${token?.token}`,
|
||||
'Content-Type': contentType || 'application/json',
|
||||
},
|
||||
headers: { 'Content-Type': contentType || 'application/json' },
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const errorMessage = error?.response?.data?.error;
|
||||
// console.log(error?.response?.data.error, '---------------------------api error message');
|
||||
|
||||
if (error?.response?.statusCode === 401) {
|
||||
store.dispatch(setToken({}));
|
||||
} catch (error) {
|
||||
if (isAppError(error)) {
|
||||
if (error.kind === 'unauthorized') {
|
||||
// Session clearing happens via the auth/unauthorized event → store.
|
||||
logger.warn('auth', 'JWT expired or malformed — session cleared');
|
||||
return {
|
||||
success: false,
|
||||
message: 'JWT Error - Token expired or malformed',
|
||||
} as R;
|
||||
}
|
||||
const body =
|
||||
typeof error.data === 'object' && error.data !== null ? (error.data as object) : {};
|
||||
return {
|
||||
success: false,
|
||||
message: 'JWT Error - Token expired or malformed',
|
||||
message: (body as { message?: string }).message || 'No Response From Server',
|
||||
...body,
|
||||
} as R;
|
||||
}
|
||||
const data = {
|
||||
data: {
|
||||
success: false,
|
||||
message: error?.response?.data?.message || 'No Response From Server',
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return data as any
|
||||
return { success: false, message: 'No Response From Server' } as R;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Contract tests for the legacy adapter: until every call site migrates to
|
||||
* core/http, `apiCall` MUST keep the historical never-throws envelope —
|
||||
* screens branch on `res?.statusCode === 200` and on these exact messages.
|
||||
*/
|
||||
import { apiCall } from '../Axios';
|
||||
import { request } from '../../core/http/client';
|
||||
import { AppError } from '../../core/http/AppError';
|
||||
|
||||
jest.mock('../../core/http/client', () => ({
|
||||
request: jest.fn(),
|
||||
setTokenProvider: jest.fn(),
|
||||
httpClient: { interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } } },
|
||||
}));
|
||||
|
||||
const mockRequest = request as jest.MockedFunction<typeof request>;
|
||||
|
||||
describe('apiCall (legacy envelope adapter)', () => {
|
||||
beforeEach(() => mockRequest.mockReset());
|
||||
|
||||
it('returns the response body on success', async () => {
|
||||
mockRequest.mockResolvedValue({ statusCode: 200, data: [1, 2] });
|
||||
const res = await apiCall<any>('get', '/x');
|
||||
expect(res).toEqual({ statusCode: 200, data: [1, 2] });
|
||||
});
|
||||
|
||||
it('never throws: maps 401 to the historical JWT envelope', async () => {
|
||||
mockRequest.mockRejectedValue(new AppError('unauthorized', 'nope', 401));
|
||||
const res = await apiCall<any>('get', '/x');
|
||||
expect(res).toEqual({
|
||||
success: false,
|
||||
message: 'JWT Error - Token expired or malformed',
|
||||
});
|
||||
});
|
||||
|
||||
it('spreads the server error body and preserves its message', async () => {
|
||||
mockRequest.mockRejectedValue(
|
||||
new AppError('validation', 'Invalid bid', 400, {
|
||||
statusCode: 400,
|
||||
message: 'Invalid bid',
|
||||
field: 'bidAmount',
|
||||
}),
|
||||
);
|
||||
const res = await apiCall<any>('post', '/x', {});
|
||||
expect(res).toEqual({
|
||||
success: false,
|
||||
message: 'Invalid bid',
|
||||
statusCode: 400,
|
||||
field: 'bidAmount',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the historical network-failure message verbatim', async () => {
|
||||
mockRequest.mockRejectedValue(new AppError('network', 'No response from server.'));
|
||||
const res = await apiCall<any>('get', '/x');
|
||||
expect(res).toEqual({ success: false, message: 'No Response From Server' });
|
||||
});
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
import React, { FC, useRef, useEffect } from 'react';
|
||||
import { StyleSheet, Text, View, TouchableOpacity, Animated, Platform } from 'react-native';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import { BlurView } from '@react-native-community/blur';
|
||||
import colors from '../../Constant/Color';
|
||||
|
||||
interface CardProps {
|
||||
title: string;
|
||||
leftLabel: string;
|
||||
leftValue: React.ReactNode;
|
||||
rightLabel: string;
|
||||
rightValue: React.ReactNode;
|
||||
buttonText: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const Card: FC<CardProps> = ({
|
||||
title,
|
||||
leftLabel,
|
||||
leftValue,
|
||||
rightLabel,
|
||||
rightValue,
|
||||
buttonText,
|
||||
onPress,
|
||||
}) => {
|
||||
const scale = useRef(new Animated.Value(0.95)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.spring(scale, {
|
||||
toValue: 1,
|
||||
friction: 8,
|
||||
tension: 90,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.outer, { transform: [{ scale }] }]}>
|
||||
{/* Glass Base */}
|
||||
{Platform.OS === 'ios' && (
|
||||
<BlurView
|
||||
style={StyleSheet.absoluteFill}
|
||||
blurType="light"
|
||||
blurAmount={10}
|
||||
reducedTransparencyFallbackColor="#ffffff"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Card Body */}
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'rgba(255,255,255,0.85)',
|
||||
'rgba(255,255,255,0.65)',
|
||||
]}
|
||||
style={styles.card}
|
||||
>
|
||||
{/* Title */}
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
|
||||
{/* Stats */}
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statBox}>
|
||||
<Text style={styles.statValue}>{leftValue}</Text>
|
||||
<Text style={styles.statLabel}>{leftLabel}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.verticalDivider} />
|
||||
|
||||
<View style={styles.statBox}>
|
||||
<Text style={styles.statValue}>{rightValue}</Text>
|
||||
<Text style={styles.statLabel}>{rightLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* CTA */}
|
||||
<TouchableOpacity activeOpacity={0.85} onPress={onPress}>
|
||||
<LinearGradient
|
||||
colors={[colors.primary, colors.primaryLight]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.ctaButton}
|
||||
>
|
||||
<Text style={styles.ctaText}>{buttonText}</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
marginBottom: 20,
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 18,
|
||||
elevation: 8,
|
||||
},
|
||||
|
||||
card: {
|
||||
padding: 22,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.6)',
|
||||
},
|
||||
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#0f172a',
|
||||
marginBottom: 16,
|
||||
},
|
||||
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 18,
|
||||
},
|
||||
|
||||
statBox: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
statValue: {
|
||||
fontSize: 26,
|
||||
fontWeight: '900',
|
||||
color: '#020617',
|
||||
},
|
||||
|
||||
statLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.secondary,
|
||||
marginTop: 4,
|
||||
},
|
||||
|
||||
verticalDivider: {
|
||||
width: 1,
|
||||
height: 42,
|
||||
backgroundColor: 'rgba(15,23,42,0.15)',
|
||||
},
|
||||
|
||||
ctaButton: {
|
||||
height: 46,
|
||||
borderRadius: 14,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
ctaText: {
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { View, Text, Animated, StyleSheet } from 'react-native';
|
||||
import colors from '../../Constant/Color';
|
||||
|
||||
const Counter = ({ toValue = 1000, duration = 2000 }) => {
|
||||
const animatedValue = useRef(new Animated.Value(0)).current;
|
||||
const [displayValue, setDisplayValue] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(animatedValue, {
|
||||
toValue,
|
||||
duration,
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
|
||||
const listener = animatedValue.addListener(({ value }) => {
|
||||
setDisplayValue(Math.floor(value));
|
||||
});
|
||||
|
||||
return () => {
|
||||
animatedValue.removeListener(listener);
|
||||
};
|
||||
}, [toValue]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.counterText}>{displayValue}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Counter
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: '#f2f2f2',
|
||||
padding: 5,
|
||||
borderRadius: 5,
|
||||
},
|
||||
counterText: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: colors.primary,
|
||||
},
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View, StyleProp, ViewStyle, TextStyle } from 'react-native';
|
||||
import { Dropdown } from 'react-native-element-dropdown';
|
||||
import { Shield } from 'lucide-react-native';
|
||||
|
||||
const colors = {
|
||||
primary: '#F97316',
|
||||
primaryLight: '#FDBA74',
|
||||
primaryHighlight: '#f4f1efff',
|
||||
secondary: '#1B4B66',
|
||||
secondaryLight: '#639599',
|
||||
success: '#22C55E',
|
||||
error: '#EF4444',
|
||||
warning: '#F59E0B',
|
||||
info: '#3B82F6',
|
||||
golden: '#FFD700',
|
||||
white: '#FFFFFF',
|
||||
black: '#000000',
|
||||
darkText: '#2B2B2B',
|
||||
grayText: '#6B7280',
|
||||
lightText: '#9CA3AF',
|
||||
border: '#E5E7EB',
|
||||
lightGray: '#F9FAFB',
|
||||
disabled: '#D1D5DB',
|
||||
placeholder: '#9CA3AF',
|
||||
backdrop: 'rgba(0,0,0,0.5)',
|
||||
shadow: 'rgba(0,0,0,0.12)',
|
||||
Orange: '#F97316',
|
||||
OrangeLight: '#FDBA74',
|
||||
OrangeSoft: '#FFF3E8',
|
||||
darkGray: '#5f6368',
|
||||
lightestGray: '#f1f3f4',
|
||||
successLight: '#e6f4ea',
|
||||
};
|
||||
|
||||
type DropdownItem = {
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type MyDropdownProps = {
|
||||
label?: string;
|
||||
data: DropdownItem[];
|
||||
placeholder?: string;
|
||||
labelField: string;
|
||||
valueField: string;
|
||||
value: any;
|
||||
onChange: (item: DropdownItem) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
placeholderStyle?: StyleProp<TextStyle>;
|
||||
selectedTextStyle?: StyleProp<TextStyle>;
|
||||
focusedField?: boolean;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
dropdownStyle?: StyleProp<ViewStyle>;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
const MyDropdown: React.FC<MyDropdownProps> = ({
|
||||
label,
|
||||
data,
|
||||
placeholder = 'Select an option',
|
||||
labelField,
|
||||
valueField,
|
||||
value,
|
||||
onChange,
|
||||
style,
|
||||
containerStyle,
|
||||
placeholderStyle,
|
||||
selectedTextStyle,
|
||||
focusedField,
|
||||
onFocus,
|
||||
onBlur,
|
||||
dropdownStyle,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<View style={[{ zIndex: 4000 }, containerStyle]}>
|
||||
{label && <Text style={styles.label}>{label}</Text>}
|
||||
<Dropdown
|
||||
data={data}
|
||||
labelField={labelField}
|
||||
valueField={valueField}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
style={[
|
||||
styles.dropdown,
|
||||
style,
|
||||
{ borderColor: focusedField ? colors.primary : '#e0e0e0' },
|
||||
]}
|
||||
containerStyle={[styles.dropdownContainer, dropdownStyle]}
|
||||
selectedTextStyle={[styles.selectedText, selectedTextStyle]}
|
||||
placeholderStyle={[styles.placeholderText, placeholderStyle]}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
search
|
||||
searchPlaceholder="Search Class"
|
||||
renderLeftIcon={() => (
|
||||
<Shield
|
||||
style={{ marginRight: 10 }}
|
||||
color={focusedField ? colors.primary : '#aaa'}
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyDropdown;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
label: {
|
||||
marginVertical: 10,
|
||||
color: colors.darkText,
|
||||
},
|
||||
dropdown: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 12,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.white,
|
||||
},
|
||||
dropdownContainer: {
|
||||
borderRadius: 12,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
selectedText: {
|
||||
color: colors.darkText,
|
||||
},
|
||||
placeholderText: {
|
||||
color: colors.placeholder,
|
||||
},
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, Image, StyleSheet, Dimensions } from 'react-native';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Menu } from 'lucide-react-native';
|
||||
import colors from '../../Constant/Color';
|
||||
import ImagePath from '../../Constant/ImagePath';
|
||||
import FontsFamily from '../../Constant/FontsFamily';
|
||||
import SideDrawer from '../SideDrawer/SideDrawer';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
showDrawer?: boolean;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ title, showDrawer = true }) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LinearGradient
|
||||
colors={[colors.white, 'rgba(255,255,255,0.8)']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={[styles.container, { paddingTop: insets.top + 10 }]}
|
||||
>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.left}>
|
||||
{showDrawer && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setDrawerVisible(true)}
|
||||
style={styles.drawerIcon}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Menu size={28} color={colors.black} strokeWidth={2.5} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
</View>
|
||||
|
||||
<Image
|
||||
source={ImagePath.Icon.Logo2}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
|
||||
{/* Drawer Modal */}
|
||||
<SideDrawer
|
||||
visible={drawerVisible}
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width,
|
||||
paddingBottom: 5,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
left: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
drawerIcon: {
|
||||
marginRight: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
color: colors.black,
|
||||
fontFamily: FontsFamily.poppinsSemiBold,
|
||||
},
|
||||
logo: {
|
||||
width: 55,
|
||||
height: 55,
|
||||
},
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import Header from "./Header/Header";
|
||||
import InsideHeader from "./Header/InsideHeader";
|
||||
import Loader from "./Loader/Loader";
|
||||
import Skelton from "./Skelton/Skelton";
|
||||
|
||||
export { Header , Skelton , Loader , InsideHeader}
|
||||
@@ -1,186 +0,0 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
|
||||
import LottieView from "lottie-react-native";
|
||||
import LinearGradient from "react-native-linear-gradient";
|
||||
|
||||
const InternetConnectivity = () => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.lottieContainer}>
|
||||
<LottieView
|
||||
source={require("../../lottie/NoInternet.json")}
|
||||
autoPlay
|
||||
loop
|
||||
style={styles.lottie}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>No Internet Connection</Text>
|
||||
|
||||
<Text style={styles.subtitle}>
|
||||
It looks like you're offline. Check your connection and try again to continue.
|
||||
</Text>
|
||||
|
||||
<View style={styles.hintContainer}>
|
||||
<View style={styles.hintRow}>
|
||||
<View style={styles.hintDot} />
|
||||
<Text style={styles.hintText}>Check your Wi-Fi or mobile data</Text>
|
||||
</View>
|
||||
<View style={styles.hintRow}>
|
||||
<View style={styles.hintDot} />
|
||||
<Text style={styles.hintText}>Try turning airplane mode on/off</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {}}
|
||||
style={styles.touchable}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={["#63adde", "#3089bd50"]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.retryBtn}
|
||||
>
|
||||
<Text style={styles.retryText}>Try Again</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default InternetConnectivity;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#F8FAFF",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 20,
|
||||
},
|
||||
card: {
|
||||
width: "100%",
|
||||
maxWidth: 400,
|
||||
backgroundColor: "#FFF",
|
||||
borderRadius: 24,
|
||||
paddingVertical: 32,
|
||||
paddingHorizontal: 24,
|
||||
alignItems: "center",
|
||||
shadowColor: "#667EEA",
|
||||
shadowOpacity: 0.1,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.8)",
|
||||
},
|
||||
lottieContainer: {
|
||||
width: 180,
|
||||
height: 180,
|
||||
marginBottom: 8,
|
||||
},
|
||||
lottie: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
content: {
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
},
|
||||
title: {
|
||||
fontSize: 26,
|
||||
fontWeight: "800",
|
||||
color: "#1A1A1A",
|
||||
marginTop: 8,
|
||||
letterSpacing: -0.5,
|
||||
textAlign: "center",
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: "#666",
|
||||
textAlign: "center",
|
||||
marginTop: 12,
|
||||
marginBottom: 28,
|
||||
lineHeight: 24,
|
||||
paddingHorizontal: 10,
|
||||
fontWeight: "400",
|
||||
},
|
||||
hintContainer: {
|
||||
width: "100%",
|
||||
backgroundColor: "#F8F9FF",
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 32,
|
||||
borderWidth: 1,
|
||||
borderColor: "#EDF2FE",
|
||||
},
|
||||
hintRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
},
|
||||
hintDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: "#667EEA",
|
||||
marginRight: 12,
|
||||
},
|
||||
hintText: {
|
||||
fontSize: 14,
|
||||
color: "#555",
|
||||
fontWeight: "400",
|
||||
flex: 1,
|
||||
},
|
||||
buttonContainer: {
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
},
|
||||
touchable: {
|
||||
width: "100%",
|
||||
shadowColor: "#667EEA",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
retryBtn: {
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 30,
|
||||
borderRadius: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
},
|
||||
retryText: {
|
||||
color: "#FFF",
|
||||
fontSize: 17,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
secondaryButton: {
|
||||
marginTop: 16,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 30,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1.5,
|
||||
borderColor: "#E2E8F0",
|
||||
backgroundColor: "#FFF",
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
},
|
||||
secondaryText: {
|
||||
color: "#64748B",
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
});
|
||||
@@ -1,380 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Dimensions, ScrollView, StatusBar } from 'react-native';
|
||||
import LottieView from 'lottie-react-native';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import { AlertCircle, Clock, RefreshCw, Activity } from 'lucide-react-native';
|
||||
import colors from '../../Constant/Color';
|
||||
import FontsFamily from '../../Constant/FontsFamily';
|
||||
|
||||
|
||||
const ServerDown = () => {
|
||||
return (
|
||||
<>
|
||||
<StatusBar barStyle="dark-content" />
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContainer}
|
||||
showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.card}>
|
||||
{/* Status Indicator */}
|
||||
<View style={styles.statusIndicator}>
|
||||
<View style={styles.statusPulse} />
|
||||
<View style={styles.statusDot}>
|
||||
<AlertCircle size={24} color="#FFF" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Animated Illustration */}
|
||||
<View style={styles.animationContainer}>
|
||||
<LottieView
|
||||
source={require('../../lottie/MaintenanceServer.json')}
|
||||
autoPlay
|
||||
loop
|
||||
style={styles.animation}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Main Content */}
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>Server Unavailable</Text>
|
||||
|
||||
<View style={styles.statusMessage}>
|
||||
<Clock size={18} color="#FF6B6B" style={styles.statusIcon} />
|
||||
<Text style={styles.statusText}>
|
||||
Temporarily down for maintenance
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.description}>
|
||||
Our servers are currently undergoing maintenance. This usually
|
||||
takes 15-30 minutes. We appreciate your patience while we work
|
||||
to improve your experience.
|
||||
</Text>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={styles.buttonGroup}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={() => { }}
|
||||
style={styles.touchable}>
|
||||
<LinearGradient
|
||||
colors={[colors.primary, colors.primaryLight]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.primaryButton}>
|
||||
<RefreshCw
|
||||
size={20}
|
||||
color="#FFF"
|
||||
style={styles.buttonIcon}
|
||||
/>
|
||||
<Text style={styles.primaryButtonText}>Refresh Status</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Live Status */}
|
||||
<View style={styles.liveStatus}>
|
||||
<View style={styles.liveIndicator}>
|
||||
<View style={styles.livePulse} />
|
||||
<View style={styles.liveDot} />
|
||||
</View>
|
||||
<Activity size={14} color="#10B981" style={styles.liveIcon} />
|
||||
<Text style={styles.liveText}>Monitoring server status...</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Background Pattern */}
|
||||
<View style={styles.backgroundPattern} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerDown;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollContainer: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8FAFF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
minHeight: Dimensions.get('window').height,
|
||||
},
|
||||
card: {
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
backgroundColor: '#FFF',
|
||||
borderRadius: 28,
|
||||
paddingVertical: 36,
|
||||
paddingHorizontal: 28,
|
||||
alignItems: 'center',
|
||||
shadowColor: colors.primary,
|
||||
shadowOpacity: 0.08,
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
shadowRadius: 24,
|
||||
elevation: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.9)',
|
||||
zIndex: 2,
|
||||
},
|
||||
statusIndicator: {
|
||||
position: 'absolute',
|
||||
top: -20,
|
||||
width: 60,
|
||||
height: 60,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
statusPulse: {
|
||||
position: 'absolute',
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: colors.primary,
|
||||
opacity: 0.2,
|
||||
},
|
||||
statusDot: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: colors.primary,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 6,
|
||||
},
|
||||
animationContainer: {
|
||||
width: 200,
|
||||
height: 200,
|
||||
marginBottom: 8,
|
||||
},
|
||||
animation: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
marginLeft: -20,
|
||||
},
|
||||
content: {
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: '#1A1A1A',
|
||||
marginTop: 8,
|
||||
letterSpacing: -0.5,
|
||||
textAlign: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
statusMessage: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFF5F5',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 12,
|
||||
marginBottom: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: '#FED7D7',
|
||||
},
|
||||
statusIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 14,
|
||||
color: '#C53030',
|
||||
fontWeight: '600',
|
||||
},
|
||||
description: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
marginBottom: 28,
|
||||
paddingHorizontal: 8,
|
||||
fontFamily: FontsFamily.poppinsMedium,
|
||||
},
|
||||
progressContainer: {
|
||||
width: '100%',
|
||||
marginBottom: 32,
|
||||
},
|
||||
progressBar: {
|
||||
width: '100%',
|
||||
height: 8,
|
||||
backgroundColor: '#E2E8F0',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 12,
|
||||
},
|
||||
progressFill: {
|
||||
width: '60%',
|
||||
height: '100%',
|
||||
backgroundColor: '#8B5CF6',
|
||||
borderRadius: 4,
|
||||
},
|
||||
progressText: {
|
||||
fontSize: 13,
|
||||
color: '#64748B',
|
||||
textAlign: 'center',
|
||||
fontWeight: '500',
|
||||
},
|
||||
infoContainer: {
|
||||
width: '100%',
|
||||
backgroundColor: '#F8FAFF',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 32,
|
||||
borderWidth: 1,
|
||||
borderColor: '#EDF2FE',
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
paddingBottom: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#EDF2F7',
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 14,
|
||||
color: '#64748B',
|
||||
fontWeight: '500',
|
||||
marginLeft: 12,
|
||||
marginRight: 8,
|
||||
width: 90,
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 14,
|
||||
color: '#1A1A1A',
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#F3E8FF',
|
||||
},
|
||||
statusMaintenance: {
|
||||
backgroundColor: '#F3E8FF',
|
||||
borderWidth: 1,
|
||||
borderColor: '#D6BCFA',
|
||||
},
|
||||
statusBadgeText: {
|
||||
fontSize: 12,
|
||||
color: '#8B5CF6',
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
buttonGroup: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
touchable: {
|
||||
width: '100%',
|
||||
shadowColor: '#FF6B6B',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 6,
|
||||
marginBottom: 16,
|
||||
},
|
||||
primaryButton: {
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 30,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
buttonIcon: {
|
||||
marginRight: 10,
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: '#FFF',
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
secondaryButtonGroup: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
gap: 12,
|
||||
},
|
||||
secondaryButton: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#FFF',
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#E2E8F0',
|
||||
gap: 8,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: '#64748B',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
liveStatus: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
liveIndicator: {
|
||||
position: 'relative',
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 8,
|
||||
},
|
||||
livePulse: {
|
||||
position: 'absolute',
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#10B981',
|
||||
opacity: 0.4,
|
||||
},
|
||||
liveDot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: '#10B981',
|
||||
},
|
||||
liveIcon: {
|
||||
marginRight: 6,
|
||||
},
|
||||
liveText: {
|
||||
fontSize: 13,
|
||||
color: '#64748B',
|
||||
fontWeight: '500',
|
||||
},
|
||||
backgroundPattern: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: '#F8FAFF',
|
||||
opacity: 0.3,
|
||||
zIndex: 1,
|
||||
},
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { StyleSheet, TextInput, View, TextInputProps, TouchableOpacity } from 'react-native';
|
||||
import React from 'react';
|
||||
|
||||
type MyInputProps = {
|
||||
icon?: string;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
onChangeText?: (text: string) => void;
|
||||
isVarify?: boolean;
|
||||
maxlength?: number;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
focused?: boolean;
|
||||
keyboardType?: TextInputProps['keyboardType'];
|
||||
editable?: boolean;
|
||||
autoCapitalize?: TextInputProps['autoCapitalize'];
|
||||
rightIcon?: string,
|
||||
rightIconPress?: () => void
|
||||
} & TextInputProps;
|
||||
|
||||
const MyInput: React.FC<MyInputProps> = ({
|
||||
icon,
|
||||
placeholder,
|
||||
value,
|
||||
onChangeText,
|
||||
isVarify,
|
||||
maxlength,
|
||||
onFocus,
|
||||
onBlur,
|
||||
focused,
|
||||
keyboardType,
|
||||
editable = true,
|
||||
autoCapitalize,
|
||||
rightIcon,
|
||||
rightIconPress,
|
||||
...textInputProps
|
||||
}) => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.inputWrapper,
|
||||
focused && styles.inputFocused,
|
||||
!editable && styles.inputDisabled,
|
||||
]}
|
||||
>
|
||||
{/* <Icon
|
||||
name={icon}
|
||||
size={18}
|
||||
color={focused ? "#0000" : '#aaa'}
|
||||
style={styles.icon}
|
||||
/> */}
|
||||
<TextInput
|
||||
{...textInputProps}
|
||||
placeholder={placeholder}
|
||||
style={[styles.input, !editable && { color: '#999' }]}
|
||||
placeholderTextColor="#aaa"
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
maxLength={maxlength}
|
||||
editable={editable}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
keyboardType={keyboardType}
|
||||
autoCapitalize={autoCapitalize}
|
||||
/>
|
||||
{
|
||||
rightIcon ?
|
||||
<TouchableOpacity onPress={rightIconPress}>
|
||||
</TouchableOpacity>
|
||||
|
||||
: null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyInput;
|
||||
const styles = StyleSheet.create({
|
||||
inputWrapper: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e0e0e0',
|
||||
paddingHorizontal: 10,
|
||||
marginVertical: 10,
|
||||
height: 55,
|
||||
|
||||
},
|
||||
icon: {
|
||||
marginRight: 10,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
color: '#000',
|
||||
paddingVertical: 0,
|
||||
},
|
||||
inputFocused: {
|
||||
borderColor: '#007BFF',
|
||||
},
|
||||
inputDisabled: {
|
||||
backgroundColor: '#f2f2f2',
|
||||
},
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { StyleSheet, Text, TouchableOpacity, ViewStyle } from 'react-native';
|
||||
import React from 'react';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import colors from '../../Constant/Color';
|
||||
|
||||
|
||||
type PrimaryButtonProps = {
|
||||
title: string;
|
||||
onPress: () => void | any;
|
||||
style?: ViewStyle;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const PrimaryButton: React.FC<PrimaryButtonProps> = ({ title, onPress, style, disabled }) => {
|
||||
return (
|
||||
<LinearGradient
|
||||
colors={[
|
||||
colors.primary, // #F97316 (Orange)
|
||||
colors.primaryLight, // #FDBA74 (OrangeLight)
|
||||
colors.OrangeLight, // #FDBA74 (backup)
|
||||
colors.OrangeSoft // #FFF3E8 (super soft orange end)
|
||||
]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
locations={[0.0, 0.4, 0.7, 1.0]}
|
||||
style={[styles.primaryButton, style]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.touchable}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.82}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Text style={styles.buttonText}>{title}</Text>
|
||||
</TouchableOpacity>
|
||||
</LinearGradient>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrimaryButton;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
primaryButton: {
|
||||
borderRadius: 14,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 28,
|
||||
marginBottom: 20,
|
||||
minHeight: 54,
|
||||
|
||||
// Orange-themed shadows
|
||||
shadowColor: colors.primary,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 10,
|
||||
|
||||
overflow: 'hidden',
|
||||
},
|
||||
touchable: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
textAlign: 'center',
|
||||
color: colors.white,
|
||||
fontWeight: '800', // Extra bold for orange contrast
|
||||
fontSize: 16,
|
||||
letterSpacing: 0.3,
|
||||
textShadowColor: 'rgba(0,0,0,0.2)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
includeFontPadding: false,
|
||||
},
|
||||
});
|
||||
@@ -1,257 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Dimensions, Image } from 'react-native';
|
||||
import Modal from 'react-native-modal';
|
||||
import Animated, { useSharedValue, useAnimatedStyle, withTiming, withDelay } from 'react-native-reanimated';
|
||||
import { Home, Gavel, FileText, User, LogOut, ChevronRight } from 'lucide-react-native';
|
||||
import { CommonActions, useNavigation } from '@react-navigation/native';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { removeToken } from '../../Redux/Slices/Token';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import colors from '../../Constant/Color';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const DRAWER_WIDTH = width * 0.78;
|
||||
|
||||
interface Props { visible: boolean; onClose: () => void; }
|
||||
|
||||
const SideDrawer: React.FC<Props> = ({ visible, onClose }) => {
|
||||
const navigation: any = useNavigation();
|
||||
const dispatch = useDispatch();
|
||||
const userData = useSelector((state: any) => state.token?.token);
|
||||
|
||||
const user = { name: userData?.fullName, role: userData?.role, };
|
||||
const translateX = useSharedValue(-DRAWER_WIDTH);
|
||||
|
||||
useEffect(() => {
|
||||
translateX.value = visible
|
||||
? withTiming(0, { duration: 0 })
|
||||
: withTiming(-DRAWER_WIDTH, { duration: 250 });
|
||||
}, [visible]);
|
||||
|
||||
const drawerStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: translateX.value }],
|
||||
}));
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: 'Profile',
|
||||
icon: User,
|
||||
onPress: () => navigation.navigate(NavigationString.Profile),
|
||||
},
|
||||
{
|
||||
label: 'Home',
|
||||
icon: Home,
|
||||
onPress: () => navigation.navigate(NavigationString.Home),
|
||||
},
|
||||
{
|
||||
label: 'Auctions',
|
||||
icon: Gavel,
|
||||
onPress: () => navigation.navigate(NavigationString.Auction),
|
||||
},
|
||||
{
|
||||
label: 'Tenders',
|
||||
icon: FileText,
|
||||
onPress: () => navigation.navigate(NavigationString.Tender),
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isVisible={visible}
|
||||
onBackdropPress={onClose}
|
||||
onBackButtonPress={onClose}
|
||||
backdropOpacity={0.45}
|
||||
animationIn="fadeIn"
|
||||
animationOut="fadeOut"
|
||||
style={styles.modal}
|
||||
useNativeDriver
|
||||
coverScreen={false}
|
||||
propagateSwipe
|
||||
>
|
||||
<Animated.View style={[styles.drawer, drawerStyle]}>
|
||||
{/* ================= Header / Profile ================= */}
|
||||
<View style={styles.profileHeader}>
|
||||
{/* <TouchableOpacity style={styles.closeBtn} onPress={() => {
|
||||
console.log('sdfadsfdsf');
|
||||
}}>
|
||||
<X size={20} color="#111827" />
|
||||
</TouchableOpacity> */}
|
||||
|
||||
<View style={styles.avatar}>
|
||||
<User size={40} color={colors.black} strokeWidth={2} />
|
||||
</View>
|
||||
|
||||
<Text style={styles.userName}>{user.name}</Text>
|
||||
<Text style={styles.userRole}>{user.role}</Text>
|
||||
</View>
|
||||
|
||||
{/* ================= Menu ================= */}
|
||||
<View style={styles.menuContainer}>
|
||||
{menuItems.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
const itemStyle = useAnimatedStyle(() => ({
|
||||
opacity: withDelay(
|
||||
index * 80,
|
||||
withTiming(1, { duration: 300 }),
|
||||
),
|
||||
transform: [
|
||||
{
|
||||
translateX: withDelay(
|
||||
index * 80,
|
||||
withTiming(0, { duration: 300 }),
|
||||
),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View key={item.label} style={itemStyle}>
|
||||
<TouchableOpacity
|
||||
style={styles.menuItem}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {
|
||||
onClose();
|
||||
item.onPress();
|
||||
}}
|
||||
>
|
||||
<View style={styles.menuLeft}>
|
||||
<Icon size={20} color="#1F2937" />
|
||||
<Text style={styles.menuText}>{item.label}</Text>
|
||||
</View>
|
||||
<ChevronRight size={18} color="#9CA3AF" />
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* ================= Logout ================= */}
|
||||
<View style={styles.logoutContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.logoutBtn}
|
||||
onPress={() => {
|
||||
dispatch(removeToken());
|
||||
navigation.dispatch(
|
||||
CommonActions.reset({
|
||||
index: 0,
|
||||
routes: [{ name: NavigationString.Login }],
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<LogOut size={20} color="#EF4444" />
|
||||
<Text style={styles.logoutText}>Logout</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SideDrawer;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modal: {
|
||||
margin: 0,
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
|
||||
drawer: {
|
||||
width: DRAWER_WIDTH,
|
||||
height: height - 80,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopRightRadius: 24,
|
||||
borderBottomRightRadius: 24,
|
||||
elevation: 12,
|
||||
},
|
||||
|
||||
/* -------- Profile -------- */
|
||||
profileHeader: {
|
||||
backgroundColor: '#F9FAFB',
|
||||
paddingTop: 50,
|
||||
paddingBottom: 24,
|
||||
alignItems: 'center',
|
||||
borderTopRightRadius: 24,
|
||||
},
|
||||
|
||||
closeBtn: {
|
||||
position: 'absolute',
|
||||
top: 20,
|
||||
right: 16,
|
||||
padding: 10,
|
||||
zIndex: 20
|
||||
},
|
||||
|
||||
avatar: {
|
||||
justifyContent:'center',
|
||||
alignItems:'center',
|
||||
backgroundColor:colors.primary,
|
||||
padding:25,
|
||||
borderRadius:60,
|
||||
marginBottom: 10,
|
||||
},
|
||||
|
||||
userName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
},
|
||||
|
||||
userRole: {
|
||||
fontSize: 13,
|
||||
color: '#6B7280',
|
||||
marginTop: 2,
|
||||
},
|
||||
|
||||
/* -------- Menu -------- */
|
||||
menuContainer: {
|
||||
paddingHorizontal: 14,
|
||||
paddingTop: 20,
|
||||
},
|
||||
|
||||
menuItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 14,
|
||||
marginBottom: 6,
|
||||
},
|
||||
|
||||
menuLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
|
||||
menuText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1F2937',
|
||||
},
|
||||
|
||||
/* -------- Logout -------- */
|
||||
logoutContainer: {
|
||||
marginTop: 'auto',
|
||||
paddingHorizontal: 14,
|
||||
paddingBottom: 24,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E5E7EB',
|
||||
},
|
||||
|
||||
logoutBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
|
||||
logoutText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#EF4444',
|
||||
},
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { View, Dimensions } from 'react-native';
|
||||
import React from 'react';
|
||||
import SkeletonPlaceholder from 'react-native-skeleton-placeholder';
|
||||
|
||||
const Skeleton = () => {
|
||||
const { width } = Dimensions.get('screen');
|
||||
const cardWidth = width - 30; // full width with some padding
|
||||
|
||||
return (
|
||||
<View style={{ padding: 10 }}>
|
||||
{[...Array(6)].map((_, index) => (
|
||||
<SkeletonPlaceholder key={index}>
|
||||
<SkeletonPlaceholder.Item
|
||||
width={cardWidth}
|
||||
marginBottom={20}
|
||||
borderRadius={12}
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Image */}
|
||||
<SkeletonPlaceholder.Item width={cardWidth} height={150} borderRadius={12} />
|
||||
|
||||
{/* Title */}
|
||||
<SkeletonPlaceholder.Item marginTop={10} width={cardWidth * 0.7} height={18} borderRadius={6} />
|
||||
|
||||
{/* Subtitle / Price */}
|
||||
<SkeletonPlaceholder.Item marginTop={6} width={cardWidth * 0.5} height={14} borderRadius={6} />
|
||||
</SkeletonPlaceholder.Item>
|
||||
</SkeletonPlaceholder>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Skeleton;
|
||||
@@ -1,19 +0,0 @@
|
||||
import { View, Dimensions } from 'react-native';
|
||||
import React from 'react';
|
||||
import SkeletonPlaceholder from 'react-native-skeleton-placeholder';
|
||||
|
||||
const SkeltonList = () => {
|
||||
const { width, height } = Dimensions.get('screen');
|
||||
|
||||
return (
|
||||
<View style={{ width: width - 30, height }}>
|
||||
{[...Array(10)].map((_, index) => (
|
||||
<SkeletonPlaceholder key={index} borderRadius={5}>
|
||||
<SkeletonPlaceholder.Item width={"100%"} height={50} marginBottom={12} />
|
||||
</SkeletonPlaceholder>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkeltonList;
|
||||
@@ -1,109 +0,0 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import colors from '../Constant/Color';
|
||||
|
||||
type StepProgressBarProps = {
|
||||
currentStep: number;
|
||||
steps: string[];
|
||||
onStepPress?: (stepIndex: number) => void;
|
||||
};
|
||||
|
||||
const StepProgressBar: React.FC<StepProgressBarProps> = ({
|
||||
currentStep,
|
||||
steps,
|
||||
onStepPress = () => {},
|
||||
}) => {
|
||||
const animations = useRef<Animated.Value[]>(
|
||||
steps.map(() => new Animated.Value(1))
|
||||
).current;
|
||||
|
||||
useEffect(() => {
|
||||
animations.forEach((anim, index) => {
|
||||
Animated.timing(anim, {
|
||||
toValue: index === currentStep ? 1.2 : 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
});
|
||||
}, [currentStep]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{steps.map((label, index) => {
|
||||
const isActive = index === currentStep;
|
||||
const isCompleted = index < currentStep;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
onPress={() => onStepPress(index)}
|
||||
style={styles.stepContainer}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.circle,
|
||||
isCompleted && styles.completedCircle,
|
||||
isActive && styles.activeCircle,
|
||||
{
|
||||
transform: [{ scale: animations[index] }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
isCompleted && { color: '#4caf50' },
|
||||
isActive && { color: '#2196f3' },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepProgressBar;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
stepContainer: {
|
||||
alignItems: 'center',
|
||||
width: 70,
|
||||
},
|
||||
circle: {
|
||||
width: 50,
|
||||
height: 5,
|
||||
borderRadius: 25,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
completedCircle: {
|
||||
backgroundColor: '#4caf50',
|
||||
},
|
||||
activeCircle: {
|
||||
backgroundColor: '#2196f3',
|
||||
},
|
||||
label: {
|
||||
// marginTop: 6,
|
||||
// fontSize: 12,
|
||||
textAlign: 'center',
|
||||
color: '#555',
|
||||
},
|
||||
});
|
||||
@@ -1,201 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { View, Text, Modal, ScrollView, TouchableOpacity, StyleSheet, Dimensions } from 'react-native';
|
||||
import { CheckCircle, X } from 'lucide-react-native'; // Updated icons
|
||||
import colors from '../../Constant/Color';
|
||||
type ModalProps = {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onChecked: () => void
|
||||
}
|
||||
|
||||
const TermsAndConditionsModal: React.FC<ModalProps> = ({ visible, onClose, onChecked }) => {
|
||||
return (
|
||||
<Modal
|
||||
animationType="slide"
|
||||
transparent={false}
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContainer}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Terms & Conditions</Text>
|
||||
<Text style={styles.subtitle}>Last updated: {new Date().toLocaleDateString()}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Section title="1. Acceptance of Terms">
|
||||
<Text style={styles.paragraph}>
|
||||
By accessing or using our services, you agree to be bound by these Terms and Conditions.
|
||||
If you do not agree with any part of these terms, you must not use our services.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section title="2. User Responsibilities">
|
||||
<Text style={styles.paragraph}>
|
||||
You are responsible for maintaining the confidentiality of your account information and
|
||||
for all activities that occur under your account. You agree to notify us immediately
|
||||
of any unauthorized use of your account.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section title="3. Intellectual Property">
|
||||
<Text style={styles.paragraph}>
|
||||
All content included on our platform, such as text, graphics, logos, and software, is
|
||||
the property of our company and protected by intellectual property laws. You may not
|
||||
reproduce, distribute, or create derivative works without our express permission.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section title="4. Limitation of Liability">
|
||||
<Text style={styles.paragraph}>
|
||||
Our company shall not be liable for any indirect, incidental, special, or consequential
|
||||
damages resulting from the use or inability to use our services, even if we have been
|
||||
advised of the possibility of such damages.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section title="5. Privacy Policy">
|
||||
<Text style={styles.paragraph}>
|
||||
Your use of our services is also governed by our Privacy Policy, which explains how we
|
||||
collect, use, and protect your personal information. Please review our Privacy Policy
|
||||
to understand our practices.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section title="6. Changes to Terms">
|
||||
<Text style={styles.paragraph}>
|
||||
We reserve the right to modify these terms at any time. We will notify you of any changes
|
||||
by posting the new Terms and Conditions on this page. Your continued use of the service
|
||||
after such modifications constitutes your acceptance of the new terms.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section title="7. Governing Law">
|
||||
<Text style={styles.paragraph}>
|
||||
These Terms shall be governed by and construed in accordance with the laws of the
|
||||
jurisdiction in which our company is registered, without regard to its conflict of law provisions.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Text style={styles.footerText}>
|
||||
If you have any questions about these Terms, please contact us at legal@company.com.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Accept Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.acceptButton}
|
||||
onPress={() => {
|
||||
onClose();
|
||||
onChecked();
|
||||
}}
|
||||
>
|
||||
<CheckCircle color={colors.white} size={28} />
|
||||
<Text style={styles.acceptButtonText}>I Accept</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// Reusable Section component
|
||||
type SectionProps = {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const Section: React.FC<SectionProps> = ({ title, children }) => (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.lightGray, // Updated with your colors
|
||||
},
|
||||
scrollContainer: {
|
||||
paddingBottom: 120, // Space for accept button
|
||||
flexGrow: 1,
|
||||
},
|
||||
header: {
|
||||
backgroundColor: colors.primary, // Orange header
|
||||
paddingVertical: 32,
|
||||
paddingHorizontal: 24,
|
||||
borderBottomLeftRadius: 24,
|
||||
borderBottomRightRadius: 24,
|
||||
marginBottom: 24,
|
||||
shadowColor: colors.shadow,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: colors.white,
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 28,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.darkText,
|
||||
marginBottom: 12,
|
||||
},
|
||||
paragraph: {
|
||||
fontSize: 16,
|
||||
lineHeight: 25,
|
||||
color: colors.grayText,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 15,
|
||||
fontStyle: 'italic',
|
||||
color: colors.lightText,
|
||||
marginTop: 24,
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
},
|
||||
acceptButton: {
|
||||
position: 'absolute',
|
||||
bottom: 40,
|
||||
left: 24,
|
||||
right: 24,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 20,
|
||||
shadowColor: colors.shadow,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 16,
|
||||
elevation: 12,
|
||||
},
|
||||
acceptButtonText: {
|
||||
color: colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginLeft: 12,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
export default TermsAndConditionsModal;
|
||||
@@ -1,10 +0,0 @@
|
||||
import colors from './Color';
|
||||
import AllUrls from './AllUrls';
|
||||
import FontsFamily from './FontsFamily';
|
||||
import ImagePath from './ImagePath';
|
||||
import NavigationString from './NavigationString';
|
||||
import { showErrorAlert, showSuccessAlert, showToast } from './ShowDailog';
|
||||
import Variable from './Variable';
|
||||
|
||||
export { AllUrls, colors, ImagePath, NavigationString, showErrorAlert, showSuccessAlert, showToast, Variable ,FontsFamily};
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import Variable from "./Variable"
|
||||
|
||||
// Customer
|
||||
const Login = `${Variable.Main_Base}/customer/login`
|
||||
const OtpVerify = `${Variable.Main_Base}/customer/verify_otp`
|
||||
const userUpdate = `${Variable.Main_Base}/customer`
|
||||
const userLogout = `${Variable.Main_Base}/customer/logout`
|
||||
const userInfo = `${Variable.Main_Base}/customer/info`
|
||||
|
||||
// Category
|
||||
const categoryApi = `${Variable.Main_Base}/category`
|
||||
|
||||
// Brand
|
||||
const brandsApi = `${Variable.Main_Base}/brand`
|
||||
|
||||
// Brand
|
||||
const bannersApi = `${Variable.Main_Base}/banner/position`
|
||||
|
||||
// Vehicle Segment
|
||||
const segmentApi = `${Variable.Main_Base}/vehicleSegmentType/without-page`
|
||||
const vehicleApi = `${Variable.Main_Base}/vehicle`
|
||||
|
||||
// Products
|
||||
const productApi = `${Variable.Main_Base}/product`
|
||||
const brandProductApi = `${Variable.Main_Base}/product/assigned`
|
||||
const ProductDetailsApi = `${Variable.Main_Base}/product`
|
||||
const ProductWishlistApi = `${Variable.Main_Base}/wish-list`
|
||||
const recentViewedProductApi = `${Variable.Main_Base}/view`
|
||||
const updateAddToCartApi = `${Variable.Main_Base}/add-to-cart`
|
||||
const brandCategoryApi = `${Variable.Main_Base}/category/brand`
|
||||
|
||||
// Rating
|
||||
const ratingApi = `${Variable.Main_Base}/rating`
|
||||
|
||||
// Coupon
|
||||
const couponApi = `${Variable.Main_Base}/coupon`
|
||||
|
||||
// Orders
|
||||
const orderApi = `${Variable.Main_Base}/order`
|
||||
|
||||
// Address
|
||||
const addressApi = `${Variable.Main_Base}/address`
|
||||
const addressStatusApi = `${Variable.Main_Base}/address/status`
|
||||
|
||||
// Recharge
|
||||
const rechrgeWalletApi = `${Variable.Main_Base}/wallets/create-order`
|
||||
const rechrgeWalletVerifyApi = `${Variable.Main_Base}/wallets/verify`
|
||||
const rechrgeWalletHistoryApi = `${Variable.Main_Base}/wallets`
|
||||
|
||||
// Order Payment
|
||||
const orderPaymentApi = `${Variable.Main_Base}/order/init`
|
||||
const orderConfirmApi = `${Variable.Main_Base}/order/callback`
|
||||
|
||||
// Charges
|
||||
const chargesApi = `${Variable.Main_Base}/charges`
|
||||
// FAQs
|
||||
const FAQsApi = `${Variable.Main_Base}/faq`
|
||||
|
||||
// offers
|
||||
const offersApi = `${Variable.Main_Base}/recharge`
|
||||
// PrivacyPolicy
|
||||
const privacyPolicyApi = `${Variable.Main_Base}/documents`
|
||||
|
||||
const AllUrls = {
|
||||
Login, OtpVerify, userUpdate, userLogout, categoryApi, brandsApi, bannersApi, segmentApi, productApi, brandProductApi, ProductDetailsApi, ratingApi,
|
||||
ProductWishlistApi, recentViewedProductApi, updateAddToCartApi, vehicleApi, brandCategoryApi, userInfo, couponApi, orderApi, addressApi, addressStatusApi,
|
||||
rechrgeWalletApi, orderPaymentApi, orderConfirmApi, rechrgeWalletVerifyApi, chargesApi, FAQsApi, rechrgeWalletHistoryApi, offersApi,privacyPolicyApi
|
||||
};
|
||||
export default AllUrls;
|
||||
@@ -1,7 +1,17 @@
|
||||
/**
|
||||
* @deprecated Legacy palette kept only for not-yet-migrated screens.
|
||||
* New code must use `useTheme()` (src/theme) — it is the single source of
|
||||
* visual truth and supports dark mode.
|
||||
*
|
||||
* The BRAND colors below are derived from the theme so the app has exactly
|
||||
* one brand orange (historically this file carried a second, different one).
|
||||
*/
|
||||
import { lightColors } from '../theme/colors';
|
||||
|
||||
const colors = {
|
||||
// Primary (Orange-based)
|
||||
primary: '#F97316', // Main orange (buttons, highlights)
|
||||
primaryLight: '#FDBA74', // Soft orange (chips, badges)
|
||||
// Primary — single-sourced from the design system
|
||||
primary: lightColors.primary,
|
||||
primaryLight: lightColors.primaryLight,
|
||||
primaryHighlight: '#f4f1efff', // Very light orange (backgrounds)
|
||||
|
||||
// Secondary (Warm contrast)
|
||||
@@ -25,16 +35,16 @@ const colors = {
|
||||
border: '#E5E7EB',
|
||||
lightGray: '#F9FAFB',
|
||||
disabled: '#D1D5DB',
|
||||
gray:'#b9babb',
|
||||
gray: '#b9babb',
|
||||
|
||||
// Additional
|
||||
placeholder: '#9CA3AF',
|
||||
backdrop: 'rgba(0,0,0,0.5)',
|
||||
shadow: 'rgba(0,0,0,0.12)',
|
||||
|
||||
// Aliases (optional)
|
||||
Orange: '#F97316',
|
||||
OrangeLight: '#FDBA74',
|
||||
// Aliases (single-sourced with primary above)
|
||||
Orange: lightColors.primary,
|
||||
OrangeLight: lightColors.primaryLight,
|
||||
OrangeSoft: '#FFF3E8',
|
||||
|
||||
darkGray: '#5f6368',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import colors from '../Color';
|
||||
|
||||
@@ -17,6 +18,7 @@ type ConfirmDialogProps = {
|
||||
cancelText?: string;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
loading:boolean
|
||||
};
|
||||
|
||||
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
@@ -27,6 +29,7 @@ const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
cancelText = 'Cancel',
|
||||
onConfirm,
|
||||
onClose,
|
||||
loading,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
@@ -59,7 +62,10 @@ const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.confirmText}>{confirmText}</Text>
|
||||
{ !loading ?<Text style={styles.confirmText}>{confirmText}</Text>:<ActivityIndicator
|
||||
color={colors.primary}
|
||||
size={30}
|
||||
/>}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
@@ -5,9 +5,12 @@ import Diamond from '../Images/diamond.svg'
|
||||
import Hill from '../Images/Svg/Hill.svg'
|
||||
const Logo = require('../Images/logo.png')
|
||||
const Logo2 = require('../Images/logo2.png')
|
||||
|
||||
|
||||
|
||||
const Icon = {
|
||||
Logo,
|
||||
Logo2
|
||||
Logo,
|
||||
Logo2
|
||||
}
|
||||
const ImagePath = {
|
||||
Icon,
|
||||
@@ -15,7 +18,13 @@ const ImagePath = {
|
||||
OnBoard2,
|
||||
OnBoard3,
|
||||
Diamond,
|
||||
Hill
|
||||
|
||||
Hill,
|
||||
|
||||
DarkBg: require('../Images/Backgrounds/darkBackground.png'),
|
||||
LightBg: require('../Images/Backgrounds/lightBackground.png'),
|
||||
DarkBg2: require('../Images/Backgrounds/darkBackground2.png'),
|
||||
LightBg2: require('../Images/Backgrounds/lightBackground2.png'),
|
||||
// Home screen backdrop (cream node-constellation art). Light theme only.
|
||||
HomeBg: require('../Images/Backgrounds/homeBackground.png'),
|
||||
}
|
||||
export default ImagePath;
|
||||
@@ -21,7 +21,11 @@ const NavigationString = {
|
||||
LiveTenderDetail:'LiveTenderDetail',
|
||||
AuctionDetail: 'AuctionDetail',
|
||||
Profile:'Profile',
|
||||
ViewTenderDetail:'ViewTenderDetail',
|
||||
ResetPassword:'ResetPassword'
|
||||
}
|
||||
export default NavigationString
|
||||
ResetPassword:'ResetPassword',
|
||||
KycWebViewScreen:'KycWebViewScreen',
|
||||
StageThreeScreen:'StageThree',
|
||||
DeviceBinding: 'DeviceBinding',
|
||||
DscBinding: 'DscBinding',
|
||||
EnvelopeCSpreadsheet: 'EnvelopeCSpreadsheet'
|
||||
} as const
|
||||
export default NavigationString
|
||||
|
||||
@@ -1,26 +1,13 @@
|
||||
import { ALERT_TYPE, Dialog, Root, Toast } from 'react-native-alert-notification';
|
||||
import { showAppToast } from '../components/AppToast';
|
||||
|
||||
const showSuccessAlert = (text: string) => {
|
||||
Toast.show({
|
||||
type: ALERT_TYPE.SUCCESS,
|
||||
title: 'Success',
|
||||
textBody: text,
|
||||
});
|
||||
showAppToast({ type: 'success', message: text });
|
||||
};
|
||||
|
||||
const showErrorAlert = (text: string) => {
|
||||
Toast.show({
|
||||
type: ALERT_TYPE.DANGER,
|
||||
title: 'Error',
|
||||
textBody: text,
|
||||
});
|
||||
showAppToast({ type: 'error', message: text });
|
||||
};
|
||||
|
||||
const showToast = (text: string) => {
|
||||
Toast.show({
|
||||
type: ALERT_TYPE.SUCCESS,
|
||||
title: 'Success',
|
||||
textBody: text,
|
||||
});
|
||||
}
|
||||
export { showErrorAlert, showSuccessAlert, showToast }
|
||||
const showToast = showSuccessAlert;
|
||||
|
||||
export { showErrorAlert, showSuccessAlert, showToast };
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
const webSocketUrl = 'https://procurelinc.in/Auction/liveBid/';
|
||||
const socketUrl = `https://procurelinc.in/Auction/api/`;
|
||||
const Variables = { webSocketUrl, socketUrl }
|
||||
export default Variables
|
||||
/**
|
||||
* @deprecated Compatibility re-export. Import { env } from 'src/core/config/env'
|
||||
* in new code; this file exists only until the socket/bidding consumers migrate.
|
||||
*/
|
||||
import { env } from '../core/config/env';
|
||||
|
||||
const Variables = {
|
||||
webSocketUrl: env.webSocketUrl,
|
||||
socketUrl: env.socketBaseUrl,
|
||||
};
|
||||
|
||||
export default Variables;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Platform, ToastAndroid } from "react-native";
|
||||
|
||||
export const showToast = (message: string) => {
|
||||
if (Platform.OS === 'android') {
|
||||
ToastAndroid.show(message, ToastAndroid.SHORT);
|
||||
} else {
|
||||
console.log(message); // Fallback for iOS or other platforms
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
export const generateTransactionId = () => {
|
||||
const now = new Date();
|
||||
|
||||
const dateTime =
|
||||
now.getFullYear().toString() +
|
||||
(now.getMonth() + 1).toString().padStart(2, '0') +
|
||||
now.getDate().toString().padStart(2, '0') +
|
||||
now.getHours().toString().padStart(2, '0') +
|
||||
now.getMinutes().toString().padStart(2, '0') +
|
||||
now.getSeconds().toString().padStart(2, '0');
|
||||
|
||||
const randomPart = Math.floor(1000 + Math.random() * 9000); // 4-digit random
|
||||
|
||||
return `TNX${dateTime}${randomPart}`;
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Platform } from 'react-native';
|
||||
import * as Network from 'expo-network';
|
||||
|
||||
export const getDeviceDetails = async () => {
|
||||
const ipAddress = await Network.getIpAddressAsync();
|
||||
|
||||
const detail = [
|
||||
{ name: 'IMEI', value: '356938035643809' },
|
||||
{ name: 'IP', value: ipAddress },
|
||||
{ name: 'OS', value: Platform.OS },
|
||||
{ name: 'APP', value: 'Globots' },
|
||||
];
|
||||
|
||||
return {detail};
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useColorScheme } from 'react-native';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { lightColors, darkColors, spacing, typography, shadows } from '../theme';
|
||||
import { selectThemeMode, setThemeMode } from '../Redux/Slices/ThemeSlice';
|
||||
|
||||
export const useTheme = () => {
|
||||
const systemScheme = useColorScheme();
|
||||
const themeMode = useSelector(selectThemeMode);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const isDarkMode =
|
||||
themeMode === 'system'
|
||||
? systemScheme === 'dark'
|
||||
: themeMode === 'dark';
|
||||
|
||||
const themeColors = isDarkMode ? darkColors : lightColors;
|
||||
|
||||
const toggleTheme = (mode: 'light' | 'dark' | 'system') => {
|
||||
dispatch(setThemeMode(mode));
|
||||
};
|
||||
|
||||
return {
|
||||
colors: themeColors,
|
||||
isDarkMode,
|
||||
themeMode,
|
||||
toggleTheme,
|
||||
spacing,
|
||||
typography,
|
||||
shadows,
|
||||
};
|
||||
};
|
||||
|
||||
|
After Width: | Height: | Size: 6.0 MiB |
|
After Width: | Height: | Size: 6.2 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 5.3 MiB |
|
After Width: | Height: | Size: 5.6 MiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectIsAuthenticated } from '../Redux/Slices/Token';
|
||||
import NavigationString from '../Constant/NavigationString';
|
||||
import { resetAndNavigate } from './NavigationService';
|
||||
import { logger } from '../core/observability/logger';
|
||||
|
||||
/**
|
||||
* Declarative auth gate: whenever an existing session ends — a 401 clearing
|
||||
* the token via the auth/unauthorized event, an explicit logout, or a
|
||||
* keychain wipe — reset navigation to Login.
|
||||
*
|
||||
* Historically a mid-session 401 cleared the token but left the user
|
||||
* stranded on an authenticated screen where every request failed silently.
|
||||
* Renders nothing; mount once inside the Redux provider.
|
||||
*/
|
||||
const AuthSessionGuard = () => {
|
||||
const isAuthenticated = useSelector(selectIsAuthenticated);
|
||||
const wasAuthenticated = useRef(isAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasAuthenticated.current && !isAuthenticated) {
|
||||
logger.info('auth', 'session ended — routing to Login');
|
||||
resetAndNavigate(NavigationString.Login);
|
||||
}
|
||||
wasAuthenticated.current = isAuthenticated;
|
||||
}, [isAuthenticated]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AuthSessionGuard;
|
||||
@@ -1,156 +1,103 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Text } from 'react-native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import * as LucideIcons from 'lucide-react-native';
|
||||
import Animated, { ZoomIn } from 'react-native-reanimated';
|
||||
import Screens from '../Screens';
|
||||
import colors from '../Constant/Color';
|
||||
import NavigationString from '../Constant/NavigationString';
|
||||
import EditableExcel from '../Screens/Tender/Component/EnvelopeCExcelPreview';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const TAB_BAR_HEIGHT = 60;
|
||||
const TAB_BUTTON_WIDTH = width / 4;
|
||||
import NavigationString from '../Constant/NavigationString';
|
||||
import { useTheme } from '../Hooks/useTheme';
|
||||
import { typography } from '../theme/typography';
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
|
||||
const TabButton = ({ route, isFocused, onPress }: any) => {
|
||||
const icons: Record<string, { outline: any; filled: any }> = {
|
||||
Home: { outline: "Home", filled: "Home" },
|
||||
Auction: { outline: "Gavel", filled: "Gavel" },
|
||||
Tender: { outline: "FileText", filled: "FileText" },
|
||||
Profile: { outline: "User", filled: "User" },
|
||||
};
|
||||
const IconName = LucideIcons[isFocused ? icons[route.name].filled : icons[route.name].outline];
|
||||
const scaleAnim = useRef(new Animated.Value(1)).current;
|
||||
const translateYAnim = useRef(new Animated.Value(0)).current;
|
||||
const opacityAnim = useRef(new Animated.Value(1)).current;
|
||||
const ICON_BY_ROUTE: Record<string, keyof typeof LucideIcons> = {
|
||||
Home: 'House',
|
||||
Auction: 'Gavel',
|
||||
Tender: 'FileText',
|
||||
Profile: 'User',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocused) {
|
||||
Animated.parallel([
|
||||
Animated.sequence([
|
||||
Animated.timing(translateYAnim, {
|
||||
toValue: -8,
|
||||
duration: 120,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(translateYAnim, {
|
||||
toValue: 0,
|
||||
friction: 3,
|
||||
tension: 80,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1.3,
|
||||
friction: 5,
|
||||
tension: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
friction: 5,
|
||||
tension: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 0.6,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [isFocused]);
|
||||
const TabButton = ({ route, isFocused, onPress }: any) => {
|
||||
const { colors } = useTheme();
|
||||
const IconName = (LucideIcons[ICON_BY_ROUTE[route.name] || 'HelpCircle'] as React.ElementType);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={[styles.tabButton, { width: TAB_BUTTON_WIDTH }]}
|
||||
activeOpacity={0.8}
|
||||
style={styles.tabButton}
|
||||
activeOpacity={0.85}
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected: isFocused }}
|
||||
accessibilityLabel={`${route.name} tab`}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.iconContainer,
|
||||
{
|
||||
transform: [{ scale: scaleAnim }, { translateY: translateYAnim }],
|
||||
opacity: opacityAnim,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{IconName && (
|
||||
<IconName
|
||||
size={20}
|
||||
color={isFocused ? colors.primary : '#888'}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
{
|
||||
color: isFocused ? colors.primary : '#888',
|
||||
fontWeight: isFocused ? '600' : '400',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{route.name === 'BrandDay' ? 'Coupons' : route.name}
|
||||
</Text>
|
||||
{isFocused ? (
|
||||
// Active: a solid brand pill that pops in with icon + label.
|
||||
<Animated.View
|
||||
entering={ZoomIn.duration(220)}
|
||||
style={[styles.activePill, { backgroundColor: colors.primary }]}
|
||||
>
|
||||
{IconName && <IconName size={20} color="#FFFFFF" strokeWidth={2.4} />}
|
||||
<Text
|
||||
style={[styles.activeLabel, { fontFamily: typography.fonts.medium }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{route.name}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
) : (
|
||||
// Inactive: muted icon only.
|
||||
IconName && (
|
||||
<IconName size={24} color={colors.textSecondary} strokeWidth={2} />
|
||||
)
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomTabBar = ({ state, descriptors, navigation }: any) => {
|
||||
return (
|
||||
<View style={styles.tabBar}>
|
||||
{state.routes.map((route: any, index: number) => {
|
||||
const isFocused = state.index === index;
|
||||
const CustomTabBar = ({ state, navigation }: any) => {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<TabButton
|
||||
key={route.key}
|
||||
route={route}
|
||||
isFocused={isFocused}
|
||||
onPress={() => navigation.navigate(route.name)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.tabBar,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
shadowColor: colors.shadow,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{state.routes.map((route: any, index: number) => (
|
||||
<TabButton
|
||||
key={route.key}
|
||||
route={route}
|
||||
isFocused={state.index === index}
|
||||
onPress={() => navigation.navigate(route.name)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default function BottomTab() {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<Tab.Navigator
|
||||
tabBar={(props) => <CustomTabBar {...props} />}
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarHideOnKeyboard: true,
|
||||
// Smooth cross-fade when switching tabs instead of an instant cut.
|
||||
animation: 'shift',
|
||||
}}
|
||||
>
|
||||
<Tab.Screen name="Home" component={Screens.Home} />
|
||||
<Tab.Screen name={NavigationString.Auction} component={Screens.Auction} />
|
||||
<Tab.Screen name={NavigationString.Tender} component={Screens.Tender} />
|
||||
{/* <Tab.Screen name={'EditableExcel'} component={EditableExcel} /> */}
|
||||
<Tab.Screen name={NavigationString.Profile} component={Screens.ProfileTab} />
|
||||
|
||||
<Tab.Screen name={NavigationString.Profile} component={Screens.ProfileTab} />
|
||||
</Tab.Navigator>
|
||||
</View>
|
||||
);
|
||||
@@ -159,29 +106,43 @@ export default function BottomTab() {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
tabBar: {
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
left: 20,
|
||||
right: 20,
|
||||
flexDirection: 'row',
|
||||
height: TAB_BAR_HEIGHT,
|
||||
backgroundColor: '#fff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#eee',
|
||||
elevation: 5,
|
||||
height: 66,
|
||||
borderRadius: 33,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around',
|
||||
paddingHorizontal: 8,
|
||||
// Soft floating shadow (iOS) + elevation (Android).
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 16,
|
||||
elevation: 12,
|
||||
zIndex: 9999,
|
||||
},
|
||||
tabButton: {
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
},
|
||||
iconContainer: {
|
||||
marginBottom: 4,
|
||||
justifyContent: 'center',
|
||||
activePill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 9,
|
||||
borderRadius: 22,
|
||||
},
|
||||
label: {
|
||||
fontSize: 11,
|
||||
textAlign: 'center',
|
||||
includeFontPadding: false,
|
||||
activeLabel: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 13,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { createStackNavigator } from '@react-navigation/stack';
|
||||
import NavigationString from '../Constant/NavigationString';
|
||||
import Screens from '../Screens';
|
||||
import colors from '../Constant/Color';
|
||||
import BottomTab from './Bottom';
|
||||
|
||||
export default function MainStack() {
|
||||
const Stack = createStackNavigator();
|
||||
const option = { headerShown: false };
|
||||
const fadeTransition = {
|
||||
gestureDirection: 'horizontal',
|
||||
transitionSpec: {
|
||||
open: { animation: 'timing', config: { duration: 300 } },
|
||||
close: { animation: 'timing', config: { duration: 300 } },
|
||||
},
|
||||
cardStyleInterpolator: ({ current }) => ({
|
||||
cardStyle: {
|
||||
opacity: current.progress,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
cardStyle: { backgroundColor: colors.white },
|
||||
...fadeTransition
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name={NavigationString.Splash} component={Screens.Splash} options={option} />
|
||||
<Stack.Screen name={NavigationString.Signup} component={Screens.Signup} options={option} />
|
||||
<Stack.Screen name={NavigationString.Onboarding} component={Screens.Onboarding} options={option} />
|
||||
<Stack.Screen name={NavigationString.Login} component={Screens.Login} options={option} />
|
||||
|
||||
<Stack.Screen name={NavigationString.Home} component={BottomTab} options={option} />
|
||||
<Stack.Screen name={NavigationString.PurchaseList} component={Screens.PurchaseList} options={option} />
|
||||
<Stack.Screen name={NavigationString.Emd} component={Screens.Emd} options={option} />
|
||||
<Stack.Screen name={NavigationString.StartBidding} component={Screens.StartBidding} options={option} />
|
||||
<Stack.Screen name={NavigationString.PurchaseAuctionList} component={Screens.PurchaseAuctionList} options={option} />
|
||||
<Stack.Screen name={NavigationString.PurchaseTenderList} component={Screens.PurchaseTenderList} options={option} />
|
||||
<Stack.Screen name={NavigationString.ProceedToPurTender} component={Screens.ProceedToPurTender} options={option} />
|
||||
<Stack.Screen name={NavigationString.TenderBidding} component={Screens.TenderBidding} options={option} />
|
||||
<Stack.Screen name={NavigationString.ProceedToBidDetail} component={Screens.ProceedToBidDetail} options={option} />
|
||||
<Stack.Screen name={NavigationString.StartReverseBidding} component={Screens.StartReverseBidding} options={option} />
|
||||
<Stack.Screen name={NavigationString.Registration} component={Screens.Registration} options={option} />
|
||||
<Stack.Screen name={NavigationString.Partnership} component={Screens.Partnership} options={option} />
|
||||
<Stack.Screen name={NavigationString.LiveTenderDetail} component={Screens.LiveTenderDetail} options={option} />
|
||||
<Stack.Screen name={NavigationString.ResetPassword} component={Screens.ResetPassword} options={option} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from 'react';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import NavigationString from '../Constant/NavigationString';
|
||||
import Screens from '../Screens';
|
||||
import colors from '../Constant/Color';
|
||||
import BottomTab from './Bottom';
|
||||
import Login from '../Screens/Login/Login';
|
||||
import { RootStackParamList } from './types';
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
export default function MainStack() {
|
||||
return (
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
// Native-thread driven transitions for smooth, jank-free navigation.
|
||||
animation: 'slide_from_right',
|
||||
animationDuration: 250,
|
||||
gestureEnabled: true,
|
||||
// Avoids a white/black flash between screens during the transition.
|
||||
contentStyle: { backgroundColor: colors.white },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name={NavigationString.Splash} component={Screens.Splash} options={{ animation: 'fade' }} />
|
||||
<Stack.Screen name={NavigationString.Signup} component={Screens.Signup} />
|
||||
<Stack.Screen name={NavigationString.Onboarding} component={Screens.Onboarding} />
|
||||
<Stack.Screen name={NavigationString.Login} component={Login} options={{ animation: 'fade' }} />
|
||||
<Stack.Screen name={NavigationString.Home} component={BottomTab} options={{ animation: 'fade', gestureEnabled: false }} />
|
||||
<Stack.Screen name={NavigationString.PurchaseList} component={Screens.PurchaseList} />
|
||||
<Stack.Screen name={NavigationString.Emd} component={Screens.Emd} />
|
||||
<Stack.Screen name={NavigationString.StartBidding} component={Screens.StartBidding} />
|
||||
<Stack.Screen name={NavigationString.PurchaseAuctionList} component={Screens.PurchaseAuctionList} />
|
||||
<Stack.Screen name={NavigationString.PurchaseTenderList} component={Screens.PurchaseTenderList} />
|
||||
<Stack.Screen name={NavigationString.ProceedToPurTender} component={Screens.ProceedToPurTender} />
|
||||
<Stack.Screen name={NavigationString.TenderBidding} component={Screens.TenderBidding} />
|
||||
<Stack.Screen name={NavigationString.ProceedToBidDetail} component={Screens.ProceedToBidDetail} />
|
||||
<Stack.Screen name={NavigationString.StartReverseBidding} component={Screens.StartReverseBidding} />
|
||||
<Stack.Screen name={NavigationString.Registration} component={Screens.Registration} />
|
||||
<Stack.Screen name={NavigationString.Partnership} component={Screens.Partnership} />
|
||||
<Stack.Screen name={NavigationString.LiveTenderDetail} component={Screens.LiveTenderDetail} />
|
||||
<Stack.Screen name={NavigationString.ResetPassword} component={Screens.ResetPassword} />
|
||||
<Stack.Screen name={NavigationString.KycWebViewScreen} component={Screens.KycWebViewScreen} />
|
||||
<Stack.Screen name={NavigationString.StageThreeScreen} component={Screens.StageThreeScreen} />
|
||||
<Stack.Screen name={NavigationString.DeviceBinding} component={Screens.DeviceBinding} />
|
||||
<Stack.Screen name={NavigationString.DscBinding} component={Screens.DscBinding} />
|
||||
<Stack.Screen name={NavigationString.EnvelopeCSpreadsheet} component={Screens.EnvelopeCSpreadsheet} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createNavigationContainerRef, CommonActions } from '@react-navigation/native';
|
||||
|
||||
export const navigationRef = createNavigationContainerRef<any>();
|
||||
|
||||
export function navigate(name: string, params?: any) {
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate(name, params);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetAndNavigate(name: string) {
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.dispatch(
|
||||
CommonActions.reset({
|
||||
index: 0,
|
||||
routes: [{ name }],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import MainStack from './MainStack';
|
||||
export default function Routes() {
|
||||
return <>{MainStack()} </>
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import MainStack from './MainStack';
|
||||
|
||||
export default function Routes() {
|
||||
// Render as an element (not a function call) so React treats MainStack as a
|
||||
// real component — hooks, devtools identity, and memoization all depend on it.
|
||||
return <MainStack />;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Typed route registry for the root stack. Param shapes reflect what each
|
||||
* screen actually reads today — tighten them as screens get typed models.
|
||||
*
|
||||
* Usage in screens:
|
||||
* useNavigation<NativeStackNavigationProp<RootStackParamList>>()
|
||||
* useRoute<RouteProp<RootStackParamList, 'TenderBidding'>>()
|
||||
*/
|
||||
export type RootStackParamList = {
|
||||
Splash: undefined;
|
||||
Signup: undefined;
|
||||
Onboarding: undefined;
|
||||
Login: undefined;
|
||||
Home: undefined;
|
||||
PurchaseList: undefined;
|
||||
Emd: undefined;
|
||||
// Both bidding routes render the pattern-driven AuctionBiddingScreen; the
|
||||
// auction itself comes from the redux `auction` slice, not params.
|
||||
StartBidding: undefined;
|
||||
StartReverseBidding: undefined;
|
||||
PurchaseAuctionList: undefined;
|
||||
PurchaseTenderList: undefined;
|
||||
ProceedToPurTender: undefined;
|
||||
TenderBidding: { tenderId: number | string };
|
||||
ProceedToBidDetail: { tender: { tenderId: number | string } & Record<string, unknown> };
|
||||
Registration: Record<string, unknown> | undefined;
|
||||
Partnership: { otherId: unknown; orgType: unknown };
|
||||
/** The whole tender object is currently spread into params. */
|
||||
LiveTenderDetail: { tenderId: number } & Record<string, unknown>;
|
||||
ResetPassword: undefined;
|
||||
/** Server-rendered KYC HTML delivered via params. */
|
||||
KycWebViewScreen: { res: { data: string } } & Record<string, unknown>;
|
||||
StageThree: { otherId: unknown; orgType: unknown };
|
||||
DeviceBinding: { message?: string; showBindButton?: boolean } | undefined;
|
||||
DscBinding: { message?: string } | undefined;
|
||||
EnvelopeCSpreadsheet: { tender: Record<string, unknown>; buttonEnable?: boolean };
|
||||
};
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* Centralized permission broker — screens must request permissions through
|
||||
* these helpers, never react-native-permissions directly.
|
||||
*/
|
||||
import {
|
||||
check,
|
||||
request,
|
||||
@@ -6,93 +10,72 @@ import {
|
||||
openSettings,
|
||||
PERMISSIONS,
|
||||
Permission,
|
||||
checkNotifications,
|
||||
requestNotifications,
|
||||
} from 'react-native-permissions';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import { logger } from '../core/observability/logger';
|
||||
|
||||
// Normalize permission for platform
|
||||
const normalizePermission = (perm: string): string => {
|
||||
const normalizePermission = (perm: string): Permission => {
|
||||
return Platform.select({
|
||||
ios: (PERMISSIONS.IOS as Record<string, string>)[perm] || perm,
|
||||
android: (PERMISSIONS.ANDROID as Record<string, string>)[perm] || perm,
|
||||
default: perm,
|
||||
})!;
|
||||
}) as Permission;
|
||||
};
|
||||
|
||||
|
||||
// Generic permission requester
|
||||
export const requestSinglePermission = async (
|
||||
permission: string
|
||||
): Promise<boolean> => {
|
||||
const normalized = normalizePermission(permission);
|
||||
|
||||
|
||||
const status: PermissionStatus = await check(normalized);
|
||||
|
||||
|
||||
if (status === RESULTS.GRANTED) {
|
||||
console.log(`✅ ${permission} already granted.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
if (status === RESULTS.BLOCKED) {
|
||||
console.log(`🚫 ${permission} is blocked. Prompt user to go to settings.`);
|
||||
logger.info('permissions', `${permission} is blocked — opening settings`);
|
||||
openSettings();
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (status === RESULTS.DENIED || status === RESULTS.LIMITED) {
|
||||
const result: PermissionStatus = await request(normalized);
|
||||
if (result === RESULTS.GRANTED) {
|
||||
console.log(`✅ ${permission} granted after request.`);
|
||||
return true;
|
||||
} else {
|
||||
console.log(`❌ ${permission} denied.`);
|
||||
return false;
|
||||
}
|
||||
return result === RESULTS.GRANTED;
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Fallback definition
|
||||
const POST_NOTIFICATIONS =
|
||||
Platform.OS === 'android' ? 'android.permission.POST_NOTIFICATIONS' : '';
|
||||
|
||||
|
||||
export const requestNotificationPermission = async (): Promise<boolean> => {
|
||||
|
||||
if (Platform.OS !== 'android' || !POST_NOTIFICATIONS) {
|
||||
console.log('Notification permission not needed on this platform.');
|
||||
if (Platform.OS !== 'android') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const status: PermissionStatus = await check(POST_NOTIFICATIONS);
|
||||
|
||||
|
||||
const { status } = await checkNotifications();
|
||||
|
||||
if (status === RESULTS.GRANTED) {
|
||||
console.log('🔔 Notification permission already granted.');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
if (status === RESULTS.BLOCKED) {
|
||||
console.log('🔕 Notification permission is blocked. Prompt user to open settings.');
|
||||
logger.info('permissions', 'notifications blocked — opening settings');
|
||||
openSettings();
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (status === RESULTS.DENIED) {
|
||||
const result: PermissionStatus = await request(POST_NOTIFICATIONS);
|
||||
if (result === RESULTS.GRANTED) {
|
||||
console.log('✅ Notification permission granted.');
|
||||
return true;
|
||||
} else {
|
||||
console.log('❌ Notification permission denied.');
|
||||
return false;
|
||||
}
|
||||
const { status: result } = await requestNotifications(['alert', 'sound']);
|
||||
return result === RESULTS.GRANTED;
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const isAnyPermissionGranted = async (permissions: Permission[]): Promise<boolean> => {
|
||||
for (const permission of permissions) {
|
||||
const result = await check(permission);
|
||||
@@ -105,12 +88,12 @@ export const isAnyPermissionGranted = async (permissions: Permission[]): Promise
|
||||
return false; // No permissions granted
|
||||
};
|
||||
|
||||
export async function checkPermission(permission:any) {
|
||||
export async function checkPermission(permission: Permission) {
|
||||
try {
|
||||
const result = await check(permission);
|
||||
return result === RESULTS.GRANTED;
|
||||
} catch (error) {
|
||||
console.warn('Permission check error:', error);
|
||||
logger.warn('permissions', 'permission check failed', { error: String(error) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export const auctionSlice = createSlice({
|
||||
},
|
||||
removeAuction: (state) => {
|
||||
state.auction = '';
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
export const { setAuction , removeAuction} = auctionSlice.actions;
|
||||
|
||||
@@ -34,4 +34,4 @@ export const {
|
||||
clearPurchaseTender,
|
||||
} = selectPurchaseTenderSlice.actions;
|
||||
|
||||
export default selectPurchaseTenderSlice.reducer;
|
||||
export default selectPurchaseTenderSlice.reducer;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeState {
|
||||
mode: ThemeMode;
|
||||
}
|
||||
|
||||
const initialState: ThemeState = {
|
||||
mode: 'system',
|
||||
};
|
||||
|
||||
export const themeSlice = createSlice({
|
||||
name: 'theme',
|
||||
initialState,
|
||||
reducers: {
|
||||
setThemeMode: (state, action: PayloadAction<ThemeMode>) => {
|
||||
state.mode = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setThemeMode } = themeSlice.actions;
|
||||
|
||||
export const selectThemeMode = (state: any) => state.theme.mode;
|
||||
|
||||
export default themeSlice.reducer;
|
||||
@@ -1,20 +1,65 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
const initialState = {
|
||||
token: '',
|
||||
/**
|
||||
* The authenticated session. `token` is the JWT; the remaining fields are the
|
||||
* backend user profile returned at login. Kept intentionally open (`unknown`
|
||||
* index) until a typed User domain model replaces the raw login response.
|
||||
*/
|
||||
export type AuthSession = {
|
||||
token: string;
|
||||
/** 'tender' | 'auction' — kept as string until the backend contract is typed. */
|
||||
userType?: string;
|
||||
firstName?: string;
|
||||
fullName?: string;
|
||||
contractorId?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/** `''` means logged out — kept for backward compatibility with persisted state. */
|
||||
export type TokenState = {
|
||||
token: AuthSession | '';
|
||||
};
|
||||
|
||||
const initialState: TokenState = {
|
||||
token: '',
|
||||
};
|
||||
|
||||
const isValidSession = (payload: unknown): payload is AuthSession =>
|
||||
typeof payload === 'object' && payload !== null && typeof (payload as AuthSession).token === 'string';
|
||||
|
||||
export const tokenSlice = createSlice({
|
||||
name: 'token',
|
||||
initialState,
|
||||
reducers: {
|
||||
setToken: (state, { payload }) => {
|
||||
state.token = payload;
|
||||
},
|
||||
removeToken: (state) => {
|
||||
state.token = '';
|
||||
}
|
||||
name: 'token',
|
||||
initialState,
|
||||
reducers: {
|
||||
setToken: (state, { payload }: PayloadAction<AuthSession | Record<string, never> | ''>) => {
|
||||
// Normalize every "cleared" shape ({} on 401, '' on logout) to ''
|
||||
// so there is exactly one logged-out representation.
|
||||
state.token = isValidSession(payload) ? payload : '';
|
||||
},
|
||||
removeToken: state => {
|
||||
state.token = '';
|
||||
},
|
||||
},
|
||||
});
|
||||
export const { setToken , removeToken} = tokenSlice.actions;
|
||||
|
||||
export const { setToken, removeToken } = tokenSlice.actions;
|
||||
|
||||
type WithTokenSlice = { token: TokenState };
|
||||
|
||||
/** The session object, or null when logged out. */
|
||||
export const getToken = (state: WithTokenSlice): AuthSession | null => {
|
||||
const session = state.token.token;
|
||||
return isValidSession(session) ? session : null;
|
||||
};
|
||||
|
||||
/** The raw JWT, or null when logged out — the ONLY sanctioned way to read it. */
|
||||
export const selectAuthJwt = (state: WithTokenSlice): string | null => {
|
||||
const session = state.token.token;
|
||||
return isValidSession(session) ? session.token : null;
|
||||
};
|
||||
|
||||
/** True when a usable session exists. */
|
||||
export const selectIsAuthenticated = (state: WithTokenSlice): boolean =>
|
||||
selectAuthJwt(state) !== null;
|
||||
|
||||
export default tokenSlice.reducer;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import reducer, {
|
||||
setToken,
|
||||
removeToken,
|
||||
getToken,
|
||||
selectAuthJwt,
|
||||
selectIsAuthenticated,
|
||||
TokenState,
|
||||
} from '../Token';
|
||||
|
||||
const asRoot = (token: TokenState) => ({ token });
|
||||
|
||||
describe('token slice', () => {
|
||||
const session = { token: 'jwt-abc', userType: 'tender', fullName: 'A User' };
|
||||
|
||||
it('stores a valid session', () => {
|
||||
const state = reducer(undefined, setToken(session));
|
||||
expect(state.token).toEqual(session);
|
||||
});
|
||||
|
||||
it('normalizes every cleared shape to the single logged-out value', () => {
|
||||
// Historical 401 path dispatched setToken({}); logout dispatched ''.
|
||||
expect(reducer(undefined, setToken({})).token).toBe('');
|
||||
expect(reducer(undefined, setToken('')).token).toBe('');
|
||||
expect(reducer({ token: session }, removeToken()).token).toBe('');
|
||||
});
|
||||
|
||||
it('selectors expose the JWT only for valid sessions', () => {
|
||||
expect(selectAuthJwt(asRoot({ token: session }))).toBe('jwt-abc');
|
||||
expect(selectAuthJwt(asRoot({ token: '' }))).toBeNull();
|
||||
expect(selectIsAuthenticated(asRoot({ token: session }))).toBe(true);
|
||||
expect(selectIsAuthenticated(asRoot({ token: '' }))).toBe(false);
|
||||
expect(getToken(asRoot({ token: session }))).toEqual(session);
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import {configureStore} from '@reduxjs/toolkit';
|
||||
import {combineReducers} from 'redux';
|
||||
import {persistStore, persistReducer} from 'redux-persist';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import {tokenSlice} from './Slices/Token';
|
||||
import {auctionSlice} from './Slices/SelectedAuction';
|
||||
import selectPurchaseAuctionReducer from './Slices/selectPurchaseAuction'
|
||||
import selectPurchaseTenderReducer from './Slices/SelectedPurchaseTender';
|
||||
import { tenderSlice } from './Slices/SelectedTender';
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
token: tokenSlice.reducer,
|
||||
auction: auctionSlice.reducer,
|
||||
selectPurchaseAuction: selectPurchaseAuctionReducer,
|
||||
selectPurchaseTender: selectPurchaseTenderReducer,
|
||||
tender: tenderSlice.reducer,
|
||||
});
|
||||
const persistConfig = {
|
||||
key: 'root',
|
||||
storage: AsyncStorage,
|
||||
whitelist: ['token'],
|
||||
};
|
||||
const persistReducers = persistReducer(persistConfig, rootReducer);
|
||||
export const store = configureStore({
|
||||
reducer: persistReducers,
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: false,
|
||||
}),
|
||||
});
|
||||
export const persistor = persistStore(store);
|
||||
@@ -0,0 +1,78 @@
|
||||
import { combineReducers, configureStore } from '@reduxjs/toolkit';
|
||||
import {
|
||||
persistStore,
|
||||
persistReducer,
|
||||
FLUSH,
|
||||
REHYDRATE,
|
||||
PAUSE,
|
||||
PERSIST,
|
||||
PURGE,
|
||||
REGISTER,
|
||||
} from 'redux-persist';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { removeToken, selectAuthJwt, tokenSlice } from './Slices/Token';
|
||||
import { auctionSlice } from './Slices/SelectedAuction';
|
||||
import selectPurchaseAuctionReducer from './Slices/selectPurchaseAuction';
|
||||
import selectPurchaseTenderReducer from './Slices/SelectedPurchaseTender';
|
||||
import { tenderSlice } from './Slices/SelectedTender';
|
||||
import { themeSlice } from './Slices/ThemeSlice';
|
||||
import { secureStorage } from '../core/storage/secureStorage';
|
||||
import { setTokenProvider } from '../core/http/client';
|
||||
import { appEvents } from '../core/events/appEvents';
|
||||
|
||||
/**
|
||||
* The auth session is persisted through the device keychain/keystore
|
||||
* (hardware-backed encryption at rest) — never plaintext AsyncStorage.
|
||||
* Non-sensitive prefs (theme) stay in AsyncStorage via the root persist.
|
||||
*/
|
||||
const tokenPersistConfig = {
|
||||
key: 'token',
|
||||
storage: secureStorage,
|
||||
};
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
token: persistReducer(tokenPersistConfig, tokenSlice.reducer),
|
||||
auction: auctionSlice.reducer,
|
||||
selectPurchaseAuction: selectPurchaseAuctionReducer,
|
||||
selectPurchaseTender: selectPurchaseTenderReducer,
|
||||
tender: tenderSlice.reducer,
|
||||
theme: themeSlice.reducer,
|
||||
});
|
||||
|
||||
const rootPersistConfig = {
|
||||
key: 'root',
|
||||
storage: AsyncStorage,
|
||||
whitelist: ['theme'],
|
||||
};
|
||||
|
||||
const persistedReducer = persistReducer(rootPersistConfig, rootReducer);
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: persistedReducer,
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware({
|
||||
// redux-persist dispatches non-serializable actions by design; everything
|
||||
// else stays checked so genuine leaks surface in development.
|
||||
serializableCheck: {
|
||||
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export const persistor = persistStore(store);
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composition root wiring: the transport layer never imports the store; it
|
||||
// reads the JWT through this provider and announces auth failures on the
|
||||
// event bus, which we translate into state changes here.
|
||||
// ---------------------------------------------------------------------------
|
||||
setTokenProvider(() => selectAuthJwt(store.getState()));
|
||||
|
||||
appEvents.on('auth/unauthorized', () => {
|
||||
if (selectAuthJwt(store.getState()) !== null) {
|
||||
store.dispatch(removeToken());
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
|
||||
import type { AppDispatch, RootState } from './Store';
|
||||
|
||||
/** Typed replacements for the raw react-redux hooks — use these everywhere. */
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch;
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
@@ -1,22 +1,29 @@
|
||||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Animated, Dimensions } from 'react-native';
|
||||
import { InsideHeader } from '../../Component/Index';
|
||||
import { useFetchLiveAuction, useFetchPurchaseAuction } from '../../Services/BBPS/Hooks';
|
||||
import colors from '../../Constant/Color';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Animated,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { InsideHeader } from '../../components';
|
||||
import { useFetchLiveAuction, useFetchPurchaseAuction } from '../../Services/ApiCall/Hooks';
|
||||
import LiveAuction from './LiveAuction';
|
||||
import PurchaseAuction from './PurchaseAuction';
|
||||
import { ShoppingCart } from 'lucide-react-native';
|
||||
import { ShoppingCart, Radio, Package } from 'lucide-react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import FontsFamily from '../../Constant/FontsFamily';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import AppText from '../../components/AppText';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
const HORIZONTAL_PADDING = 32;
|
||||
const SWITCH_INSETS = 2;
|
||||
const TAB_HEIGHT = 44;
|
||||
const SWITCH_INSETS = 3;
|
||||
const TAB_HEIGHT = 48;
|
||||
|
||||
const Auction = () => {
|
||||
const { colors, spacing, shadows } = useTheme();
|
||||
const Navigation = useNavigation() as any;
|
||||
const { data: liveAuctionData = {} }: any = useFetchLiveAuction();
|
||||
const { data: purchaseAuctionData = {} } = useFetchPurchaseAuction();
|
||||
@@ -30,15 +37,14 @@ const Auction = () => {
|
||||
? purchaseAuctionData.data.length
|
||||
: 0;
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState<'live' | 'purchase'>('live');
|
||||
|
||||
const translateX = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const TAB_WIDTH = useMemo(() => {
|
||||
const availableWidth = SCREEN_WIDTH - HORIZONTAL_PADDING - SWITCH_INSETS * 2;
|
||||
return availableWidth / 2.4;
|
||||
return availableWidth / 2;
|
||||
}, []);
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState<'live' | 'purchase'>('purchase');
|
||||
const translateX = useRef(new Animated.Value(TAB_WIDTH)).current;
|
||||
|
||||
const onTabPress = (tab: 'live' | 'purchase', index: number) => {
|
||||
setSelectedTab(tab);
|
||||
Animated.spring(translateX, {
|
||||
@@ -48,147 +54,254 @@ const Auction = () => {
|
||||
}).start();
|
||||
};
|
||||
|
||||
|
||||
const cartCount = SelectedPurchaseList.length;
|
||||
|
||||
const styles = useMemo(
|
||||
() => makeStyles(colors, spacing, shadows, TAB_WIDTH, TAB_HEIGHT),
|
||||
[colors, spacing, shadows, TAB_WIDTH],
|
||||
);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<InsideHeader title="Auction" showArrow />
|
||||
|
||||
{/* Tab Row */}
|
||||
<View style={styles.topRow}>
|
||||
{/* Segmented Control */}
|
||||
<View
|
||||
style={[
|
||||
styles.switchWrapper,
|
||||
{
|
||||
width: TAB_WIDTH * 2 + SWITCH_INSETS * 2,
|
||||
},
|
||||
{ width: TAB_WIDTH * 2 + SWITCH_INSETS * 2 },
|
||||
]}
|
||||
>
|
||||
{/* Sliding pill */}
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
styles.activeTab,
|
||||
{
|
||||
width: TAB_WIDTH,
|
||||
transform: [{ translateX }],
|
||||
},
|
||||
{ width: TAB_WIDTH, transform: [{ translateX }] },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Live Tab */}
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, { width: TAB_WIDTH }]}
|
||||
activeOpacity={0.8}
|
||||
onPress={() => onTabPress('live', 0)}
|
||||
>
|
||||
<Text
|
||||
<Radio
|
||||
size={13}
|
||||
color={selectedTab === 'live' ? colors.primary : colors.textSecondary}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<AppText
|
||||
variant="caption"
|
||||
color={selectedTab === 'live' ? colors.primary : colors.textSecondary}
|
||||
style={[
|
||||
styles.tabText,
|
||||
selectedTab === 'live' && styles.activeText,
|
||||
selectedTab === 'live' && (styles.activeTabText as any),
|
||||
]}
|
||||
>
|
||||
Live ({liveCount})
|
||||
</Text>
|
||||
Live {liveCount ? `(${liveCount})` : ''}
|
||||
</AppText>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Purchase Tab */}
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, { width: TAB_WIDTH }]}
|
||||
activeOpacity={0.8}
|
||||
onPress={() => onTabPress('purchase', 1)}
|
||||
>
|
||||
<Text
|
||||
<Package
|
||||
size={13}
|
||||
color={selectedTab === 'purchase' ? colors.primary : colors.textSecondary}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<AppText
|
||||
variant="caption"
|
||||
color={selectedTab === 'purchase' ? colors.primary : colors.textSecondary}
|
||||
style={[
|
||||
styles.tabText,
|
||||
selectedTab === 'purchase' && styles.activeText,
|
||||
selectedTab === 'purchase' && (styles.activeTabText as any),
|
||||
]}
|
||||
>
|
||||
Purchased ({purchaseCount})
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
{SelectedPurchaseList.length ? (
|
||||
<View
|
||||
style={{
|
||||
borderWidth: 2,
|
||||
borderColor: colors.white,
|
||||
backgroundColor: colors.primaryLight,
|
||||
position: 'absolute',
|
||||
borderRadius: 50,
|
||||
zIndex: 20,
|
||||
right: 0,
|
||||
top: -10,
|
||||
paddingHorizontal: 2,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 12, color: colors.black }}>
|
||||
{SelectedPurchaseList.length}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
padding: 10,
|
||||
backgroundColor: colors.primaryLight,
|
||||
borderRadius: 20,
|
||||
}}
|
||||
onPress={() => {
|
||||
Navigation.navigate(NavigationString.PurchaseList);
|
||||
}}
|
||||
>
|
||||
<ShoppingCart size={22} color={colors.black} strokeWidth={2} />
|
||||
Purchased {purchaseCount ? `(${purchaseCount})` : ''}
|
||||
</AppText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
{selectedTab === 'live' ? <LiveAuction /> : <PurchaseAuction />}
|
||||
|
||||
{/* Cart Floating Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.cartFloatingButton}
|
||||
activeOpacity={0.9}
|
||||
onPress={() => Navigation.navigate(NavigationString.PurchaseList)}
|
||||
>
|
||||
<ShoppingCart size={24} color={colors.buttonText} strokeWidth={2} />
|
||||
{cartCount > 0 && (
|
||||
<View style={styles.cartBadge}>
|
||||
<AppText variant="caption" color={colors.primary} style={styles.cartBadgeText as any}>
|
||||
{cartCount}
|
||||
</AppText>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Auction;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.white,
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
switchWrapper: {
|
||||
flexDirection: 'row',
|
||||
marginVertical: 14,
|
||||
backgroundColor: '#f1f5f9',
|
||||
borderRadius: 18,
|
||||
padding: 2,
|
||||
height: TAB_HEIGHT,
|
||||
},
|
||||
tab: {
|
||||
height: TAB_HEIGHT - 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 2,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 13,
|
||||
color: '#64748b',
|
||||
lineHeight: 18,
|
||||
fontFamily: FontsFamily.poppinsSemiBold,
|
||||
},
|
||||
activeText: {
|
||||
color: colors.primary,
|
||||
},
|
||||
activeTab: {
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
left: 2,
|
||||
height: TAB_HEIGHT - 4,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 6,
|
||||
elevation: 3,
|
||||
},
|
||||
});
|
||||
/* ─── Styles factory ─── */
|
||||
const makeStyles = (colors: any, spacing: any, shadows: any, TAB_WIDTH: number, TAB_HEIGHT: number) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
|
||||
/* ── Tab Row ── */
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.m,
|
||||
paddingVertical: spacing.s + 2,
|
||||
},
|
||||
switchWrapper: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 16,
|
||||
padding: SWITCH_INSETS,
|
||||
height: TAB_HEIGHT,
|
||||
...shadows.light,
|
||||
},
|
||||
tab: {
|
||||
height: TAB_HEIGHT - SWITCH_INSETS * 2,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 5,
|
||||
zIndex: 2,
|
||||
},
|
||||
tabText: {
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.3,
|
||||
} as any,
|
||||
activeTabText: {
|
||||
fontWeight: '700',
|
||||
} as any,
|
||||
activeTab: {
|
||||
position: 'absolute',
|
||||
top: SWITCH_INSETS,
|
||||
left: SWITCH_INSETS,
|
||||
height: TAB_HEIGHT - SWITCH_INSETS * 2,
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: 13,
|
||||
...shadows.medium,
|
||||
},
|
||||
|
||||
/* ── Cart Floating Button ── */
|
||||
cartFloatingButton: {
|
||||
position: 'absolute',
|
||||
bottom: spacing.xl + 80,
|
||||
right: spacing.l,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...shadows.strong,
|
||||
zIndex: 999,
|
||||
},
|
||||
cartBadge: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
minWidth: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.background,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 4,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
cartBadgeText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
lineHeight: 13,
|
||||
} as any,
|
||||
|
||||
/* ── Restricted Access ── */
|
||||
restrictedContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.l,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
restrictedCard: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 28,
|
||||
padding: spacing.l,
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
...shadows.strong,
|
||||
},
|
||||
lockRing: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: `${colors.primary}12`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.l,
|
||||
borderWidth: 1.5,
|
||||
borderColor: `${colors.primary}25`,
|
||||
},
|
||||
lockInner: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
backgroundColor: `${colors.primary}18`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
restrictedTitle: {
|
||||
marginBottom: spacing.s + 4,
|
||||
} as any,
|
||||
restrictedMessage: {
|
||||
marginBottom: spacing.s,
|
||||
lineHeight: 24,
|
||||
} as any,
|
||||
hintBox: {
|
||||
backgroundColor: colors.overlay,
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: spacing.m,
|
||||
paddingVertical: spacing.s + 2,
|
||||
marginBottom: spacing.l,
|
||||
width: '100%',
|
||||
},
|
||||
loginButton: {
|
||||
backgroundColor: colors.buttonPrimary,
|
||||
paddingHorizontal: spacing.l,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 16,
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
...shadows.medium,
|
||||
},
|
||||
loginBtnText: {
|
||||
letterSpacing: 1.2,
|
||||
} as any,
|
||||
});
|
||||
|
||||
@@ -1,181 +1,202 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { View, FlatList, ActivityIndicator, RefreshControl, Text } from 'react-native';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import Animated, { FadeInDown, FadeOutUp } from 'react-native-reanimated';
|
||||
import LiveAuctionTile from './LiveAuctionTile';
|
||||
import { useFetchLiveAuction } from '../../Services/BBPS/Hooks';
|
||||
import { SearchInput } from '../../Component/Serach/SeacrhInput';
|
||||
import { Skelton } from '../../Component/Index';
|
||||
import colors from '../../Constant/Color';
|
||||
import { useFetchLiveAuction } from '../../Services/ApiCall/Hooks';
|
||||
import { SearchInput } from '../../components/SearchInput';
|
||||
import { Skeleton, EmptyState } from '../../components';
|
||||
import SkeletonList from '../../components/SkeletonList';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { togglePurchaseAuction } from '../../Redux/Slices/selectPurchaseAuction';
|
||||
import { apiCall } from '../../Axios/Axios';
|
||||
import { GET_MY_AUCTION } from '../../Services/BBPS/ApiUrls';
|
||||
import SkeltonList from '../../Component/Skelton/SkeltonList';
|
||||
import Skeleton from '../../Component/Skelton/Skelton';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import AppText from '../../components/AppText';
|
||||
|
||||
const LiveAuction = () => {
|
||||
const Dispatch = useDispatch()
|
||||
const onEndReachedCalledDuringMomentum = useRef(true);
|
||||
const SelectedPurchaseList = useSelector((state: any) => state.selectPurchaseAuction);
|
||||
const [showRefreshBanner, setShowRefreshBanner] = useState(false);
|
||||
const [allBoolean, setAllBoolean] = useState({ liveAuctionData: [], isLoading: false })
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isRefetching, refetch, isPending }: any = useFetchLiveAuction();
|
||||
const Dispatch = useDispatch();
|
||||
const { colors, shadows } = useTheme();
|
||||
const user = useSelector((state: any) => state.token?.token);
|
||||
const onEndReachedCalledDuringMomentum = useRef(true);
|
||||
const SelectedPurchaseList = useSelector((state: any) => state.selectPurchaseAuction);
|
||||
const [showRefreshBanner, setShowRefreshBanner] = useState(false);
|
||||
|
||||
// My Methods
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
refetch,
|
||||
isPending,
|
||||
}: any = useFetchLiveAuction();
|
||||
|
||||
const getMyAuction = async () => {
|
||||
try {
|
||||
setAllBoolean(prev => ({ ...prev, isLoading: true }))
|
||||
const res = await apiCall<any>('get', `${GET_MY_AUCTION}`,);
|
||||
if (res?.statusCode === 200) {
|
||||
const purchasedIds = new Set(res.data.map((item: any) => item.auctionId));
|
||||
const filteredLive = data?.result.map((item: any) => ({ ...item, active: !purchasedIds.has(item.auctionId) }));
|
||||
setAllBoolean(prev => ({ ...prev, liveAuctionData: filteredLive }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('getMyAuction error', error);
|
||||
} finally {
|
||||
setAllBoolean(prev => ({ ...prev, isLoading: false }));
|
||||
}
|
||||
};
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: any) => {
|
||||
const isPurchase = SelectedPurchaseList.findIndex(
|
||||
(prev: any) => prev.auctionNumber === item.auctionNumber,
|
||||
);
|
||||
return (
|
||||
<LiveAuctionTile
|
||||
data={item}
|
||||
index={index}
|
||||
isPurchasedByContractor={item.contractorId?.includes(
|
||||
String(user?.contractorId),
|
||||
)}
|
||||
auctionId={item.auctionId}
|
||||
auctionDispatchDate={item.auctionDispatchDate}
|
||||
isPurchased={isPurchase !== -1}
|
||||
purchaseEnd={item.purchaseEnd}
|
||||
auctionOfWork={item.auctionOfWork}
|
||||
auctionFees={item.auctionFees}
|
||||
auctionNumber={item.auctionNumber}
|
||||
onPurchasePress={() => {
|
||||
Dispatch(togglePurchaseAuction(item));
|
||||
}}
|
||||
onDetailsPress={() => {}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
// Intentionally runs only on SelectedPurchaseList changes; other deps are stable or recreated each render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[SelectedPurchaseList],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: any) => {
|
||||
const isPurchase = SelectedPurchaseList.findIndex((prev: any) => prev.auctionNumber === item.auctionNumber)
|
||||
return <LiveAuctionTile
|
||||
data={item}
|
||||
index={index}
|
||||
auctionId={item.auctionId}
|
||||
auctionDispatchDate={item.auctionDispatchDate}
|
||||
isPurchased={isPurchase !== -1 ? true : false}
|
||||
purchaseEnd={item.purchaseEnd}
|
||||
auctionOfWork={item.auctionOfWork}
|
||||
auctionFees={item.auctionFees}
|
||||
auctionNumber={item.auctionNumber}
|
||||
onPurchasePress={() => { Dispatch(togglePurchaseAuction(item)) }}
|
||||
onDetailsPress={() => { }}
|
||||
/>
|
||||
},
|
||||
[SelectedPurchaseList]
|
||||
);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (onEndReachedCalledDuringMomentum.current) return;
|
||||
|
||||
if (!hasNextPage || isFetchingNextPage) return;
|
||||
|
||||
fetchNextPage();
|
||||
onEndReachedCalledDuringMomentum.current = true;
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const renderFooter = useCallback(() => {
|
||||
if (!isFetchingNextPage) return null;
|
||||
return (
|
||||
<View style={{ paddingVertical: 12 }}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
</View>
|
||||
);
|
||||
}, [isFetchingNextPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRefetching) {
|
||||
setShowRefreshBanner(true);
|
||||
} else {
|
||||
const timer = setTimeout(() => {
|
||||
setShowRefreshBanner(false);
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isRefetching]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.result?.length > 0) {
|
||||
getMyAuction()
|
||||
}
|
||||
}, [data])
|
||||
|
||||
if (isLoading) {
|
||||
return <Skelton />;
|
||||
}
|
||||
const loadMore = useCallback(() => {
|
||||
if (onEndReachedCalledDuringMomentum.current) return;
|
||||
if (!hasNextPage || isFetchingNextPage) return;
|
||||
fetchNextPage();
|
||||
onEndReachedCalledDuringMomentum.current = true;
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const renderFooter = useCallback(() => {
|
||||
if (!isFetchingNextPage) return null;
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<SearchInput onChange={() => { }} placeholder="Search...." filter={false} />
|
||||
|
||||
{showRefreshBanner && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(200)}
|
||||
exiting={FadeOutUp.duration(500)}
|
||||
style={{
|
||||
alignSelf: 'center',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 14,
|
||||
marginTop: 6,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 3 },
|
||||
elevation: 4,
|
||||
zIndex: 10, // Ensure it stays on top
|
||||
position: 'absolute',
|
||||
top: 60, // Adjust based on your SearchInput height
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: '#374151',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Refreshing auctions
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
{
|
||||
isPending || allBoolean.isLoading ? <Skeleton /> :
|
||||
<FlatList
|
||||
data={allBoolean.liveAuctionData ?? []}
|
||||
renderItem={renderItem}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 50, paddingTop: 10 }}
|
||||
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.1}
|
||||
|
||||
onMomentumScrollBegin={() => {
|
||||
onEndReachedCalledDuringMomentum.current = false;
|
||||
}}
|
||||
|
||||
ListFooterComponent={renderFooter}
|
||||
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefetching}
|
||||
onRefresh={refetch}
|
||||
tintColor="transparent"
|
||||
colors={[colors.primary]}
|
||||
/>
|
||||
}
|
||||
|
||||
initialNumToRender={6}
|
||||
maxToRenderPerBatch={6}
|
||||
windowSize={7}
|
||||
removeClippedSubviews
|
||||
/>
|
||||
}
|
||||
|
||||
|
||||
|
||||
</View>
|
||||
<View style={styles.footerLoader}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
<AppText variant="caption" color={colors.textSecondary}>
|
||||
Loading more…
|
||||
</AppText>
|
||||
</View>
|
||||
);
|
||||
}, [isFetchingNextPage, colors]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRefetching) {
|
||||
setShowRefreshBanner(true);
|
||||
} else {
|
||||
const timer = setTimeout(() => setShowRefreshBanner(false), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isRefetching]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{/* Search */}
|
||||
<SearchInput onChange={() => {}} placeholder="Search auctions…" filter={false} />
|
||||
|
||||
{/* Refresh Banner */}
|
||||
{showRefreshBanner && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(200)}
|
||||
exiting={FadeOutUp.duration(400)}
|
||||
style={[
|
||||
styles.refreshBanner,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
...shadows.medium,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
<AppText variant="caption" color={colors.text} style={styles.refreshText as any}>
|
||||
Refreshing auctions…
|
||||
</AppText>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
{isPending ? (
|
||||
<SkeletonList />
|
||||
) : (
|
||||
<FlatList
|
||||
data={data?.result ?? []}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item: any) => item?.auctionId?.toString()}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.listContent}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.1}
|
||||
onMomentumScrollBegin={() => {
|
||||
onEndReachedCalledDuringMomentum.current = false;
|
||||
}}
|
||||
ListFooterComponent={renderFooter}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
title="No live auctions found"
|
||||
subtitle="New live auctions will appear here as they open."
|
||||
/>
|
||||
}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefetching}
|
||||
onRefresh={refetch}
|
||||
tintColor="transparent"
|
||||
colors={[colors.primary]}
|
||||
/>
|
||||
}
|
||||
initialNumToRender={6}
|
||||
maxToRenderPerBatch={6}
|
||||
windowSize={7}
|
||||
removeClippedSubviews
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveAuction;
|
||||
export default LiveAuction;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 60,
|
||||
paddingTop: 10,
|
||||
},
|
||||
refreshBanner: {
|
||||
alignSelf: 'center',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
marginTop: 6,
|
||||
borderRadius: 20,
|
||||
zIndex: 10,
|
||||
position: 'absolute',
|
||||
top: 60,
|
||||
},
|
||||
refreshText: {
|
||||
fontWeight: '500',
|
||||
} as any,
|
||||
footerLoader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import React, { memo } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||
import moment from 'moment';
|
||||
import colors from '../../Constant/Color';
|
||||
import { LiveAuctionTileProps } from './types/types';
|
||||
import AuctionDetail from './Modal/AuctionDetail';
|
||||
import { Calendar, Tag, Info, ArrowRight } from 'lucide-react-native';
|
||||
import {
|
||||
Tag,
|
||||
Calendar,
|
||||
Clock,
|
||||
ShoppingCart,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
} from 'lucide-react-native';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import AppText from '../../components/AppText';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
|
||||
const LiveAuctionTile: React.FC<LiveAuctionTileProps> = ({
|
||||
const LiveAuctionTile: React.FC<LiveAuctionTileProps & { index?: number }> = ({
|
||||
data,
|
||||
index = 0,
|
||||
isPurchased = false,
|
||||
onPurchasePress,
|
||||
auctionDispatchDate,
|
||||
@@ -15,230 +27,252 @@ const LiveAuctionTile: React.FC<LiveAuctionTileProps> = ({
|
||||
purchaseEnd,
|
||||
auctionFees,
|
||||
auctionNumber,
|
||||
isPurchasedByContractor,
|
||||
}) => {
|
||||
const { colors, spacing, shadows } = useTheme();
|
||||
const [showDetail, setShowDetail] = React.useState(false);
|
||||
|
||||
const purchaseEnded = moment().isAfter(moment(purchaseEnd, 'DD-MM-YYYY HH:mm:ss'));
|
||||
const deadlineFormatted = moment(purchaseEnd, 'DD-MM-YYYY HH:mm:ss').format('DD MMM, YY');
|
||||
const Navigation = useNavigation() as any;
|
||||
const getStatus = () => {
|
||||
if (isPurchasedByContractor) return 'purchased';
|
||||
if (purchaseEnded) return 'expired';
|
||||
if (isPurchased) return 'in_cart';
|
||||
return 'active';
|
||||
};
|
||||
const status = getStatus();
|
||||
|
||||
const styles = makeStyles(colors, spacing, shadows);
|
||||
|
||||
if (showDetail) {
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<AuctionDetail visible={showDetail} data={data} onClose={() => setShowDetail(false)} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.cardWrapper}>
|
||||
{
|
||||
!showDetail ?
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
styles.statusTag,
|
||||
{ backgroundColor: isPurchased ? colors.error : purchaseEnded ? '#94a3b8' : '#4ade80' },
|
||||
]}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(index * 60).springify()}
|
||||
style={styles.card}
|
||||
>
|
||||
{/* ── Row 1: Number + Fee ── */}
|
||||
<View style={styles.topRow}>
|
||||
<View style={styles.numberRow}>
|
||||
<Tag size={14} color={colors.primary} strokeWidth={2} />
|
||||
<AppText variant="bodySmall" color={colors.primary} style={styles.auctionNo as any}>
|
||||
#{auctionNumber}
|
||||
</AppText>
|
||||
</View>
|
||||
<AppText variant="h4" color={colors.primary} style={styles.fee as any}>
|
||||
₹{auctionFees}
|
||||
</AppText>
|
||||
</View>
|
||||
|
||||
{/* ── Row 2: Title ── */}
|
||||
<AppText variant="h4" color={colors.text} numberOfLines={2} style={styles.title as any}>
|
||||
{auctionOfWork ?? 'General Auction Work'}
|
||||
</AppText>
|
||||
|
||||
{/* ── Row 3: Date strip ── */}
|
||||
<View style={[styles.dateStrip, { backgroundColor: colors.overlay }]}>
|
||||
{/* Start */}
|
||||
<View style={styles.dateCol}>
|
||||
<View style={styles.dateHeader}>
|
||||
<Calendar size={13} color={colors.textSecondary} strokeWidth={2} />
|
||||
<AppText variant="caption" color={colors.textSecondary} style={styles.dateLabel as any}>
|
||||
Start Date
|
||||
</AppText>
|
||||
</View>
|
||||
<AppText variant="caption" color={colors.text} style={styles.dateValue as any}>
|
||||
{auctionDispatchDate ?? '—'}
|
||||
</AppText>
|
||||
</View>
|
||||
|
||||
{/* Separator */}
|
||||
<View style={[styles.separator, { backgroundColor: colors.border }]} />
|
||||
|
||||
{/* Deadline */}
|
||||
<View style={styles.dateCol}>
|
||||
<View style={styles.dateHeader}>
|
||||
<Clock
|
||||
size={13}
|
||||
color={purchaseEnded ? colors.error : colors.success}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<View
|
||||
style={styles.container}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity style={styles.badge} onPress={() => setShowDetail(true)}>
|
||||
<Tag size={14} color={colors.primary} />
|
||||
<Text style={styles.badgeText}>#{auctionNumber}</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.priceText}>₹{auctionFees}</Text>
|
||||
</View>
|
||||
<AppText variant="caption" color={colors.textSecondary} style={styles.dateLabel as any}>
|
||||
Deadline
|
||||
</AppText>
|
||||
</View>
|
||||
<AppText
|
||||
variant="caption"
|
||||
color={purchaseEnded ? colors.error : colors.text}
|
||||
style={styles.dateValue as any}
|
||||
>
|
||||
{deadlineFormatted}
|
||||
</AppText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Work Title */}
|
||||
<Text style={styles.workTitle} numberOfLines={2}>
|
||||
{auctionOfWork ?? 'General Auction Work'}
|
||||
</Text>
|
||||
{/* ── Row 4: Actions ── */}
|
||||
<View style={styles.actionRow}>
|
||||
{/* Ghost Details button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.detailsBtn, { borderColor: colors.border }]}
|
||||
onPress={() => setShowDetail(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<AppText variant="bodySmall" color={colors.textSecondary} style={styles.detailsText as any}>
|
||||
View Details
|
||||
</AppText>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Info Grid */}
|
||||
<View style={styles.infoGrid}>
|
||||
<View style={styles.infoItem}>
|
||||
<Calendar size={16} color="#64748b" />
|
||||
<View style={styles.infoTextGroup}>
|
||||
<Text style={styles.infoLabel}>Start</Text>
|
||||
<Text style={styles.infoValue}>{auctionDispatchDate ?? '-'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoItem}>
|
||||
<Calendar size={16} color={purchaseEnded ? "#ef4444" : "#22c55e"} />
|
||||
<View style={styles.infoTextGroup}>
|
||||
<Text style={styles.infoLabel}>Deadline</Text>
|
||||
<Text style={[styles.infoValue, purchaseEnded && { color: '#ef4444' }]}>
|
||||
{moment(purchaseEnd, 'DD-MM-YYYY HH:mm:ss').format('DD MMM, YY')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Action Bar */}
|
||||
<View style={styles.actionBar}>
|
||||
<TouchableOpacity style={styles.iconBtn} onPress={() => setShowDetail(true)}>
|
||||
<Info size={18} color={colors.primary} />
|
||||
<Text style={styles.iconBtnText}>Details</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.mainButton,
|
||||
purchaseEnded
|
||||
? styles.btnDisabled
|
||||
: !data.active ? { backgroundColor: 'lightgray' } : isPurchased
|
||||
? styles.btnPurchased
|
||||
: styles.btnActive,
|
||||
]}
|
||||
onPress={onPurchasePress}
|
||||
disabled={purchaseEnded ? true : data.active ? false : true}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.mainButtonText,
|
||||
isPurchased && !purchaseEnded ? { color: colors.white } : { color: '#fff' },
|
||||
]}
|
||||
>
|
||||
{purchaseEnded ? 'Expired' : !data.active ? 'Purchased' : isPurchased ? 'Remove' : 'Add to Cart'}
|
||||
</Text>
|
||||
{!purchaseEnded && (
|
||||
<ArrowRight size={16} color={isPurchased ? colors.white : '#fff'} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Auction Detail Modal */}
|
||||
</View>
|
||||
</>
|
||||
: <AuctionDetail visible={showDetail} data={data} onClose={() => setShowDetail(false)} />
|
||||
}
|
||||
|
||||
</View>
|
||||
{/* CTA */}
|
||||
{status === 'active' && (
|
||||
<TouchableOpacity
|
||||
style={[styles.ctaBtn, { backgroundColor: colors.primary }]}
|
||||
onPress={onPurchasePress}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<ShoppingCart size={14} color="#fff" strokeWidth={2.5} />
|
||||
<AppText variant="caption" color="#fff" style={styles.ctaText as any}>
|
||||
ADD TO CART
|
||||
</AppText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{status === 'in_cart' && (
|
||||
<TouchableOpacity
|
||||
style={[styles.ctaBtn, { backgroundColor: colors.success }]}
|
||||
onPress={() => { Navigation.navigate(NavigationString.PurchaseList) }}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<ShoppingCart size={14} color="#fff" strokeWidth={2.5} />
|
||||
<AppText variant="caption" color="#fff" style={styles.ctaText as any}>
|
||||
GO TO CART
|
||||
</AppText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{status === 'purchased' && (
|
||||
<View style={[styles.ctaBtn, { backgroundColor: colors.secondary }]}>
|
||||
<CheckCircle2 size={14} color="#fff" strokeWidth={2.5} />
|
||||
<AppText variant="caption" color="#fff" style={styles.ctaText as any}>
|
||||
PURCHASED
|
||||
</AppText>
|
||||
</View>
|
||||
)}
|
||||
{status === 'expired' && (
|
||||
<View style={[styles.ctaBtn, { backgroundColor: colors.secondary }]}>
|
||||
<XCircle size={14} color="#fff" strokeWidth={2.5} />
|
||||
<AppText variant="caption" color="#fff" style={styles.ctaText as any}>
|
||||
EXPIRED
|
||||
</AppText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(LiveAuctionTile);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
cardWrapper: {
|
||||
marginVertical: 10,
|
||||
marginHorizontal: 16,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 24,
|
||||
flexDirection: 'row',
|
||||
overflow: 'hidden',
|
||||
const makeStyles = (colors: any, spacing: any, shadows: any) =>
|
||||
StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
marginVertical: 7,
|
||||
padding: spacing.m,
|
||||
...shadows.medium,
|
||||
},
|
||||
|
||||
// Modern shadow
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
statusTag: {
|
||||
width: 6,
|
||||
height: '100%',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
badge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary + '25',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 12,
|
||||
gap: 6,
|
||||
},
|
||||
badgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: colors.primary,
|
||||
},
|
||||
priceText: {
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
color: '#0f172a',
|
||||
},
|
||||
workTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#334155',
|
||||
lineHeight: 24,
|
||||
marginBottom: 16,
|
||||
},
|
||||
infoGrid: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: '#f0f4f8',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 16,
|
||||
marginBottom: 18,
|
||||
},
|
||||
infoItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
infoTextGroup: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 10,
|
||||
color: '#94a3b8',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: '700',
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
color: '#1e293b',
|
||||
},
|
||||
actionBar: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
iconBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#e2e8f0',
|
||||
},
|
||||
iconBtnText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: colors.primary,
|
||||
},
|
||||
mainButton: {
|
||||
flex: 1,
|
||||
height: 46,
|
||||
borderRadius: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
btnActive: {
|
||||
backgroundColor: '#4ade80', // light green
|
||||
},
|
||||
btnPurchased: {
|
||||
backgroundColor: colors.error,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.error,
|
||||
},
|
||||
btnDisabled: {
|
||||
backgroundColor: '#e2e8f0',
|
||||
},
|
||||
mainButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
});
|
||||
/* Top row */
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.s,
|
||||
},
|
||||
numberRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
},
|
||||
auctionNo: {
|
||||
fontWeight: '700',
|
||||
} as any,
|
||||
fee: {
|
||||
fontWeight: '800',
|
||||
} as any,
|
||||
|
||||
/* Title */
|
||||
title: {
|
||||
fontWeight: '700',
|
||||
lineHeight: 24,
|
||||
marginBottom: spacing.m,
|
||||
} as any,
|
||||
|
||||
/* Date strip */
|
||||
dateStrip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 12,
|
||||
paddingVertical: spacing.s + 2,
|
||||
paddingHorizontal: spacing.m,
|
||||
marginBottom: spacing.m,
|
||||
},
|
||||
dateCol: {
|
||||
flex: 1,
|
||||
},
|
||||
dateHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
marginBottom: 3,
|
||||
},
|
||||
dateLabel: {
|
||||
fontWeight: '500',
|
||||
} as any,
|
||||
dateValue: {
|
||||
fontWeight: '700',
|
||||
} as any,
|
||||
separator: {
|
||||
width: StyleSheet.hairlineWidth,
|
||||
height: 36,
|
||||
marginHorizontal: spacing.m,
|
||||
},
|
||||
|
||||
/* Actions */
|
||||
actionRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.s,
|
||||
alignItems: 'center',
|
||||
},
|
||||
detailsBtn: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
borderWidth: 1,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
detailsText: {
|
||||
fontWeight: '600',
|
||||
} as any,
|
||||
ctaBtn: {
|
||||
flex: 2,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
ctaText: {
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.8,
|
||||
} as any,
|
||||
});
|
||||
|
||||
@@ -1,63 +1,64 @@
|
||||
import React, { memo, useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, StatusBar, Modal } from 'react-native';
|
||||
import { Briefcase, Calendar, FileText, Download, Eye, Layers, ArrowLeft, Clock } from 'lucide-react-native';
|
||||
import { useAuctionItem, useGetAuciton, useGetAucitonDocument } from '../../../Services/BBPS/Hooks';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Modal,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Briefcase,
|
||||
Calendar,
|
||||
FileText,
|
||||
Download,
|
||||
Eye,
|
||||
Layers,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react-native';
|
||||
import { useAuctionItem, useGetAuciton, useGetAucitonDocument } from '../../../Services/ApiCall/Hooks';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { AUCTION_DOCUMENT_DOWNLOAD } from '../../../Services/BBPS/ApiUrls';
|
||||
import { AUCTION_DOCUMENT_DOWNLOAD } from '../../../Services/ApiCall/ApiUrls';
|
||||
import ReactNativeBlobUtil from 'react-native-blob-util';
|
||||
import AuctionItemDetail from './AuctionItemDetail';
|
||||
import { Loader } from '../../../Component/Index';
|
||||
import colors from '../../../Constant/Color';
|
||||
import { useTheme } from '../../../Hooks/useTheme';
|
||||
import AppText from '../../../components/AppText';
|
||||
import { showToast } from '../../../Constant/ShowDailog';
|
||||
import { logger } from '../../../core/observability/logger';
|
||||
import moment from 'moment';
|
||||
|
||||
interface Props { visible: boolean; onClose: () => void; data?: any }
|
||||
|
||||
const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
const inset = useSafeAreaInsets()
|
||||
const { data: auctionData, isLoading } = useGetAuciton({ auctionId: data?.auctionId })
|
||||
const [aucitonDetail, setAuciotnDetail] = useState<any>(auctionData)
|
||||
const [allBoolean, setAllBoolean] = useState({ showItemDetail: false, selectedItem: {}, fileLoading: false });
|
||||
const { data: auctionDocuments } = useGetAucitonDocument({ auctionId: data?.auctionId })
|
||||
const { mutate } = useAuctionItem()
|
||||
const inset = useSafeAreaInsets();
|
||||
const { colors, spacing, shadows } = useTheme();
|
||||
|
||||
const { data: auctionData } = useGetAuciton({ auctionId: data?.auctionId });
|
||||
const [aucitonDetail, setAuciotnDetail] = useState<any>(auctionData);
|
||||
const [allBoolean, setAllBoolean] = useState({
|
||||
showItemDetail: false,
|
||||
selectedItem: {},
|
||||
});
|
||||
// Tracks WHICH document is downloading, so only that row shows a spinner.
|
||||
// A shared boolean lit up every row's spinner at once, making a single
|
||||
// per-document download look like it was downloading all of them.
|
||||
const [downloadingId, setDownloadingId] = useState<number | null>(null);
|
||||
const { data: auctionDocuments } = useGetAucitonDocument({ auctionId: data?.auctionId });
|
||||
const { mutate } = useAuctionItem();
|
||||
|
||||
const formatDate = (date?: string) => {
|
||||
if (!date) return '-';
|
||||
if (!date) return '—';
|
||||
return moment(date, 'DD-MM-YYYY HH:mm:ss').format('DD MMM YYYY, hh:mm A');
|
||||
};
|
||||
|
||||
const isLive = data?.purchaseEnd ? moment().isBefore(moment(data.purchaseEnd, 'DD-MM-YYYY HH:mm:ss')) : false;
|
||||
|
||||
const Section = ({ title, icon: Icon, children }: any) => (
|
||||
<View style={styles.sectionContainer}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Icon size={20} color={colors.primary} />
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
|
||||
const InfoBox = ({ label, value, full = false }: any) => (
|
||||
<View style={[styles.infoBox, full ? { width: '100%' } : { width: '48%' }]}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Text style={styles.value}>{value || '-'}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
const handleDownload = async (id: number, fileName: string) => {
|
||||
if (downloadingId !== null) { return; } // one download at a time
|
||||
try {
|
||||
setAllBoolean((prev: any) => ({ ...prev, fileLoading: true }));
|
||||
setDownloadingId(id);
|
||||
const { dirs } = ReactNativeBlobUtil.fs;
|
||||
|
||||
const path = `${dirs.DownloadDir}/${fileName}.pdf`;
|
||||
|
||||
// console.log('Saving to:', path);
|
||||
|
||||
const downloadUrl = `${AUCTION_DOCUMENT_DOWNLOAD}?documentId=${id}&isAmendment=false`;
|
||||
// console.log('Download URL:', downloadUrl);
|
||||
|
||||
const response = await ReactNativeBlobUtil.config({
|
||||
await ReactNativeBlobUtil.config({
|
||||
fileCache: false,
|
||||
addAndroidDownloads: {
|
||||
useDownloadManager: true,
|
||||
@@ -68,292 +69,341 @@ const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
description: 'Downloading document',
|
||||
mediaScannable: true,
|
||||
},
|
||||
}).fetch('GET', downloadUrl, {
|
||||
Accept: 'application/pdf',
|
||||
});
|
||||
|
||||
const stat = await ReactNativeBlobUtil.fs.stat(path);
|
||||
console.log('File size:', stat.size);
|
||||
|
||||
}).fetch('GET', downloadUrl, { Accept: 'application/pdf' });
|
||||
showToast('Download complete');
|
||||
} catch (error: any) {
|
||||
console.log('Error:', error?.message || error);
|
||||
logger.error('auction', 'document download failed', { error: String(error?.message || error) });
|
||||
showToast('Download failed. Please try again.');
|
||||
} finally {
|
||||
setAllBoolean((prev: any) => ({ ...prev, fileLoading: false }));
|
||||
setDownloadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
mutate({ auctionNumber: data?.auctionNumber, status: data?.auctionPattern }, {
|
||||
onSuccess: (res) => {
|
||||
setAuciotnDetail((prev: any) => ({ ...prev, auctionItems: res.data, auctionDocuments }))
|
||||
}, onError: (err) => {
|
||||
console.log(err, '-----auction item error');
|
||||
}
|
||||
});
|
||||
mutate(
|
||||
{ auctionNumber: data?.auctionNumber, status: data?.auctionPattern },
|
||||
{
|
||||
onSuccess: (res: any) => {
|
||||
setAuciotnDetail((prev: any) => ({ ...prev, auctionItems: res.data, auctionDocuments }));
|
||||
},
|
||||
onError: (err: any) => {
|
||||
console.log(err, '-----auction item error');
|
||||
},
|
||||
},
|
||||
);
|
||||
// Intentionally runs only on auction number/document changes; other deps are stable or recreated each render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data.auctionNumber, auctionDocuments]);
|
||||
|
||||
const isAmended = auctionData?.auctionStatus === 'Amended';
|
||||
const kd = (key: any, amended: any) => isAmended ? amended : key;
|
||||
|
||||
const styles = makeStyles(colors, spacing, shadows);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={false}
|
||||
animationType="none"
|
||||
animationType="slide"
|
||||
statusBarTranslucent
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.safeArea}>
|
||||
<StatusBar barStyle="dark-content" />
|
||||
|
||||
{/* Full Header */}
|
||||
<View style={[styles.header, { paddingTop: inset.top }]}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.backBtn}>
|
||||
<ArrowLeft size={24} color="#1e293b" />
|
||||
<View style={styles.root}>
|
||||
{/* ── Header ── */}
|
||||
<View style={[styles.header, { paddingTop: inset.top + 6 }]}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.backBtn} activeOpacity={0.7}>
|
||||
<ArrowLeft size={22} color={colors.text} strokeWidth={2.5} />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.headerTitleContainer}>
|
||||
<Text style={styles.headerTitleText}>Auction Overview</Text>
|
||||
<Text style={styles.headerIdText}>Ref: {data?.auctionNumber || 'N/A'}</Text>
|
||||
</View>
|
||||
<View style={styles.liveIndicator}>
|
||||
<View style={[styles.dot, { backgroundColor: isLive ? '#22c55e' : '#94a3b8' }]} />
|
||||
<Text style={styles.liveText}>{isLive ? 'LIVE' : 'CLOSED'}</Text>
|
||||
|
||||
<View style={styles.headerCenter}>
|
||||
<AppText variant="h4" color={colors.text} style={styles.headerTitle as any}>
|
||||
Auction Overview
|
||||
</AppText>
|
||||
<AppText variant="caption" color={colors.textSecondary}>
|
||||
Ref: {data?.auctionNumber ?? 'N/A'}
|
||||
</AppText>
|
||||
</View>
|
||||
|
||||
</View>
|
||||
|
||||
{/* Scrollable Body */}
|
||||
{/* ── Body ── */}
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={true}
|
||||
style={{ backgroundColor: colors.background }}
|
||||
>
|
||||
{/* 1. Basic Info Grid */}
|
||||
<Section title="Details" icon={Layers}>
|
||||
<View style={styles.grid}>
|
||||
<InfoBox label="Auction No." value={data?.auctionNumber} />
|
||||
<InfoBox label="Pattern" value={data?.auctionPattern || 'Forward'} />
|
||||
<InfoBox label="Mode" value={data?.auctionMode || 'Open'} />
|
||||
<InfoBox label="Category" value={data?.categoryOfAuction || 'General'} />
|
||||
<InfoBox label="EMD Type" value={data?.emd || 'Online'} />
|
||||
<InfoBox label="EMD Figure" value={data?.emdFigure || 'Item Wise'} />
|
||||
</View>
|
||||
</Section>
|
||||
|
||||
{/* 2. Work Description */}
|
||||
<Section title="Auction Details" icon={Briefcase}>
|
||||
<View style={styles.grid}>
|
||||
<InfoBox label="Auction Name" value={auctionData?.auctionOfWork} />
|
||||
<InfoBox label="Category Of Auction " value={auctionData?.auctionCategories} />
|
||||
<InfoBox label="Auction Fees (In Figure) " value={auctionData?.auctionFees} />
|
||||
<InfoBox label="Auction Fees (In Words)" value={auctionData?.auctionFeesInWord} />
|
||||
<InfoBox label="Auction Keyword" value={auctionData?.auctionKeywordName} />
|
||||
</View>
|
||||
</Section>
|
||||
{/* BASIC DETAILS */}
|
||||
<SectionCard title="Basic Details" icon={Layers} colors={colors} spacing={spacing} shadows={shadows}>
|
||||
<InfoRow label="Auction No." value={data?.auctionNumber} colors={colors} />
|
||||
<InfoRow label="Pattern" value={data?.auctionPattern || 'Forward'} colors={colors} />
|
||||
<InfoRow label="Mode" value={data?.auctionMode || 'Open'} colors={colors} />
|
||||
<InfoRow label="Category" value={data?.categoryOfAuction || 'General'} colors={colors} />
|
||||
<InfoRow label="EMD Type" value={data?.emd || 'Online'} colors={colors} last />
|
||||
</SectionCard>
|
||||
|
||||
{/* 3. Timeline Section */}
|
||||
<Section title="Key Dates" icon={Calendar}>
|
||||
<View style={styles.timelineContainer}>
|
||||
<View style={styles.timelineRow}>
|
||||
<View style={styles.iconCircle}><Clock size={16} color="#64748b" /></View>
|
||||
<View>
|
||||
<Text style={styles.label}>Auction Purchase (Start)</Text>
|
||||
<Text style={styles.value}>{formatDate(data?.auctionDispatchDate)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.timelineConnector} />
|
||||
<View style={styles.timelineRow}>
|
||||
<View style={[styles.iconCircle, { backgroundColor: '#fee2e2' }]}><Clock size={16} color="#ef4444" /></View>
|
||||
<View>
|
||||
<Text style={styles.label}>Purchase Deadline (End)</Text>
|
||||
<Text style={styles.value}>{formatDate(data?.purchaseEnd)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Section>
|
||||
{/* AUCTION DETAILS */}
|
||||
<SectionCard title="Auction Details" icon={Briefcase} colors={colors} spacing={spacing} shadows={shadows}>
|
||||
<InfoRow label="Name" value={auctionData?.auctionOfWork} colors={colors} multiline />
|
||||
<InfoRow label="Category" value={auctionData?.auctionCategories} colors={colors} />
|
||||
<InfoRow label="Fees" value={auctionData?.auctionFees ? `₹ ${auctionData.auctionFees}` : '—'} colors={colors} valueColor={colors.primary} />
|
||||
<InfoRow label="Fees (Words)" value={auctionData?.auctionFeesInWord} colors={colors} multiline />
|
||||
<InfoRow label="Keyword" value={auctionData?.auctionKeywordName} colors={colors} last />
|
||||
</SectionCard>
|
||||
|
||||
{/* 4. Item Table Section */}
|
||||
<Section title="Auction Items" icon={FileText}>
|
||||
<View style={styles.tableWrapper}>
|
||||
<View style={styles.tableHead}>
|
||||
<Text style={[styles.tableLabel, { flex: 2 }]}>Item</Text>
|
||||
<Text style={styles.tableLabel}>Time</Text>
|
||||
<Text style={[styles.tableLabel, { textAlign: 'right' }]}>View</Text>
|
||||
</View>
|
||||
{/* Dummy Item Row */}
|
||||
{aucitonDetail?.auctionItems?.map((item: any, index: number) => (
|
||||
<View style={styles.tableRow}>
|
||||
<View style={{ flex: 2 }}>
|
||||
<Text style={styles.itemMain}>{item.productName}</Text>
|
||||
<Text style={styles.itemSub}>Category: {item?.categoryName}</Text>
|
||||
{/* KEY DATES */}
|
||||
<SectionCard title="Key Dates" icon={Calendar} colors={colors} spacing={spacing} shadows={shadows}>
|
||||
<InfoRow label="Purchase Start" value={formatDate(kd(auctionData?.keyDates?.auctionPurchase?.startDateTime, auctionData?.amendedkeyDates?.auctionPurchase?.startDateTime))} colors={colors} />
|
||||
<InfoRow label="Purchase End" value={formatDate(kd(auctionData?.keyDates?.auctionPurchase?.endDateTime, auctionData?.amendedkeyDates?.auctionPurchase?.endDateTime))} colors={colors} valueColor={colors.error} />
|
||||
<InfoRow label="Bidding Start" value={formatDate(kd(auctionData?.keyDates?.auctionBidding?.startDateTime, auctionData?.amendedkeyDates?.auctionBidding?.startDateTime))} colors={colors} />
|
||||
<InfoRow label="Bidding End" value={formatDate(kd(auctionData?.keyDates?.auctionBidding?.endDateTime, auctionData?.amendedkeyDates?.auctionBidding?.endDateTime))} colors={colors} valueColor={colors.error} last />
|
||||
</SectionCard>
|
||||
|
||||
{/* AUCTION ITEMS */}
|
||||
{aucitonDetail?.auctionItems?.length > 0 && (
|
||||
<SectionCard title="Auction Items" icon={FileText} colors={colors} spacing={spacing} shadows={shadows}>
|
||||
{aucitonDetail.auctionItems.map((item: any, index: number) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.itemRow,
|
||||
{ borderBottomColor: colors.border },
|
||||
index === aucitonDetail.auctionItems.length - 1 && { borderBottomWidth: 0 },
|
||||
]}
|
||||
>
|
||||
<View style={styles.itemLeft}>
|
||||
<AppText variant="bodySmall" color={colors.text} style={styles.itemName as any}>
|
||||
{item.productName}
|
||||
</AppText>
|
||||
<AppText variant="caption" color={colors.textSecondary}>
|
||||
{item?.categoryName} · {item.timeDurationType}
|
||||
</AppText>
|
||||
</View>
|
||||
<Text style={styles.itemQty}>{item.timeDurationType}</Text>
|
||||
<TouchableOpacity style={styles.actionIcon} onPress={() => { setAllBoolean((prev: any) => ({ ...prev, showItemDetail: true, selectedItem: item })) }}>
|
||||
<Eye size={18} color={colors.primary} />
|
||||
<TouchableOpacity
|
||||
style={[styles.eyeBtn, { backgroundColor: `${colors.primary}15` }]}
|
||||
onPress={() => setAllBoolean(prev => ({ ...prev, showItemDetail: true, selectedItem: item }))}
|
||||
>
|
||||
<Eye size={16} color={colors.primary} strokeWidth={2} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</Section>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* 5. Downloads */}
|
||||
<Section title="Downloads" icon={Download}>
|
||||
{aucitonDetail?.auctionDocuments?.map((doc: any, index: number) => (
|
||||
<TouchableOpacity style={styles.fileCard} onPress={() => {
|
||||
handleDownload(
|
||||
doc.auctionDocumentId,
|
||||
doc.fileName.replace('.pdf', '')
|
||||
)
|
||||
}}>
|
||||
<View style={styles.fileIcon}><FileText size={20} color="#ef4444" /></View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.fileName}>{doc.fileName}</Text>
|
||||
</View>
|
||||
{allBoolean.fileLoading ? (
|
||||
<View >
|
||||
<Loader size='small' color='#94a3b8' />
|
||||
{/* DOCUMENTS */}
|
||||
{aucitonDetail?.auctionDocuments?.length > 0 && (
|
||||
<SectionCard title="Documents" icon={Download} colors={colors} spacing={spacing} shadows={shadows}>
|
||||
{aucitonDetail.auctionDocuments.map((doc: any, index: number) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.docRow,
|
||||
{ borderBottomColor: colors.border },
|
||||
index === aucitonDetail.auctionDocuments.length - 1 && { borderBottomWidth: 0 },
|
||||
]}
|
||||
activeOpacity={0.7}
|
||||
disabled={downloadingId !== null}
|
||||
onPress={() => handleDownload(doc.auctionDocumentId, doc.fileName.replace('.pdf', ''))}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Download ${doc.fileName}`}
|
||||
>
|
||||
<View style={[styles.docIcon, { backgroundColor: `${colors.error}15` }]}>
|
||||
<FileText size={16} color={colors.error} strokeWidth={2} />
|
||||
</View>
|
||||
) : (
|
||||
<Download size={20} color="#94a3b8" />
|
||||
)}
|
||||
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</Section>
|
||||
<AppText variant="bodySmall" color={colors.text} style={styles.docName as any} numberOfLines={1}>
|
||||
{doc.fileName}
|
||||
</AppText>
|
||||
{downloadingId === doc.auctionDocumentId
|
||||
? <ActivityIndicator size="small" color={colors.primary} />
|
||||
: <Download size={18} color={colors.textSecondary} strokeWidth={2} />
|
||||
}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
</ScrollView>
|
||||
|
||||
{allBoolean.showItemDetail && <AuctionItemDetail data={allBoolean.selectedItem} onClose={() => { setAllBoolean((prev: any) => ({ ...prev, showItemDetail: false })) }} visible={allBoolean.showItemDetail} />}
|
||||
{allBoolean.showItemDetail && (
|
||||
<AuctionItemDetail
|
||||
data={allBoolean.selectedItem}
|
||||
onClose={() => setAllBoolean(prev => ({ ...prev, showItemDetail: false }))}
|
||||
visible={allBoolean.showItemDetail}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Modal >
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
fullModal: {
|
||||
margin: 0,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f1f5f9',
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
backBtn: {
|
||||
padding: 8,
|
||||
marginRight: 8,
|
||||
},
|
||||
headerTitleContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
headerTitleText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#0f172a',
|
||||
},
|
||||
headerIdText: {
|
||||
fontSize: 13,
|
||||
color: '#64748b',
|
||||
},
|
||||
liveIndicator: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f8fafc',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 20,
|
||||
gap: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
dot: { width: 8, height: 8, borderRadius: 4 },
|
||||
liveText: { fontSize: 11, fontWeight: '800', color: '#475569' },
|
||||
export default memo(AuctionFullDetail);
|
||||
|
||||
scrollView: { flex: 1, backgroundColor: '#f8fafc' },
|
||||
scrollContent: { padding: 16, paddingBottom: 30 },
|
||||
/* ─── Sub-components ─── */
|
||||
|
||||
sectionContainer: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#eef2f6',
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
marginBottom: 20,
|
||||
paddingBottom: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f8fafc',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
color: '#1e293b',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
grid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
infoBox: { marginBottom: 16 },
|
||||
label: { fontSize: 12, color: '#94a3b8', fontWeight: '600', marginBottom: 4 },
|
||||
value: { fontSize: 15, fontWeight: '700', color: '#334155' },
|
||||
spacer: { height: 12 },
|
||||
const SectionCard = ({ title, icon: Icon, children, colors, spacing, shadows }: any) => {
|
||||
const styles = makeStyles(colors, spacing, shadows);
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Icon size={18} color={colors.primary} strokeWidth={2} />
|
||||
<AppText variant="caption" color={colors.text} style={styles.cardTitle as any}>
|
||||
{title.toUpperCase()}
|
||||
</AppText>
|
||||
</View>
|
||||
<View style={[styles.cardDivider, { backgroundColor: colors.border }]} />
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
timelineContainer: { paddingLeft: 8 },
|
||||
timelineRow: { flexDirection: 'row', alignItems: 'center', gap: 15 },
|
||||
iconCircle: { width: 34, height: 34, borderRadius: 10, backgroundColor: '#f1f5f9', alignItems: 'center', justifyContent: 'center' },
|
||||
timelineConnector: { width: 2, height: 20, backgroundColor: '#f1f5f9', marginLeft: 16 },
|
||||
const InfoRow = ({ label, value, colors, last = false, multiline = false, valueColor }: any) => (
|
||||
<View style={[
|
||||
{
|
||||
flexDirection: 'row',
|
||||
alignItems: multiline ? 'flex-start' : 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 13,
|
||||
borderBottomWidth: last ? 0 : StyleSheet.hairlineWidth,
|
||||
borderBottomColor: 'rgba(0,0,0,0.06)',
|
||||
gap: 12,
|
||||
},
|
||||
]}>
|
||||
<AppText variant="bodySmall" color={colors.textSecondary} style={{ flex: 1 } as any}>
|
||||
{label}
|
||||
</AppText>
|
||||
<AppText
|
||||
variant="bodySmall"
|
||||
color={valueColor || colors.text}
|
||||
style={{ flex: 1, textAlign: 'right', fontWeight: '600' } as any}
|
||||
numberOfLines={multiline ? 4 : 1}
|
||||
>
|
||||
{value || '—'}
|
||||
</AppText>
|
||||
</View>
|
||||
);
|
||||
|
||||
tableWrapper: { backgroundColor: '#f8fafc', borderRadius: 12, padding: 12 },
|
||||
tableHead: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#e2e8f0', paddingBottom: 8, marginBottom: 12 },
|
||||
tableLabel: { fontSize: 11, fontWeight: '800', color: '#94a3b8', flex: 1 },
|
||||
tableRow: { flexDirection: 'row', alignItems: 'center' },
|
||||
itemMain: { fontSize: 14, fontWeight: '700', color: '#1e293b' },
|
||||
itemSub: { fontSize: 12, color: '#64748b' },
|
||||
itemQty: { flex: 1, fontSize: 14, fontWeight: '700', color: '#334155' },
|
||||
actionIcon: { padding: 6 },
|
||||
/* ─── Styles factory ─── */
|
||||
const makeStyles = (colors: any, spacing: any, shadows: any) =>
|
||||
StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
|
||||
fileCard: { flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: '#f8fafc', padding: 12, borderRadius: 12 },
|
||||
fileIcon: { width: 40, height: 40, borderRadius: 8, backgroundColor: '#fee2e2', alignItems: 'center', justifyContent: 'center' },
|
||||
fileName: { fontSize: 14, fontWeight: '700', color: '#334155' },
|
||||
fileSize: { fontSize: 12, color: '#94a3b8' },
|
||||
/* Header */
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.m,
|
||||
paddingBottom: spacing.m,
|
||||
backgroundColor: colors.surface,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.border,
|
||||
gap: spacing.s,
|
||||
},
|
||||
backBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerCenter: {
|
||||
flex: 1,
|
||||
},
|
||||
headerTitle: {
|
||||
fontWeight: '800',
|
||||
} as any,
|
||||
statusChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.s + 4,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1.2,
|
||||
gap: 5,
|
||||
},
|
||||
statusDot: {
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 4,
|
||||
},
|
||||
statusText: {
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.5,
|
||||
} as any,
|
||||
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: '#fff',
|
||||
padding: 20,
|
||||
paddingBottom: Platform.OS === 'ios' ? 34 : 20,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#f1f5f9',
|
||||
},
|
||||
mainBtn: {
|
||||
backgroundColor: colors.primary,
|
||||
height: 56,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: colors.primary,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
btnDisabled: { backgroundColor: '#cbd5e1' },
|
||||
mainBtnText: { color: '#fff', fontSize: 16, fontWeight: '800' },
|
||||
});
|
||||
scrollContent: {
|
||||
padding: spacing.m,
|
||||
paddingBottom: 50,
|
||||
gap: spacing.m,
|
||||
},
|
||||
|
||||
export default memo(AuctionFullDetail);
|
||||
/* Card */
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 18,
|
||||
paddingHorizontal: spacing.m,
|
||||
paddingTop: spacing.m,
|
||||
paddingBottom: spacing.s,
|
||||
marginBottom: spacing.m,
|
||||
...shadows.light,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.s,
|
||||
marginBottom: spacing.s + 2,
|
||||
},
|
||||
cardTitle: {
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1.2,
|
||||
} as any,
|
||||
cardDivider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
marginBottom: spacing.s,
|
||||
},
|
||||
|
||||
/* Items */
|
||||
itemRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.s + 2,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
gap: spacing.s,
|
||||
},
|
||||
itemLeft: {
|
||||
flex: 1,
|
||||
},
|
||||
itemName: {
|
||||
fontWeight: '700',
|
||||
marginBottom: 2,
|
||||
} as any,
|
||||
eyeBtn: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 9,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
/* Docs */
|
||||
docRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.s + 2,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
gap: spacing.s,
|
||||
},
|
||||
docIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 9,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
docName: {
|
||||
flex: 1,
|
||||
fontWeight: '600',
|
||||
} as any,
|
||||
});
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
import React, { memo, useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, Modal } from 'react-native';
|
||||
import React, { memo, useState, useMemo } from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, ScrollView, Platform, Modal } from 'react-native';
|
||||
import { Package, IndianRupee, FileText, Download, ArrowLeft, Info, } from 'lucide-react-native';
|
||||
import colors from '../../../Constant/Color';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useAucitonItemDetail } from '../../../Services/BBPS/Hooks';
|
||||
import { useAucitonItemDetail } from '../../../Services/ApiCall/Hooks';
|
||||
import ReactNativeBlobUtil from 'react-native-blob-util';
|
||||
import { AUCTION_ITEM_DOCUMENT_DOWNLOAD } from '../../../Services/BBPS/ApiUrls';
|
||||
import { Loader } from '../../../Component/Index';
|
||||
import { AUCTION_ITEM_DOCUMENT_DOWNLOAD } from '../../../Services/ApiCall/ApiUrls';
|
||||
import { Loader } from '../../../components';
|
||||
import { useTheme } from '../../../Hooks/useTheme';
|
||||
import AppText from '../../../components/AppText';
|
||||
|
||||
interface Props { visible: boolean; onClose: () => void; data?: any; }
|
||||
|
||||
const Section = ({ title, icon: Icon, children }: any) => (
|
||||
const Section = ({ title, icon: Icon, children, styles, colors }: any) => (
|
||||
<View style={styles.sectionCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<View style={styles.iconCircle}>
|
||||
<Icon size={18} color={colors.primary} strokeWidth={2.5} />
|
||||
</View>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
<AppText variant="caption" bold style={styles.sectionTitle}>{title}</AppText>
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
|
||||
const PriceTile = ({ label, fig, words, full = false }: any) => (
|
||||
const PriceTile = ({ label, fig, words, full = false, styles }: any) => (
|
||||
<View style={[styles.priceTile, full ? { width: '100%' } : { width: '48%' }]}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Text style={styles.priceFig}>₹{Number(fig).toLocaleString('en-IN')}</Text>
|
||||
<Text style={styles.priceWords}>{words}</Text>
|
||||
<AppText variant="caption" color={styles.label.color} bold style={styles.label}>{label}</AppText>
|
||||
<AppText variant="h3" bold style={styles.priceFig}>₹{Number(fig).toLocaleString('en-IN')}</AppText>
|
||||
<AppText variant="caption" style={styles.priceWords}>{words}</AppText>
|
||||
</View>
|
||||
);
|
||||
|
||||
const AuctionItemDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
const inset = useSafeAreaInsets()
|
||||
const { colors, typography } = useTheme();
|
||||
const styles = useMemo(() => createStyles(colors, typography), [colors, typography]);
|
||||
|
||||
const [allBoolean, setAllBoolean] = useState({ fileLoading: false });
|
||||
const { data: itemData } = useAucitonItemDetail({ auctionItemId: data.id });
|
||||
const { data: itemData } = useAucitonItemDetail({ auctionItemId: data?.id });
|
||||
|
||||
const handleDownload = async (id: number, fileName: string) => {
|
||||
try {
|
||||
@@ -41,12 +46,9 @@ const AuctionItemDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
|
||||
const path = `${dirs.DownloadDir}/${fileName}.pdf`;
|
||||
|
||||
// console.log('Saving to:', path);
|
||||
|
||||
const downloadUrl = `${AUCTION_ITEM_DOCUMENT_DOWNLOAD}?auctionItemId=${id}`;
|
||||
// console.log('Download URL:', downloadUrl);
|
||||
|
||||
const response = await ReactNativeBlobUtil.config({
|
||||
await ReactNativeBlobUtil.config({
|
||||
fileCache: false,
|
||||
addAndroidDownloads: {
|
||||
useDownloadManager: true,
|
||||
@@ -61,8 +63,7 @@ const AuctionItemDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
Accept: 'application/pdf',
|
||||
});
|
||||
|
||||
const stat = await ReactNativeBlobUtil.fs.stat(path);
|
||||
console.log(' File size:', stat.size);
|
||||
await ReactNativeBlobUtil.fs.stat(path);
|
||||
|
||||
} catch (error: any) {
|
||||
console.log('Error:', error?.message || error);
|
||||
@@ -84,11 +85,11 @@ const AuctionItemDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
{/* Navbar */}
|
||||
<View style={[styles.header, { paddingTop: inset.top }]}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.backBtn}>
|
||||
<ArrowLeft size={24} color="#1e293b" />
|
||||
<ArrowLeft size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<View>
|
||||
<Text style={styles.headerTitle}>Item Information</Text>
|
||||
<Text style={styles.headerSub}>Detail View</Text>
|
||||
<AppText variant="body" bold style={styles.headerTitle}>Item Information</AppText>
|
||||
<AppText variant="caption" style={styles.headerSub}>Detail View</AppText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -97,69 +98,66 @@ const AuctionItemDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Section title="Product Specification" icon={Package}>
|
||||
<Section title="Product Specification" icon={Package} styles={styles} colors={colors}>
|
||||
<View style={styles.identityHeader}>
|
||||
<Text style={styles.productNameText}>{data.productName.toUpperCase()}</Text>
|
||||
<AppText variant="h4" bold style={styles.productNameText}>{data?.productName?.toUpperCase()}</AppText>
|
||||
<View style={styles.categoryBadge}>
|
||||
<Text style={styles.categoryText}>{data.categoryName}</Text>
|
||||
<AppText variant="caption" bold style={styles.categoryText}>{data?.categoryName}</AppText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.specGrid}>
|
||||
<View style={styles.specItem}>
|
||||
<Text style={styles.label}>Sub Category</Text>
|
||||
<Text style={styles.specValue}>{data.subCategoryName}</Text>
|
||||
<AppText variant="caption" bold style={styles.label}>Sub Category</AppText>
|
||||
<AppText variant="bodySmall" bold style={styles.specValue}>{data?.subCategoryName}</AppText>
|
||||
</View>
|
||||
<View style={styles.specItem}>
|
||||
<Text style={styles.label}>Duration</Text>
|
||||
<Text style={styles.specValue}>{data.timeDurationType}</Text>
|
||||
<AppText variant="caption" bold style={styles.label}>Duration</AppText>
|
||||
<AppText variant="bodySmall" bold style={styles.specValue}>{data?.timeDurationType}</AppText>
|
||||
</View>
|
||||
</View>
|
||||
</Section>
|
||||
|
||||
<Section title="Reserve & Bid Values" icon={IndianRupee}>
|
||||
<Section title="Reserve & Bid Values" icon={IndianRupee} styles={styles} colors={colors}>
|
||||
<PriceTile
|
||||
label="Bidding Price"
|
||||
fig={data.reservePrice || data.emdAmount}
|
||||
words={data.reservePriceInWords || data.emdAmountInWords}
|
||||
fig={data?.reservePrice || data?.emdAmount}
|
||||
words={data?.reservePriceInWords || data?.emdAmountInWords}
|
||||
full
|
||||
styles={styles}
|
||||
/>
|
||||
<View style={styles.hDivider} />
|
||||
<PriceTile
|
||||
label="Auction Start Value"
|
||||
fig={data.reservePrice || data.auctionStartValueFigure}
|
||||
words={data.reservePriceInWords || data.auctionStartValueWords}
|
||||
fig={data?.reservePrice || data?.auctionStartValueFigure}
|
||||
words={data?.reservePriceInWords || data?.auctionStartValueWords}
|
||||
full
|
||||
styles={styles}
|
||||
/>
|
||||
<View style={styles.hDivider} />
|
||||
{/* <View style={styles.row}>
|
||||
<PriceTile label="Bid Variation" fig={data.bidVariationFig} words={data.bidVariationWords} />
|
||||
<PriceTile label="Auction Start Value" fig={data.auctionStartValue} words={data.auctionStartValueInWords
|
||||
} />
|
||||
</View> */}
|
||||
</Section>
|
||||
|
||||
<Section title="Brief Description" icon={Info}>
|
||||
<Text style={styles.descContent}>{data.briefDescription}</Text>
|
||||
<Section title="Brief Description" icon={Info} styles={styles} colors={colors}>
|
||||
<AppText variant="bodySmall" style={styles.descContent}>{data?.briefDescription}</AppText>
|
||||
</Section>
|
||||
|
||||
{itemData?.length && <Section title="View Documents" icon={FileText}>
|
||||
{itemData?.length > 0 && <Section title="View Documents" icon={FileText} styles={styles} colors={colors}>
|
||||
<View style={styles.docTableHead}>
|
||||
<Text style={styles.tableLabel}>Sr No.</Text>
|
||||
<Text style={[styles.tableLabel, { flex: 2 }]}>Document Name</Text>
|
||||
<Text style={styles.tableLabel}>Action</Text>
|
||||
<AppText variant="caption" bold style={styles.tableLabel}>Sr No.</AppText>
|
||||
<AppText variant="caption" bold style={[styles.tableLabel, { flex: 2 }]}>Document Name</AppText>
|
||||
<AppText variant="caption" bold style={styles.tableLabel}>Action</AppText>
|
||||
</View>
|
||||
|
||||
{itemData?.map((doc: any, index: any) => (
|
||||
<TouchableOpacity style={styles.docRow} activeOpacity={0.7} onPress={() => { handleDownload(doc.assetId, doc.fileName) }} key={doc.assetId}>
|
||||
<Text style={styles.docIndex}>{index + 1}</Text>
|
||||
<AppText variant="bodySmall" bold style={styles.docIndex}>{index + 1}</AppText>
|
||||
<View style={{ flex: 2 }}>
|
||||
<Text style={styles.docTitle} numberOfLines={1}>{doc.fileName}</Text>
|
||||
<AppText variant="bodySmall" bold style={styles.docTitle} numberOfLines={1}>{doc.fileName}</AppText>
|
||||
</View>
|
||||
<View style={styles.downloadIconBox}>
|
||||
{allBoolean.fileLoading ? (
|
||||
<View >
|
||||
<Loader size='small' color='#94a3b8' />
|
||||
<Loader size='small' color={colors.textSecondary} />
|
||||
</View>
|
||||
) : (
|
||||
<Download size={20} color={colors.primary} />
|
||||
@@ -174,26 +172,26 @@ const AuctionItemDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
fullModal: { margin: 0, backgroundColor: '#f8fafc' },
|
||||
safeArea: { flex: 1 },
|
||||
const createStyles = (colors: any, typography: any) => StyleSheet.create({
|
||||
fullModal: { margin: 0, backgroundColor: colors.background },
|
||||
safeArea: { flex: 1, backgroundColor: colors.background },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
backgroundColor: '#fff',
|
||||
backgroundColor: colors.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f1f5f9',
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
backBtn: { padding: 8, marginRight: 8 },
|
||||
headerTitle: { fontSize: 18, fontWeight: '800', color: '#0f172a' },
|
||||
headerSub: { fontSize: 12, color: '#94a3b8', fontWeight: '600', textTransform: 'uppercase' },
|
||||
headerTitle: { fontSize: 18, color: colors.text },
|
||||
headerSub: { fontSize: 12, color: colors.textSecondary, textTransform: 'uppercase' },
|
||||
|
||||
scrollView: { flex: 1 },
|
||||
scrollContent: { padding: 16, paddingBottom: 50 },
|
||||
|
||||
sectionCard: {
|
||||
backgroundColor: '#fff',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 24,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
@@ -218,55 +216,54 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
color: '#334155',
|
||||
color: colors.text,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
// Product Identity
|
||||
identityHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 15 },
|
||||
productNameText: { fontSize: 20, fontWeight: '900', color: '#1e293b', flex: 1 },
|
||||
categoryBadge: { backgroundColor: '#f1f5f9', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20 },
|
||||
categoryText: { fontSize: 11, fontWeight: '800', color: '#64748b', textTransform: 'uppercase' },
|
||||
productNameText: { fontSize: 20, color: colors.text, flex: 1 },
|
||||
categoryBadge: { backgroundColor: colors.border, paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20 },
|
||||
categoryText: { fontSize: 11, color: colors.textSecondary, textTransform: 'uppercase' },
|
||||
specGrid: { flexDirection: 'row', gap: 24, marginTop: 5 },
|
||||
specItem: { flex: 1 },
|
||||
specValue: { fontSize: 15, fontWeight: '700', color: '#334155', marginTop: 2 },
|
||||
specValue: { fontSize: 15, color: colors.text, marginTop: 2 },
|
||||
|
||||
// Pricing
|
||||
priceTile: { marginBottom: 16 },
|
||||
label: { fontSize: 11, color: '#94a3b8', fontWeight: '700', textTransform: 'uppercase', marginBottom: 4 },
|
||||
priceFig: { fontSize: 22, fontWeight: '900', color: colors.black },
|
||||
priceWords: { fontSize: 13, color: '#64748b', fontStyle: 'italic', marginTop: 2 },
|
||||
hDivider: { height: 1, backgroundColor: '#f1f5f9', marginBottom: 16 },
|
||||
label: { fontSize: 11, color: colors.textSecondary, textTransform: 'uppercase', marginBottom: 4 },
|
||||
priceFig: { fontSize: 22, color: colors.text },
|
||||
priceWords: { fontSize: 13, color: colors.textSecondary, fontStyle: 'italic', marginTop: 2 },
|
||||
hDivider: { height: 1, backgroundColor: colors.border, marginBottom: 16 },
|
||||
row: { flexDirection: 'row', justifyContent: 'space-between' },
|
||||
|
||||
// Description
|
||||
descContent: { fontSize: 15, color: '#475569', lineHeight: 22, fontWeight: '500' },
|
||||
descContent: { fontSize: 15, color: colors.textSecondary, lineHeight: 22, fontFamily: typography.fonts.medium },
|
||||
|
||||
// Table
|
||||
docTableHead: { flexDirection: 'row', paddingBottom: 10, borderBottomWidth: 1, borderBottomColor: '#f1f5f9', marginBottom: 10 },
|
||||
tableLabel: { fontSize: 11, fontWeight: '800', color: '#94a3b8', flex: 1 },
|
||||
docRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#f8fafc', padding: 12, borderRadius: 12 },
|
||||
docIndex: { flex: 1, fontSize: 14, fontWeight: '700', color: '#64748b' },
|
||||
docTitle: { fontSize: 14, fontWeight: '700', color: '#1e293b' },
|
||||
downloadIconBox: { backgroundColor: '#fff', padding: 8, borderRadius: 10, borderWidth: 1, borderColor: '#e2e8f0' },
|
||||
docTableHead: { flexDirection: 'row', paddingBottom: 10, borderBottomWidth: 1, borderBottomColor: colors.border, marginBottom: 10 },
|
||||
tableLabel: { fontSize: 11, color: colors.textSecondary, flex: 1 },
|
||||
docRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.background, padding: 12, borderRadius: 12 },
|
||||
docIndex: { flex: 1, fontSize: 14, color: colors.textSecondary },
|
||||
docTitle: { fontSize: 14, color: colors.text },
|
||||
downloadIconBox: { backgroundColor: colors.surface, padding: 8, borderRadius: 10, borderWidth: 1, borderColor: colors.border },
|
||||
|
||||
// Footer
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 0, width: '100%',
|
||||
backgroundColor: '#fff',
|
||||
backgroundColor: colors.surface,
|
||||
padding: 20,
|
||||
paddingBottom: Platform.OS === 'ios' ? 34 : 20,
|
||||
borderTopWidth: 1, borderTopColor: '#f1f5f9'
|
||||
borderTopWidth: 1, borderTopColor: colors.border
|
||||
},
|
||||
primaryBtn: {
|
||||
backgroundColor: colors.primary,
|
||||
height: 56, borderRadius: 18,
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10
|
||||
},
|
||||
primaryBtnText: { color: '#fff', fontSize: 16, fontWeight: '800' }
|
||||
primaryBtnText: { color: colors.buttonText, fontSize: 16, fontFamily: typography.fonts.bold }
|
||||
});
|
||||
|
||||
export default memo(AuctionItemDetail);
|
||||
@@ -25,10 +25,8 @@ const FilterModal: React.FC<Props> = ({ visible, onClose, setFilter, filter }) =
|
||||
|
||||
if (pickerType === 'FROM') {
|
||||
setFromDate(date);
|
||||
console.log('FROM date set');
|
||||
} else {
|
||||
setToDate(date);
|
||||
console.log('TO date set');
|
||||
}
|
||||
|
||||
setOpenPicker(false);
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import React, { useEffect, useState, useCallback, memo, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, FlatList, RefreshControl, ActivityIndicator } from 'react-native';
|
||||
import LiveAuctionTile from './LiveAuctionTile';
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { View, FlatList, RefreshControl, ActivityIndicator } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import moment from 'moment';
|
||||
import { useFetchLiveAuctionPage, useFetchPurchaseAuction } from '../../Services/BBPS/Hooks';
|
||||
import { useFetchLiveAuctionPage } from '../../Services/ApiCall/Hooks';
|
||||
import PurchaseAuctionTile from './PurchaseAuctionTile';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { setAuction } from '../../Redux/Slices/SelectedAuction';
|
||||
import { SearchInput } from '../../Component/Serach/SeacrhInput';
|
||||
import { Skelton } from '../../Component/Index';
|
||||
import { SearchInput } from '../../components/SearchInput';
|
||||
import { Skeleton, EmptyState } from '../../components';
|
||||
import { allBooleanProps } from './types/types';
|
||||
import FilterModal from './Modal/FilterModal';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
@@ -20,14 +19,17 @@ const PurchaseAuction = () => {
|
||||
const Dispatch = useDispatch()
|
||||
const onEndReachedCalledDuringMomentum = useRef(true);
|
||||
const [filter, setFilter] = useState({})
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isRefetching, refetch, isPending }: any = useFetchLiveAuctionPage(filter);
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isRefetching, refetch, isPending }: any = useFetchLiveAuctionPage(filter);
|
||||
const [allBoolean, setAllBoolean] = useState<allBooleanProps>({ isShowFilter: false })
|
||||
const format = 'DD-MM-YYYY HH:mm:ss';
|
||||
|
||||
function isAuctionActive(item: any): boolean {
|
||||
const now = moment();
|
||||
const start = moment(item?.keyDates?.auctionBidding?.startDateTime, format);
|
||||
const end = moment(item?.keyDates?.auctionBidding?.endDateTime, format);
|
||||
const bidding = item?.auctionStatus === 'Amended'
|
||||
? item?.amendedkeyDates?.auctionBidding
|
||||
: item?.keyDates?.auctionBidding;
|
||||
const start = moment(bidding?.startDateTime, format);
|
||||
const end = moment(bidding?.endDateTime, format);
|
||||
|
||||
if (!start.isValid() || !end.isValid()) return false;
|
||||
|
||||
@@ -36,7 +38,7 @@ const PurchaseAuction = () => {
|
||||
|
||||
function isAuctionEnd(item: any): boolean {
|
||||
const now = moment();
|
||||
const end = moment(item?.keyDates?.auctionBidding?.endDateTime, format);
|
||||
const end = moment(item.auctionStatus === "Amended" ? item?.amendedkeyDates?.auctionBidding?.endDateTime : item?.keyDates?.auctionBidding?.endDateTime, format);
|
||||
|
||||
if (!end.isValid()) return false;
|
||||
|
||||
@@ -64,12 +66,12 @@ const PurchaseAuction = () => {
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<SearchInput onChange={(val) => { setFilter(prev => ({ ...prev, auctionNumber: val })) }} placeholder='Search....' filterPress={() => { setAllBoolean((prev: any) => ({ ...prev, isShowFilter: !prev.isShowFilter })) }} />
|
||||
{isPending ? <Skelton />
|
||||
<SearchInput onChange={(val) => { setFilter(prev => ({ ...prev, auctionNumber: val })) }} placeholder='Search....' filterPress={() => { setAllBoolean((prev: any) => ({ ...prev, isShowFilter: !prev.isShowFilter })) }} filter={false} />
|
||||
{isPending ? <Skeleton />
|
||||
:
|
||||
<FlatList
|
||||
data={data.result ?? []}
|
||||
renderItem={({ item, index }) => (
|
||||
data={data?.result ?? []}
|
||||
renderItem={({ item }) => (
|
||||
<PurchaseAuctionTile
|
||||
item={item}
|
||||
onPressDetails={() => {
|
||||
@@ -83,7 +85,7 @@ const PurchaseAuction = () => {
|
||||
/>
|
||||
)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 50, paddingTop: 10 }}
|
||||
contentContainerStyle={{ flexGrow: 1, paddingBottom: 50, paddingTop: 10 }}
|
||||
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.1}
|
||||
@@ -93,6 +95,12 @@ const PurchaseAuction = () => {
|
||||
}}
|
||||
|
||||
ListFooterComponent={renderFooter}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
title="No purchased auctions found"
|
||||
subtitle="Auctions you purchase will appear here."
|
||||
/>
|
||||
}
|
||||
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
@@ -111,7 +119,7 @@ const PurchaseAuction = () => {
|
||||
}
|
||||
{allBoolean.isShowFilter && <View style={{ flex: 1 }}>
|
||||
<FilterModal visible={allBoolean.isShowFilter} onClose={() => { setAllBoolean((prev: any) => ({ ...prev, isShowFilter: false })) }} setFilter={setFilter} filter={filter} />
|
||||
</View>}.
|
||||
</View>}
|
||||
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,291 +1,516 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, FlatList, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
||||
import { InsideHeader, Loader } from '../../Component/Index';
|
||||
import colors from '../../Constant/Color';
|
||||
import { CreditCard, Calendar, User, DollarSign, ArrowRight } from 'lucide-react-native';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import Animated, { FadeInDown, FadeIn } from 'react-native-reanimated';
|
||||
import {
|
||||
Calendar,
|
||||
Hash,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Receipt,
|
||||
CheckCircle2,
|
||||
Gavel,
|
||||
} from 'lucide-react-native';
|
||||
import { InsideHeader } from '../../components';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useOfflineAuctionPurchase, usePurchaseAuctionList } from '../../Services/BBPS/Hooks';
|
||||
import { useOfflineAuctionPurchase, usePurchaseAuctionList } from '../../Services/ApiCall/Hooks';
|
||||
import { showSuccessAlert } from '../../Constant/ShowDailog';
|
||||
import { clearPurchaseAuction } from '../../Redux/Slices/selectPurchaseAuction';
|
||||
import { CommonActions, useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import Skeleton from '../../Component/Skelton/Skelton';
|
||||
|
||||
const dummyAuctionList = [
|
||||
{
|
||||
auctionId: 1,
|
||||
auctionNumber: 'AUC-2025-001',
|
||||
auctionPattern: 'Single Stage',
|
||||
auctionDispatchDate: '25 Feb 2025',
|
||||
auctionDispatcherNumber: 'DISP-001',
|
||||
auctionFees: 2500,
|
||||
gst: 18,
|
||||
gstAmount: 450,
|
||||
portalCharge: 100,
|
||||
payableAmount: 3050,
|
||||
},
|
||||
];
|
||||
import Skeleton from '../../components/Skeleton';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import AppText from '../../components/AppText';
|
||||
|
||||
const PurchaseAuctionList = () => {
|
||||
const Navigation = useNavigation()
|
||||
const Dispatch = useDispatch()
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [list, setList] = useState([]);
|
||||
const SelectedPurchaseList = useSelector((state: any) => state.selectPurchaseAuction);
|
||||
|
||||
const LoginUser = useSelector((state: any) => state.token.token);
|
||||
const { mutate, isPending } = useOfflineAuctionPurchase()
|
||||
const { mutate: purchaseListMutation, isPending: purchaseListPending } = usePurchaseAuctionList();
|
||||
const Navigation: any = useNavigation();
|
||||
const Dispatch = useDispatch();
|
||||
const { colors, spacing, shadows } = useTheme();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [list, setList] = useState<any[]>([]);
|
||||
|
||||
const calculateTotalAmount = (item: any) => {
|
||||
const gstAmount =
|
||||
(Number(item.auctionFees) + Number(item.portalCharge)) *
|
||||
(Number(item.gst) / 100);
|
||||
const SelectedPurchaseList = useSelector((state: any) => state.selectPurchaseAuction);
|
||||
const LoginUser = useSelector((state: any) => state.token.token);
|
||||
|
||||
const payableAmount =
|
||||
Number(item.auctionFees) + Number(item.portalCharge) + gstAmount;
|
||||
return payableAmount
|
||||
}
|
||||
const { mutate, isPending } = useOfflineAuctionPurchase();
|
||||
const { mutate: purchaseListMutation, isPending: purchaseListPending } = usePurchaseAuctionList();
|
||||
|
||||
const handleOfflinePurchase = async () => {
|
||||
const body = {
|
||||
auctionDetails: list.map((b: any) => ({
|
||||
auctionDispatchDate: b?.auctionDispatchDate,
|
||||
auctionDispatcherNumber: b?.auctionDispatcherNumber,
|
||||
auctionFees: b?.auctionFees,
|
||||
auctionId: b?.auctionId,
|
||||
auctionNumber: b?.auctionNumber,
|
||||
auctionPattern: b?.auctionPattern,
|
||||
gst: b?.gst,
|
||||
gstAmount: b?.gstAmount,
|
||||
payableAmount: calculateTotalAmount(b),
|
||||
portalCharge: b?.portalCharge
|
||||
})),
|
||||
orderNo: ''
|
||||
};
|
||||
const calculateTotalAmount = (item: any) => {
|
||||
const gstAmount =
|
||||
(Number(item.auctionFees) + Number(item.portalCharge)) *
|
||||
(Number(item.gst) / 100);
|
||||
return Number(item.auctionFees) + Number(item.portalCharge) + gstAmount;
|
||||
};
|
||||
|
||||
try {
|
||||
mutate(body, {
|
||||
onSuccess: (res) => {
|
||||
if (res.statusCode == 200) {
|
||||
showSuccessAlert(res.message)
|
||||
Dispatch(clearPurchaseAuction())
|
||||
Navigation.dispatch(
|
||||
CommonActions.reset({
|
||||
index: 0,
|
||||
routes: [{ name: NavigationString.Home }],
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.log(error, "================purchse Error");
|
||||
}
|
||||
})
|
||||
const grandTotal = list.reduce(
|
||||
(sum: number, item: any) => sum + calculateTotalAmount(item),
|
||||
0,
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.log("purchase offline error", error);
|
||||
}
|
||||
}
|
||||
|
||||
const handlePurchase = (type: 'online' | 'offline') => {
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
type === 'online' ? Alert.alert('Online Purchase Successful!') : handleOfflinePurchase();
|
||||
|
||||
}, 1200);
|
||||
const handleOfflinePurchase = async () => {
|
||||
const body = {
|
||||
auctionDetails: list.map((b: any) => ({
|
||||
auctionDispatchDate: b?.auctionDispatchDate,
|
||||
auctionDispatcherNumber: b?.auctionDispatcherNumber,
|
||||
auctionFees: b?.auctionFees,
|
||||
auctionId: b?.auctionId,
|
||||
auctionNumber: b?.auctionNumber,
|
||||
auctionPattern: b?.auctionPattern,
|
||||
gst: b?.gst,
|
||||
gstAmount: b?.gstAmount,
|
||||
payableAmount: calculateTotalAmount(b),
|
||||
portalCharge: b?.portalCharge,
|
||||
})),
|
||||
orderNo: '',
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: typeof dummyAuctionList[0] }) => (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.title}>{item.auctionNumber}</Text>
|
||||
<Text style={styles.pattern}>{item.auctionPattern}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.cardRow}>
|
||||
<Calendar size={18} color={colors.primary} />
|
||||
<Text style={styles.text}>Dispatch Date: {item.auctionDispatchDate}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.cardRow}>
|
||||
<User size={18} color={colors.primary} />
|
||||
<Text style={styles.text}>Dispatcher No: {item.auctionDispatcherNumber}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.cardRow}>
|
||||
<DollarSign size={18} color={colors.primary} />
|
||||
<Text style={styles.text}>Auction Fees: ₹{item.auctionFees}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.cardRow}>
|
||||
<DollarSign size={18} color={colors.primary} />
|
||||
<Text style={styles.text}>GST ({item.gst}%): ₹{item.gstAmount}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.cardRow}>
|
||||
<DollarSign size={18} color={colors.primary} />
|
||||
<Text style={styles.text}>Portal Charge: ₹{item.portalCharge}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.totalRow}>
|
||||
<CreditCard size={20} color={colors.white} />
|
||||
<Text style={styles.total}>Total: ₹{item.payableAmount}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const getPurchaseList = () => {
|
||||
const auctionNumbers: string[] = SelectedPurchaseList.map(
|
||||
(item: { auctionNumber: string }) => item.auctionNumber
|
||||
);
|
||||
purchaseListMutation({ auctionNumbers, contractorId: LoginUser.contractorId }, {
|
||||
onSuccess: (res) => {
|
||||
if (res.statusCode == 200) {
|
||||
setList(res.data)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.log(error, "=================purchase list error");
|
||||
}
|
||||
})
|
||||
try {
|
||||
mutate(body, {
|
||||
onSuccess: res => {
|
||||
if (res.statusCode === 200) {
|
||||
showSuccessAlert(res.message);
|
||||
Dispatch(clearPurchaseAuction());
|
||||
Navigation.dispatch(
|
||||
CommonActions.reset({ index: 0, routes: [{ name: NavigationString.Home }] }),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: error => console.log(error, 'purchase Error'),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('purchase offline error', error);
|
||||
}
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
getPurchaseList()
|
||||
}, [SelectedPurchaseList])
|
||||
};
|
||||
|
||||
const handlePurchase = (type: 'online' | 'offline') => {
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
type === 'online' ? undefined : handleOfflinePurchase();
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
const goHome = () => {
|
||||
Dispatch(clearPurchaseAuction());
|
||||
Navigation.dispatch(
|
||||
CommonActions.reset({ index: 0, routes: [{ name: NavigationString.Home }] }),
|
||||
);
|
||||
};
|
||||
|
||||
// The auction was already purchased — proceedToPurchase then has no cart to
|
||||
// show, so bounce back to Home instead of leaving a blank list. (The backend
|
||||
// message is surfaced by the global API toast.)
|
||||
const isAlreadyPurchased = (res: any): boolean => {
|
||||
const message = String(res?.message ?? '').toLowerCase();
|
||||
return res?.statusCode === 409 || message.includes('already');
|
||||
};
|
||||
|
||||
const getPurchaseList = () => {
|
||||
const auctionNumbers: string[] = SelectedPurchaseList.map(
|
||||
(item: { auctionNumber: string }) => item.auctionNumber,
|
||||
);
|
||||
purchaseListMutation(
|
||||
{ auctionNumbers, contractorId: LoginUser.contractorId },
|
||||
{
|
||||
onSuccess: res => {
|
||||
if (res.statusCode === 200) {
|
||||
setList(res.data);
|
||||
} else if (isAlreadyPurchased(res)) {
|
||||
goHome();
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (isAlreadyPurchased(error?.response?.data)) {
|
||||
goHome();
|
||||
} else {
|
||||
console.log(error, 'purchase list error');
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
getPurchaseList();
|
||||
// Intentionally runs only on SelectedPurchaseList changes; other deps are stable or recreated each render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [SelectedPurchaseList]),
|
||||
);
|
||||
|
||||
const styles = makeStyles(colors, spacing, shadows);
|
||||
|
||||
/* ── Render Item ── */
|
||||
const renderItem = ({ item, index }: { item: any; index: number }) => {
|
||||
const total = calculateTotalAmount(item);
|
||||
const gstAmount =
|
||||
(Number(item.auctionFees) + Number(item.portalCharge)) *
|
||||
(Number(item.gst) / 100);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.white }}>
|
||||
<InsideHeader title="Purchase List" showArrow />
|
||||
<View style={styles.screen}>
|
||||
{purchaseListPending ? <Skeleton /> : <FlatList
|
||||
data={list}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.auctionId.toString()}
|
||||
contentContainerStyle={styles.container}
|
||||
/>}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(index * 90).springify()}
|
||||
style={styles.card}
|
||||
>
|
||||
{/* Top accent stripe */}
|
||||
<View style={styles.cardAccent} />
|
||||
|
||||
<View style={styles.cardBody}>
|
||||
{/* Header */}
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={[styles.indexBadge, { backgroundColor: colors.primary }]}>
|
||||
<AppText variant="caption" color={colors.buttonText} style={{ fontWeight: '800' } as any}>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</AppText>
|
||||
</View>
|
||||
|
||||
<View style={styles.bottomButtonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { marginRight: 8 }]}
|
||||
onPress={() => handlePurchase('online')}
|
||||
>
|
||||
<Text style={styles.buttonText}>Purchase Online</Text>
|
||||
<ArrowRight size={18} color="#fff" style={{ marginLeft: 6 }} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { marginLeft: 8 }]}
|
||||
onPress={() => handlePurchase('offline')}
|
||||
>
|
||||
{
|
||||
isPending ? <Loader size='small' color={colors.white} /> : <>
|
||||
<Text style={styles.buttonText}>Purchase Offline</Text>
|
||||
<ArrowRight size={18} color="#fff" style={{ marginLeft: 6 }} />
|
||||
</>
|
||||
}
|
||||
|
||||
</TouchableOpacity>
|
||||
<View style={{ flex: 1, marginLeft: spacing.s }}>
|
||||
<AppText variant="h4" color={colors.text} numberOfLines={2}>
|
||||
{item.auctionNumber}
|
||||
</AppText>
|
||||
{item.auctionPattern ? (
|
||||
<View style={[styles.patternBadge, { backgroundColor: `${colors.primary}15` }]}>
|
||||
<Gavel size={11} color={colors.primary} strokeWidth={2} />
|
||||
<AppText variant="caption" color={colors.primary} style={{ fontWeight: '600' } as any}>
|
||||
{item.auctionPattern}
|
||||
</AppText>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* Info Rows */}
|
||||
<InfoRow
|
||||
icon={<Hash size={15} color={colors.primary} />}
|
||||
label="Dispatcher No"
|
||||
value={item.auctionDispatcherNumber ?? '—'}
|
||||
colors={colors}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={<Calendar size={15} color={colors.primary} />}
|
||||
label="Dispatch Date"
|
||||
value={item.auctionDispatchDate ?? '—'}
|
||||
colors={colors}
|
||||
/>
|
||||
|
||||
<View style={[styles.divider, { marginVertical: spacing.s }]} />
|
||||
|
||||
{/* Fee Breakdown */}
|
||||
<View style={[styles.feeSection, { backgroundColor: colors.overlay }]}>
|
||||
<AppText variant="caption" color={colors.textSecondary} style={styles.feeHeader as any}>
|
||||
FEE BREAKDOWN
|
||||
</AppText>
|
||||
<FeeRow label="Auction Fees" value={`₹ ${Number(item.auctionFees).toFixed(2)}`} colors={colors} />
|
||||
<FeeRow label={`GST (${item.gst}%)`} value={`₹ ${gstAmount.toFixed(2)}`} colors={colors} />
|
||||
<FeeRow label="Portal Charge" value={`₹ ${Number(item.portalCharge).toFixed(2)}`} colors={colors} />
|
||||
</View>
|
||||
|
||||
{/* Total Banner */}
|
||||
<View style={[styles.totalBanner, { backgroundColor: colors.primary }]}>
|
||||
<View style={styles.totalLeft}>
|
||||
<Receipt size={18} color={colors.buttonText} />
|
||||
<AppText variant="bodySmall" color={colors.buttonText} style={{ fontWeight: '600' } as any}>
|
||||
Total Payable
|
||||
</AppText>
|
||||
</View>
|
||||
<AppText variant="h4" color={colors.buttonText}>
|
||||
₹ {total.toFixed(2)}
|
||||
</AppText>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<InsideHeader title="Auction Purchase" showArrow />
|
||||
|
||||
{/* Grand Total Strip */}
|
||||
<Animated.View entering={FadeInDown.springify()} style={styles.grandTotalStrip}>
|
||||
<View>
|
||||
<AppText variant="caption" color={colors.textSecondary}>
|
||||
{list.length} auction{list.length !== 1 ? 's' : ''}
|
||||
</AppText>
|
||||
<AppText variant="h3" color={colors.text}>
|
||||
₹ {grandTotal.toFixed(2)}
|
||||
</AppText>
|
||||
</View>
|
||||
<View style={[styles.grandTotalRight, { backgroundColor: `${colors.success}15` }]}>
|
||||
<CheckCircle2 size={18} color={colors.success} />
|
||||
<AppText variant="caption" color={colors.success} style={{ fontWeight: '600' } as any}>
|
||||
Ready to pay
|
||||
</AppText>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* List */}
|
||||
{purchaseListPending ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
<FlatList
|
||||
data={list}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={item => item.auctionId?.toString()}
|
||||
contentContainerStyle={styles.container}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bottom Buttons */}
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.onlineButton, { borderColor: colors.primary }]}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => handlePurchase('online')}
|
||||
>
|
||||
<Wifi size={18} color={colors.primary} />
|
||||
<AppText variant="button" color={colors.primary} style={styles.btnText as any}>
|
||||
Online
|
||||
</AppText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.offlineButton, { backgroundColor: colors.buttonPrimary }]}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => handlePurchase('offline')}
|
||||
>
|
||||
{isPending ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<>
|
||||
<WifiOff size={18} color={colors.buttonText} />
|
||||
<AppText variant="button" color={colors.buttonText} style={styles.btnText as any}>
|
||||
Offline
|
||||
</AppText>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{(loading || isPending) && (
|
||||
<Animated.View entering={FadeIn.duration(200)} style={styles.loadingOverlay}>
|
||||
<View style={[styles.loadingCard, { backgroundColor: colors.surface }]}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<AppText variant="bodySmall" color={colors.textSecondary} style={{ marginTop: spacing.s } as any}>
|
||||
Processing purchase…
|
||||
</AppText>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseAuctionList;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
/* ─── Sub-components ─── */
|
||||
|
||||
const InfoRow = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
colors,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
colors: any;
|
||||
}) => (
|
||||
<View style={infoRowStyles.row}>
|
||||
<View style={infoRowStyles.iconWrap}>{icon}</View>
|
||||
<AppText variant="caption" color={colors.textSecondary} style={infoRowStyles.label as any}>
|
||||
{label}
|
||||
</AppText>
|
||||
<AppText variant="bodySmall" color={colors.text} style={infoRowStyles.value as any}>
|
||||
{value}
|
||||
</AppText>
|
||||
</View>
|
||||
);
|
||||
|
||||
const infoRowStyles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 5, gap: 6 },
|
||||
iconWrap: { width: 22, alignItems: 'center' },
|
||||
label: { flex: 1, fontWeight: '500' } as any,
|
||||
value: { fontWeight: '600' } as any,
|
||||
});
|
||||
|
||||
const FeeRow = ({
|
||||
label,
|
||||
value,
|
||||
colors,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
colors: any;
|
||||
}) => (
|
||||
<View style={feeRowStyles.row}>
|
||||
<AppText variant="caption" color={colors.textSecondary}>{label}</AppText>
|
||||
<AppText variant="caption" color={colors.text} style={{ fontWeight: '600' } as any}>{value}</AppText>
|
||||
</View>
|
||||
);
|
||||
|
||||
const feeRowStyles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
|
||||
});
|
||||
|
||||
/* ─── Styles factory ─── */
|
||||
const makeStyles = (colors: any, spacing: any, shadows: any) =>
|
||||
StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
|
||||
/* Grand total strip */
|
||||
grandTotalStrip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: colors.surface,
|
||||
marginHorizontal: spacing.m,
|
||||
marginTop: spacing.m,
|
||||
marginBottom: spacing.s,
|
||||
paddingHorizontal: spacing.m,
|
||||
paddingVertical: spacing.m,
|
||||
borderRadius: 18,
|
||||
...shadows.medium,
|
||||
},
|
||||
grandTotalRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
paddingHorizontal: spacing.s + 2,
|
||||
paddingVertical: spacing.xs + 2,
|
||||
borderRadius: 20,
|
||||
},
|
||||
|
||||
container: {
|
||||
padding: 16,
|
||||
paddingBottom: 120,
|
||||
paddingHorizontal: spacing.m,
|
||||
paddingTop: spacing.s,
|
||||
paddingBottom: 120,
|
||||
},
|
||||
|
||||
/* Card */
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
elevation: 5,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.12,
|
||||
shadowOffset: { width: 0, height: 3 },
|
||||
shadowRadius: 8,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 20,
|
||||
marginBottom: spacing.m,
|
||||
overflow: 'hidden',
|
||||
...shadows.medium,
|
||||
},
|
||||
cardAccent: {
|
||||
height: 4,
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
cardBody: {
|
||||
padding: spacing.m,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: spacing.s,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
indexBadge: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 2,
|
||||
},
|
||||
pattern: {
|
||||
fontSize: 14,
|
||||
color: colors.primary,
|
||||
fontWeight: '600',
|
||||
patternBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: spacing.s,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
cardRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginBottom: 4,
|
||||
divider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.border,
|
||||
marginVertical: spacing.s,
|
||||
},
|
||||
text: {
|
||||
fontSize: 14,
|
||||
color: '#555',
|
||||
feeSection: {
|
||||
borderRadius: 12,
|
||||
padding: spacing.s + 2,
|
||||
marginBottom: spacing.s,
|
||||
},
|
||||
totalRow: {
|
||||
marginTop: 10,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 12,
|
||||
paddingVertical: 10,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
feeHeader: {
|
||||
letterSpacing: 1.2,
|
||||
marginBottom: spacing.xs + 2,
|
||||
fontWeight: '700',
|
||||
} as any,
|
||||
totalBanner: {
|
||||
borderRadius: 14,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: spacing.m,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
total: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
totalLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.s,
|
||||
},
|
||||
bottomButtonContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.white,
|
||||
width: '100%',
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 10,
|
||||
elevation: 5
|
||||
|
||||
/* Footer */
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: spacing.m,
|
||||
paddingTop: spacing.s + 4,
|
||||
paddingBottom: spacing.m + 4,
|
||||
backgroundColor: colors.surface,
|
||||
gap: spacing.s,
|
||||
...shadows.strong,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.primary,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 16,
|
||||
gap: spacing.s,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
onlineButton: {
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
offlineButton: {
|
||||
...shadows.medium,
|
||||
},
|
||||
btnText: {
|
||||
letterSpacing: 1.2,
|
||||
} as any,
|
||||
|
||||
/* Loading overlay */
|
||||
loadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.2)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10,
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
});
|
||||
loadingCard: {
|
||||
borderRadius: 20,
|
||||
padding: spacing.l,
|
||||
alignItems: 'center',
|
||||
...shadows.strong,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,263 +1,293 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Platform } from 'react-native';
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import Animated, { FadeInUp, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||
import moment from 'moment';
|
||||
import CountdownTimer from '../../Component/Counter/CountdownTimer';
|
||||
import colors from '../../Constant/Color';
|
||||
import CountdownTimer from '../../components/CountdownTimer';
|
||||
import AuctionDetail from './Modal/AuctionDetail';
|
||||
import AppText from '../../components/AppText';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import {
|
||||
Tag,
|
||||
Calendar,
|
||||
IndianRupee,
|
||||
ArrowRight,
|
||||
CircleDot,
|
||||
Clock,
|
||||
TimerOff,
|
||||
} from 'lucide-react-native';
|
||||
|
||||
const PurchaseAuctionTile = ({ item, onPressDetails, onStartBidding, buttonEnable, isAuctionEnd }: any) => {
|
||||
const PurchaseAuctionTile = ({
|
||||
item,
|
||||
onStartBidding,
|
||||
buttonEnable,
|
||||
isAuctionEnd,
|
||||
}: any) => {
|
||||
const { colors, spacing, shadows } = useTheme();
|
||||
const [showDetail, setShowDetail] = React.useState(false);
|
||||
const scale = useSharedValue(1);
|
||||
const elevation = useSharedValue(8);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ scale: scale.value }],
|
||||
elevation: elevation.value, // Android-safe
|
||||
};
|
||||
}, []);
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
const handlePressIn = () => {
|
||||
scale.value = withTiming(0.97, { duration: 120 });
|
||||
elevation.value = withTiming(3, { duration: 120 });
|
||||
};
|
||||
const handlePressIn = () => scale.value = withTiming(0.98, { duration: 140 });
|
||||
const handlePressOut = () => scale.value = withTiming(1, { duration: 160 });
|
||||
|
||||
const handlePressOut = () => {
|
||||
scale.value = withTiming(1, { duration: 160 });
|
||||
elevation.value = withTiming(8, { duration: 160 });
|
||||
};
|
||||
const isAmended = item?.auctionStatus === 'Amended';
|
||||
const biddingEnd = isAmended
|
||||
? item?.amendedkeyDates?.auctionBidding?.endDateTime?.replace(' kk', '')
|
||||
: item?.keyDates?.auctionBidding?.endDateTime?.replace(' kk', '');
|
||||
const statusConfig = useMemo(() => {
|
||||
if (isAuctionEnd) return { label: 'ENDED', color: colors.error, bg: `${colors.error}15`, Icon: TimerOff };
|
||||
if (buttonEnable) return { label: 'LIVE', color: colors.success, bg: `${colors.success}15`, Icon: CircleDot };
|
||||
return { label: 'PENDING', color: colors.textSecondary, bg: `${colors.textSecondary}12`, Icon: Clock };
|
||||
}, [isAuctionEnd, buttonEnable, colors]);
|
||||
|
||||
const statusColor = isAuctionEnd ? '#EF4444' : buttonEnable ? colors.primary : '#94A3B8';
|
||||
const styles = makeStyles(colors, spacing);
|
||||
|
||||
const lightStatusBg = isAuctionEnd ? '#FEF2F2' : buttonEnable ? '#EFF6FF' : '#F8FAFC';
|
||||
if (showDetail) {
|
||||
return <AuctionDetail visible={showDetail} data={item} onClose={() => setShowDetail(false)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
{!showDetail ? <Animated.View
|
||||
entering={FadeInUp.duration(400)}
|
||||
style={[styles.shadowContainer, animatedStyle]}
|
||||
<Animated.View
|
||||
entering={FadeInUp.duration(450).springify()}
|
||||
style={[styles.container, shadows.medium, animatedStyle]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowDetail(true)}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
style={styles.card}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowDetail(true)}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
style={styles.cardInternal}
|
||||
>
|
||||
<View style={[styles.statusAccent, { backgroundColor: statusColor }]} />
|
||||
|
||||
<View style={styles.mainInfo}>
|
||||
{/* Header */}
|
||||
<View style={styles.headerRow}>
|
||||
<View>
|
||||
<View style={[styles.statusBadge, { backgroundColor: lightStatusBg }]}>
|
||||
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
||||
<Text style={[styles.statusText, { color: statusColor }]}>
|
||||
{isAuctionEnd ? 'ENDED' : buttonEnable ? 'LIVE' : 'PENDING'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.title}>{item.auctionNumber}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.amountContainer}>
|
||||
<Text style={styles.amount}>
|
||||
₹{item.auctionFees?.toLocaleString()}
|
||||
</Text>
|
||||
<Text style={styles.feeLabel}>Auction Fee</Text>
|
||||
</View>
|
||||
{/* ── Header ── */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerTop}>
|
||||
{/* Status pill */}
|
||||
<View style={[styles.statusBadge, { backgroundColor: statusConfig.bg }]}>
|
||||
<statusConfig.Icon size={11} color={statusConfig.color} strokeWidth={3} />
|
||||
<AppText variant="caption" color={statusConfig.color} style={styles.statusText as any}>
|
||||
{statusConfig.label}
|
||||
</AppText>
|
||||
</View>
|
||||
|
||||
{/* Date */}
|
||||
<View style={styles.dateRow}>
|
||||
<Text style={styles.dateText}>
|
||||
<Text style={styles.dateLabel}>Bidding Start: </Text>
|
||||
{moment(
|
||||
item?.keyDates?.auctionBidding?.startDateTime?.replace(' kk', ''),
|
||||
'DD-MM-YYYY HH:mm:ss'
|
||||
).format('DD MMM YYYY, hh:mm A')}
|
||||
</Text>
|
||||
<Text style={styles.dateText}>
|
||||
<Text style={styles.dateLabel}>Bidding Ends: </Text>
|
||||
{moment(
|
||||
item?.keyDates?.auctionBidding?.endDateTime?.replace(' kk', ''),
|
||||
'DD-MM-YYYY HH:mm:ss'
|
||||
).format('DD MMM YYYY, hh:mm A')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.footerTop}>
|
||||
<Text style={styles.footerLabel}>Remaining Time</Text>
|
||||
{buttonEnable && !isAuctionEnd ? (
|
||||
<CountdownTimer
|
||||
endDate={item.keyDates.auctionBidding.endDateTime}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.endedText}>--:--:--</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
disabled={!buttonEnable}
|
||||
onPress={onStartBidding}
|
||||
activeOpacity={0.85}
|
||||
style={[
|
||||
styles.actionButton,
|
||||
{ backgroundColor: statusColor },
|
||||
!buttonEnable && styles.disabledButton,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.addText}>
|
||||
{isAuctionEnd
|
||||
? 'Bidding Closed'
|
||||
: !buttonEnable
|
||||
? 'Pending Bid'
|
||||
: 'Proceed to Start Bidding'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{/* Auction number */}
|
||||
<View style={styles.numberRow}>
|
||||
<Tag size={12} color={colors.primary} strokeWidth={2} />
|
||||
<AppText variant="caption" color={colors.textSecondary} style={styles.numberText as any}>
|
||||
{item?.auctionNumber}
|
||||
</AppText>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View> :
|
||||
<AuctionDetail visible={showDetail} data={item} onClose={() => setShowDetail(false)} />
|
||||
}
|
||||
</View>
|
||||
|
||||
{/* Work title */}
|
||||
<AppText variant="bodySmall" color={colors.text} numberOfLines={2} style={styles.workTitle as any}>
|
||||
{item?.auctionOfWork ?? item?.auctionNumber ?? 'Auction Details'}
|
||||
</AppText>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* ── Info Grid ── */}
|
||||
<View style={styles.infoGrid}>
|
||||
{/* Fee */}
|
||||
<View style={styles.infoRow}>
|
||||
<View style={[styles.iconBox, { backgroundColor: `${colors.primary}15` }]}>
|
||||
<IndianRupee size={17} color={colors.primary} strokeWidth={2} />
|
||||
</View>
|
||||
<View>
|
||||
<AppText variant="caption" color={colors.textSecondary}>Auction Fee</AppText>
|
||||
<AppText variant="caption" color={colors.text} style={styles.infoValue as any}>
|
||||
₹{Number(item?.auctionFees ?? 0).toLocaleString('en-IN')}
|
||||
</AppText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bidding End */}
|
||||
<View style={styles.infoRow}>
|
||||
<View style={[styles.iconBox, { backgroundColor: `${colors.primary}15` }]}>
|
||||
<Calendar size={17} color={colors.primary} strokeWidth={2} />
|
||||
</View>
|
||||
<View>
|
||||
<AppText variant="caption" color={colors.textSecondary}>Bidding Ends</AppText>
|
||||
<AppText variant="caption" color={colors.text} style={styles.infoValue as any}>
|
||||
{biddingEnd
|
||||
? moment(biddingEnd, 'DD-MM-YYYY HH:mm:ss').format('DD MMM, hh:mm A')
|
||||
: '—'}
|
||||
</AppText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<View style={styles.footer}>
|
||||
<AppText variant="caption" color={colors.textSecondary} style={{ marginBottom: 4, alignSelf: 'flex-start' } as any}>
|
||||
Time Remaining
|
||||
</AppText>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||
{/* Timer */}
|
||||
<View style={styles.timerSection}>
|
||||
|
||||
{buttonEnable && !isAuctionEnd ? (
|
||||
<CountdownTimer endDate={biddingEnd} />
|
||||
) : (
|
||||
<AppText variant="bodySmall" color={colors.textSecondary} style={{ fontWeight: '700' } as any}>
|
||||
-- : -- : --
|
||||
</AppText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* CTA Button */}
|
||||
<TouchableOpacity
|
||||
disabled={!buttonEnable || isAuctionEnd}
|
||||
onPress={onStartBidding}
|
||||
activeOpacity={0.85}
|
||||
style={[
|
||||
styles.ctaBtn,
|
||||
{
|
||||
backgroundColor:
|
||||
isAuctionEnd
|
||||
? colors.error
|
||||
: buttonEnable
|
||||
? colors.primary
|
||||
: colors.border,
|
||||
opacity: (!buttonEnable || isAuctionEnd) ? 0.8 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<AppText
|
||||
variant="button"
|
||||
color={colors.buttonText}
|
||||
style={styles.ctaText as any}
|
||||
>
|
||||
{isAuctionEnd ? 'Ended' : !buttonEnable ? 'Pending' : 'Bid Now'}
|
||||
</AppText>
|
||||
{buttonEnable && !isAuctionEnd && (
|
||||
<ArrowRight size={15} color={colors.buttonText} strokeWidth={2.5} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseAuctionTile;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
shadowContainer: {
|
||||
marginHorizontal: 5,
|
||||
marginTop: 14,
|
||||
marginBottom: 6,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#fff',
|
||||
elevation: 8,
|
||||
},
|
||||
cardInternal: {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
statusAccent: {
|
||||
width: 5,
|
||||
height: '100%',
|
||||
},
|
||||
mainInfo: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 12,
|
||||
},
|
||||
statusBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 6,
|
||||
marginBottom: 6,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
statusDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
marginRight: 5,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
title: {
|
||||
fontWeight: '800',
|
||||
fontSize: 16,
|
||||
color: '#1E293B',
|
||||
},
|
||||
amountContainer: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
amount: {
|
||||
fontWeight: '900',
|
||||
fontSize: 18,
|
||||
color: '#0F172A',
|
||||
},
|
||||
feeLabel: {
|
||||
fontSize: 10,
|
||||
color: '#64748B',
|
||||
fontWeight: '500',
|
||||
},
|
||||
dateRow: {
|
||||
backgroundColor: '#F8FAFC',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 10,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#F1F5F9',
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 12,
|
||||
color: '#334155',
|
||||
fontWeight: '600',
|
||||
},
|
||||
dateLabel: {
|
||||
fontWeight: '400',
|
||||
color: '#64748B',
|
||||
},
|
||||
footer: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#F1F5F9',
|
||||
paddingTop: 12,
|
||||
},
|
||||
footerTop: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
footerLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: '#94A3B8',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
endedText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
color: '#CBD5E1',
|
||||
},
|
||||
actionButton: {
|
||||
width: '100%',
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
}),
|
||||
},
|
||||
disabledButton: {
|
||||
backgroundColor: '#E2E8F0',
|
||||
},
|
||||
addText: {
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
});
|
||||
const makeStyles = (colors: any, spacing: any) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
marginHorizontal: spacing.s,
|
||||
marginBottom: spacing.m,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
backgroundColor: colors.surface,
|
||||
padding: spacing.m,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(0,0,0,0.03)',
|
||||
},
|
||||
|
||||
/* Header */
|
||||
header: {
|
||||
marginBottom: spacing.m,
|
||||
},
|
||||
headerTop: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.s,
|
||||
},
|
||||
statusBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 20,
|
||||
gap: 5,
|
||||
},
|
||||
statusText: {
|
||||
fontWeight: '800',
|
||||
fontSize: 10,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
} as any,
|
||||
numberRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
numberText: {
|
||||
letterSpacing: 0.4,
|
||||
fontWeight: '600',
|
||||
} as any,
|
||||
workTitle: {
|
||||
fontWeight: '600',
|
||||
lineHeight: 20,
|
||||
} as any,
|
||||
|
||||
divider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.border,
|
||||
marginBottom: spacing.m,
|
||||
opacity: 0.6,
|
||||
},
|
||||
|
||||
/* Info grid */
|
||||
infoGrid: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.l,
|
||||
gap: spacing.s,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
flex: 1,
|
||||
},
|
||||
iconBox: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
infoValue: {
|
||||
fontWeight: '700',
|
||||
} as any,
|
||||
|
||||
/* Footer */
|
||||
footer: {
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background,
|
||||
marginHorizontal: -spacing.m,
|
||||
marginBottom: -spacing.m,
|
||||
padding: spacing.m,
|
||||
borderBottomLeftRadius: 16,
|
||||
borderBottomRightRadius: 16,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
timerSection: {
|
||||
flex: 1,
|
||||
},
|
||||
ctaBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 18,
|
||||
borderRadius: 12,
|
||||
gap: 5,
|
||||
},
|
||||
ctaText: {
|
||||
letterSpacing: 0.8,
|
||||
} as any,
|
||||
});
|
||||
|
||||
@@ -1,220 +1,389 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
|
||||
import Animated, { FadeIn, FadeOut, Layout, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||
import { ChevronDown, Trash2, Info } from 'lucide-react-native';
|
||||
import { InsideHeader } from '../../Component/Index';
|
||||
import colors from '../../Constant/Color';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
FadeInDown,
|
||||
Layout,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { ChevronDown, ChevronRight, Trash2, ShoppingBag, Gavel, Tag } from 'lucide-react-native';
|
||||
import { InsideHeader } from '../../components';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { togglePurchaseAuction } from '../../Redux/Slices/selectPurchaseAuction';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import AppText from '../../components/AppText';
|
||||
|
||||
const PurchaseList = () => {
|
||||
const Navigation: any = useNavigation()
|
||||
const Dispatch = useDispatch()
|
||||
const Navigation: any = useNavigation();
|
||||
const Dispatch = useDispatch();
|
||||
const { colors, spacing, shadows } = useTheme();
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||
const SelectedPurchaseList = useSelector((state: any) => state.selectPurchaseAuction);
|
||||
|
||||
const toggleExpand = (index: number) => {
|
||||
setExpandedIndex(prev => (prev === index ? null : index));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!SelectedPurchaseList || SelectedPurchaseList.length < 1) {
|
||||
Navigation.goBack();
|
||||
}
|
||||
// Intentionally runs only on SelectedPurchaseList changes; other deps are stable or recreated each render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [SelectedPurchaseList]);
|
||||
|
||||
const styles = makeStyles(colors, spacing, shadows);
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<InsideHeader title="Purchase" showArrow />
|
||||
<InsideHeader title="Purchase Auctions" showArrow />
|
||||
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.heading}>Purchase List</Text>
|
||||
{/* <Info size={20} color="#0F172A" /> */}
|
||||
{/* Summary Strip */}
|
||||
<View style={styles.summaryStrip}>
|
||||
<View style={styles.summaryIcon}>
|
||||
<ShoppingBag size={16} color={colors.primary} />
|
||||
</View>
|
||||
<AppText variant="caption" color={colors.textSecondary}>
|
||||
<AppText variant="bodySmall" color={colors.primary} style={{ fontWeight: '700' } as any}>
|
||||
{SelectedPurchaseList.length}
|
||||
</AppText>
|
||||
{' '}auction{SelectedPurchaseList.length !== 1 ? 's' : ''} selected for purchase
|
||||
</AppText>
|
||||
</View>
|
||||
|
||||
{/* Cards */}
|
||||
{SelectedPurchaseList.map((item: any, index: any) => {
|
||||
const expanded = expandedIndex === index;
|
||||
return (
|
||||
<Animated.View
|
||||
key={item.auctionId}
|
||||
layout={Layout.springify()}
|
||||
style={styles.card}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={() => toggleExpand(index)}
|
||||
>
|
||||
<CardHeader
|
||||
title={item.auctionOfWork}
|
||||
expanded={expanded}
|
||||
/>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.container}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{SelectedPurchaseList.map((item: any, index: number) => (
|
||||
<PurchaseListItem
|
||||
key={item.auctionId}
|
||||
item={item}
|
||||
index={index}
|
||||
expandedIndex={expandedIndex}
|
||||
toggleExpand={toggleExpand}
|
||||
colors={colors}
|
||||
styles={styles}
|
||||
Dispatch={Dispatch}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Text style={styles.subTitle}>
|
||||
Auction No: {item.auctionNumber}
|
||||
</Text>
|
||||
|
||||
<Text style={styles.subTitle}>
|
||||
Status:{' '}
|
||||
<Text
|
||||
style={{
|
||||
color: item.status ? '#16A34A' : '#DC2626',
|
||||
fontWeight: '700',
|
||||
}}
|
||||
>
|
||||
{item.status ? 'Open' : 'Closed'}
|
||||
</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{expanded && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(220)}
|
||||
exiting={FadeOut.duration(180)}
|
||||
layout={Layout.springify()}
|
||||
style={styles.details}
|
||||
>
|
||||
{renderRow('Auction Mode', item.auctionMode)}
|
||||
{renderRow('Pattern', item.auctionPattern)}
|
||||
{renderRow('Fees', `₹ ${item.auctionFees}`)}
|
||||
{renderRow('Department', item.deptName)}
|
||||
{renderRow('Purchase End', item.purchaseEnd)}
|
||||
|
||||
<TouchableOpacity style={styles.removeButton} onPress={() => { Dispatch(togglePurchaseAuction(item)) }}>
|
||||
<Trash2 size={18} color="#fff" />
|
||||
<Text style={styles.removeText}>Remove</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
})}
|
||||
<View style={{ height: 90 }} />
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer */}
|
||||
<TouchableOpacity disabled={Boolean(!SelectedPurchaseList.length)} style={[styles.proceedButton, { backgroundColor: Boolean(!SelectedPurchaseList.length) ? colors.lightText : colors.primary }]} onPress={() => { Navigation.navigate(NavigationString.PurchaseAuctionList) }}>
|
||||
<Text style={styles.proceedText}>Proceed to Purchase</Text>
|
||||
</TouchableOpacity>
|
||||
{/* Floating Proceed Button */}
|
||||
<View style={styles.footerContainer}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
disabled={!SelectedPurchaseList.length}
|
||||
style={[
|
||||
styles.proceedButton,
|
||||
{
|
||||
backgroundColor: SelectedPurchaseList.length
|
||||
? colors.buttonPrimary
|
||||
: colors.secondary,
|
||||
},
|
||||
]}
|
||||
onPress={() => Navigation.navigate(NavigationString.PurchaseAuctionList)}
|
||||
>
|
||||
<AppText variant="button" color={colors.buttonText} style={styles.proceedText as any}>
|
||||
Proceed to Purchase
|
||||
</AppText>
|
||||
<View style={styles.countBadge}>
|
||||
<AppText variant="caption" color={colors.buttonPrimary} style={{ fontWeight: '800' } as any}>
|
||||
{SelectedPurchaseList.length}
|
||||
</AppText>
|
||||
</View>
|
||||
<ChevronRight size={20} color={colors.buttonText} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseList;
|
||||
|
||||
const CardHeader = ({ title, expanded }: {
|
||||
title: string;
|
||||
expanded: boolean;
|
||||
}) => {
|
||||
const rotation = useSharedValue(expanded ? 1 : 0);
|
||||
rotation.value = withTiming(expanded ? 1 : 0, { duration: 220 });
|
||||
const arrowStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${rotation.value * 180}deg` }],
|
||||
}));
|
||||
/* ─── Sub-components ─── */
|
||||
|
||||
const PurchaseListItem = ({ item, index, expandedIndex, toggleExpand, colors, styles, Dispatch }: any) => {
|
||||
const expanded = expandedIndex === index;
|
||||
const isActive = item.status;
|
||||
const chevronStyle = useChevronStyle(expanded);
|
||||
|
||||
return (
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Animated.View style={arrowStyle}>
|
||||
<ChevronDown size={22} color={colors.black} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(index * 80).springify()}
|
||||
layout={Layout.springify()}
|
||||
style={[styles.card, expanded && styles.cardExpanded]}
|
||||
>
|
||||
{/* Left Accent Bar */}
|
||||
<View style={[styles.accentBar, { backgroundColor: isActive ? colors.success : colors.primary }]} />
|
||||
|
||||
<View style={styles.cardInner}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
onPress={() => toggleExpand(index)}
|
||||
>
|
||||
{/* Badge Row */}
|
||||
<View style={styles.badgeRow}>
|
||||
<View style={styles.auctionBadge}>
|
||||
<Gavel size={11} color={colors.primary} strokeWidth={2} />
|
||||
<AppText variant="caption" color={colors.primary} style={styles.badgeText as any}>
|
||||
{item.auctionNumber}
|
||||
</AppText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Title + Chevron */}
|
||||
<View style={styles.titleRow}>
|
||||
<AppText variant="h4" style={styles.title as any} numberOfLines={2}>
|
||||
{item.auctionOfWork ?? item.auctionNumber}
|
||||
</AppText>
|
||||
<Animated.View style={chevronStyle}>
|
||||
<ChevronDown size={20} color={colors.textSecondary} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* Dept Tag */}
|
||||
<View style={styles.deptRow}>
|
||||
<Tag size={12} color={colors.textSecondary} />
|
||||
<AppText variant="caption" color={colors.textSecondary} style={styles.deptText as any}>
|
||||
{item.deptName || 'Department'}
|
||||
</AppText>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Expanded Section */}
|
||||
{expanded && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(220)}
|
||||
exiting={FadeOut.duration(160)}
|
||||
layout={Layout.springify()}
|
||||
style={styles.detailsContainer}
|
||||
>
|
||||
<View style={styles.divider} />
|
||||
|
||||
<DetailRow label="Auction Mode" value={item.auctionMode} colors={colors} />
|
||||
<DetailRow label="Pattern" value={item.auctionPattern} colors={colors} />
|
||||
<DetailRow label="Auction Fees" value={`₹ ${item.auctionFees}`} colors={colors} valueColor={colors.primary} />
|
||||
<DetailRow label="Department" value={item.deptName} colors={colors} />
|
||||
<DetailRow label="Purchase End" value={item.purchaseEnd} colors={colors} />
|
||||
|
||||
{/* Remove Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.removeButton}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => Dispatch(togglePurchaseAuction(item))}
|
||||
>
|
||||
<Trash2 size={16} color="#fff" />
|
||||
<AppText variant="button" color="#fff" style={styles.removeText as any}>
|
||||
Remove
|
||||
</AppText>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRow = (label: string, value: string) => (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Text style={styles.value}>{value}</Text>
|
||||
const DetailRow = ({
|
||||
label,
|
||||
value,
|
||||
colors,
|
||||
valueColor,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
colors: any;
|
||||
valueColor?: string;
|
||||
}) => (
|
||||
<View style={detailRowStyles.row}>
|
||||
<AppText variant="caption" color={colors.textSecondary} style={detailRowStyles.label as any}>
|
||||
{label}
|
||||
</AppText>
|
||||
<AppText variant="caption" color={valueColor || colors.text} style={detailRowStyles.value as any}>
|
||||
{value ?? '—'}
|
||||
</AppText>
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
container: {
|
||||
padding: 16,
|
||||
paddingBottom: 110,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 14,
|
||||
},
|
||||
heading: {
|
||||
fontSize: 22,
|
||||
fontWeight: '800',
|
||||
color: colors.black,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 18,
|
||||
padding: 16,
|
||||
marginBottom: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
},
|
||||
cardHeader: {
|
||||
const detailRowStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
subTitle: {
|
||||
fontSize: 14,
|
||||
color: '#64748B',
|
||||
marginTop: 6,
|
||||
},
|
||||
details: {
|
||||
marginTop: 16,
|
||||
paddingTop: 14,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E5E7EB',
|
||||
},
|
||||
row: {
|
||||
marginVertical: 6,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: 'rgba(0,0,0,0.06)',
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
color: '#64748B',
|
||||
},
|
||||
flex: 1,
|
||||
fontWeight: '500',
|
||||
} as any,
|
||||
value: {
|
||||
fontSize: 15,
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
fontWeight: '600',
|
||||
color: '#0F172A',
|
||||
},
|
||||
removeButton: {
|
||||
marginTop: 18,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 14,
|
||||
paddingVertical: 12,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
removeText: {
|
||||
color: '#fff',
|
||||
fontWeight: '700',
|
||||
},
|
||||
proceedButton: {
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
backgroundColor: colors.primary,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
},
|
||||
proceedText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
/* ─── Chevron animated style hook ─── */
|
||||
function useChevronStyle(expanded: boolean) {
|
||||
const rotation = useSharedValue(expanded ? 1 : 0);
|
||||
rotation.value = withTiming(expanded ? 1 : 0, { duration: 220 });
|
||||
return useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${rotation.value * 180}deg` }],
|
||||
}));
|
||||
}
|
||||
|
||||
/* ─── Styles factory ─── */
|
||||
const makeStyles = (colors: any, spacing: any, shadows: any) =>
|
||||
StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
summaryStrip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
marginHorizontal: spacing.m,
|
||||
marginTop: spacing.m,
|
||||
marginBottom: spacing.s,
|
||||
paddingHorizontal: spacing.m,
|
||||
paddingVertical: spacing.s + 2,
|
||||
borderRadius: 14,
|
||||
gap: spacing.s,
|
||||
...shadows.light,
|
||||
},
|
||||
summaryIcon: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
backgroundColor: `${colors.primary}15`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
container: {
|
||||
paddingHorizontal: spacing.m,
|
||||
paddingTop: spacing.s,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 20,
|
||||
marginBottom: spacing.m,
|
||||
flexDirection: 'row',
|
||||
overflow: 'hidden',
|
||||
...shadows.medium,
|
||||
},
|
||||
cardExpanded: {
|
||||
...shadows.strong,
|
||||
},
|
||||
accentBar: {
|
||||
width: 5,
|
||||
borderTopLeftRadius: 20,
|
||||
borderBottomLeftRadius: 20,
|
||||
},
|
||||
cardInner: {
|
||||
flex: 1,
|
||||
paddingHorizontal: spacing.m,
|
||||
paddingVertical: spacing.m,
|
||||
},
|
||||
badgeRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.s,
|
||||
},
|
||||
auctionBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: `${colors.primary}15`,
|
||||
paddingHorizontal: spacing.s,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 8,
|
||||
gap: 4,
|
||||
},
|
||||
badgeText: {
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.2,
|
||||
} as any,
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.s,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontWeight: '700',
|
||||
} as any,
|
||||
deptRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
marginTop: spacing.xs + 2,
|
||||
},
|
||||
deptText: {
|
||||
letterSpacing: 0.3,
|
||||
} as any,
|
||||
detailsContainer: {
|
||||
marginTop: spacing.s,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
marginBottom: spacing.s,
|
||||
},
|
||||
removeButton: {
|
||||
marginTop: spacing.m,
|
||||
backgroundColor: colors.error,
|
||||
borderRadius: 14,
|
||||
paddingVertical: 12,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: spacing.s,
|
||||
},
|
||||
removeText: {
|
||||
letterSpacing: 1,
|
||||
} as any,
|
||||
footerContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: spacing.m,
|
||||
right: spacing.m,
|
||||
},
|
||||
proceedButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 16,
|
||||
borderRadius: 20,
|
||||
gap: spacing.s,
|
||||
...shadows.strong,
|
||||
},
|
||||
proceedText: {
|
||||
letterSpacing: 1.5,
|
||||
} as any,
|
||||
countBadge: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface LiveAuctionTileProps {
|
||||
isPurchased?: boolean;
|
||||
auctionId?: any;
|
||||
auctionNumber?: any;
|
||||
isPurchasedByContractor?: boolean
|
||||
}
|
||||
|
||||
export interface allBooleanProps {
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
BackHandler,
|
||||
} from 'react-native';
|
||||
import LottieView from 'lottie-react-native';
|
||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||
import { InsideHeader } from '../../components';
|
||||
import { useWebSocketService } from '../../socket/Socket';
|
||||
import Variables from '../../Constant/Variable';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { apiCall } from '../../Axios/Axios';
|
||||
import {
|
||||
AUCTION_ONE_TIME_BID,
|
||||
GET_AUCTION_DETAILS,
|
||||
SELECTED_AUCTION_ITEM,
|
||||
} from '../../Services/ApiCall/ApiUrls';
|
||||
import AuctionBiddingCard from './Component/AuctionBiddingCard';
|
||||
import ConfirmBidModal from './Component/ConfirmBidModal';
|
||||
import GoBackModal from './Component/GoBackModal';
|
||||
import { CommonActions, useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import CountdownTimer from '../../components/CountdownTimer';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import AppText from '../../components/AppText';
|
||||
import { Gavel, Building2, Clock4 } from 'lucide-react-native';
|
||||
import { showErrorAlert, showSuccessAlert } from '../../Constant/ShowDailog';
|
||||
import { logger } from '../../core/observability/logger';
|
||||
import { getBiddingConfig } from './biddingPattern';
|
||||
|
||||
/**
|
||||
* THE bidding panel — serves both auction patterns. The pattern comes from
|
||||
* the selected auction; every forward/reverse difference lives in
|
||||
* biddingPattern.ts, never in this file.
|
||||
*
|
||||
* Registered under both the StartBidding and StartReverseBidding routes so
|
||||
* existing navigation keeps working unchanged.
|
||||
*/
|
||||
const AuctionBiddingScreen = () => {
|
||||
const { colors, spacing, shadows } = useTheme();
|
||||
const SelectedAuction = useSelector((state: any) => state.auction.auction);
|
||||
const loginUser = useSelector((state: any) => state.token.token);
|
||||
// The login session stores the contractor id under `id` (see ProfileTab /
|
||||
// LiveTender); the older `contractorId` key is absent, which is why bid
|
||||
// payloads and "me"/LEADING detection were getting undefined.
|
||||
const contractorId = loginUser?.contractorId ?? loginUser?.id;
|
||||
const Navigation: any = useNavigation();
|
||||
const config = useMemo(
|
||||
() => getBiddingConfig(SelectedAuction?.auctionPattern),
|
||||
[SelectedAuction?.auctionPattern],
|
||||
);
|
||||
const [items, setItems] = useState<any>([]);
|
||||
const [bidValues, setBidValues] = useState<Record<number, number>>({});
|
||||
const [allBoolean, setAllBoolean] = useState({
|
||||
showSuccess: false,
|
||||
showDialog: false,
|
||||
showBackDialog: false,
|
||||
bidPayload: {} as any,
|
||||
oneTimeBid: false,
|
||||
rebid: false,
|
||||
reload: false,
|
||||
});
|
||||
|
||||
const { subscribe, unsubscribe, disconnect, createRoom, placeBid } = useWebSocketService(
|
||||
Variables.webSocketUrl,
|
||||
() => logger.info('stomp', 'connected'),
|
||||
(error: any) => logger.warn('stomp', `connection error: ${error}`),
|
||||
);
|
||||
|
||||
const getAuction = async () => {
|
||||
const selectedRes = await apiCall<any>('get', GET_AUCTION_DETAILS, {}, { id: SelectedAuction.auctionId });
|
||||
if (selectedRes.statusCode === 200 && selectedRes.data.contractorId.length) {
|
||||
setAllBoolean(prev => ({ ...prev, oneTimeBid: true }));
|
||||
}
|
||||
};
|
||||
|
||||
const getOneTimeBidValue = async () => {
|
||||
try {
|
||||
const selectedRes = await apiCall<any>('get', AUCTION_ONE_TIME_BID, {}, { auctionId: SelectedAuction.auctionId });
|
||||
return selectedRes.statusCode === 200 ? selectedRes.data : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
const getAllAcutionItems = async () => {
|
||||
try {
|
||||
const payload = { auctionNumber: SelectedAuction.auctionNumber };
|
||||
let auctionItem: any[] = [];
|
||||
|
||||
const res = await apiCall<any>('get', config.itemsUrl, {}, payload);
|
||||
if (res?.statusCode === 200) { auctionItem = res.data || []; }
|
||||
|
||||
const selectedRes = await apiCall<any>('get', SELECTED_AUCTION_ITEM, {}, payload);
|
||||
const uniqueArray: number[] = Array.from(new Set(selectedRes?.data || []));
|
||||
const filteredItems = auctionItem.filter(item => uniqueArray.includes(item.id));
|
||||
|
||||
if (SelectedAuction.auctionCategories === 'Live') {
|
||||
const result = await createRoom(SelectedAuction.auctionNumber);
|
||||
const liveBids = new Map<number, { bidAmount: number; contractorId: number }>();
|
||||
(result || []).forEach((r: any) => {
|
||||
liveBids.set(r.auctionItemId, { bidAmount: r.bidAmount, contractorId: r.contractorId });
|
||||
});
|
||||
const updatedFilteredItems = filteredItems.map(item => {
|
||||
const bidData = liveBids.get(item.id);
|
||||
return {
|
||||
...item,
|
||||
bidAmount: bidData?.bidAmount ?? config.startValue(item),
|
||||
bidAvilable: bidData?.bidAmount ?? 0,
|
||||
me: bidData?.contractorId === contractorId,
|
||||
};
|
||||
});
|
||||
const initialBids: Record<number, number> = {};
|
||||
updatedFilteredItems.forEach(item => { initialBids[item.id] = Number(item.bidAmount); });
|
||||
setBidValues(initialBids);
|
||||
setItems(updatedFilteredItems);
|
||||
} else {
|
||||
if (SelectedAuction.rebidAllowed) { setAllBoolean(prev => ({ ...prev, rebid: true })); }
|
||||
const result: any[] = await getOneTimeBidValue();
|
||||
const bidMap = new Map<number, any>();
|
||||
result.forEach((bid: any) => bidMap.set(bid.auctionItemId, bid));
|
||||
const mergedData = auctionItem.map((item: any) => {
|
||||
const bid = bidMap.get(item.id);
|
||||
if (!bid) { return { ...item, bidAmount: config.startValue(item), contractorId: item.contractorId }; }
|
||||
return {
|
||||
...item,
|
||||
bidAmount: bid.bidAmount,
|
||||
bidId: bid.bidId,
|
||||
contractorId: bid.contractorId,
|
||||
contractorName: bid.contractorName,
|
||||
me: bid.contractorId === contractorId,
|
||||
bidTime: bid.bidTime,
|
||||
};
|
||||
});
|
||||
const initialBids: Record<number, number> = {};
|
||||
mergedData.forEach(item => { initialBids[item.id] = Number(item.bidAmount); });
|
||||
setBidValues(initialBids);
|
||||
setItems(mergedData);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('bidding', 'fetching auction items failed', { error: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// Live bid updates: subscribe once per room from an effect (never the render
|
||||
// body) and tear the subscription down when the room changes or we unmount.
|
||||
useEffect(() => {
|
||||
const destination = `/topic/room/${SelectedAuction.auctionNumber}`;
|
||||
subscribe(destination, (message: any) => {
|
||||
const result = message[message.length - 1];
|
||||
setItems((prev: any[]) =>
|
||||
prev.map(item =>
|
||||
item.id === result.auctionItemId
|
||||
? { ...item, bidAmount: result.bidAmount, me: result.contractorId === loginUser.contractorId }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
setBidValues(prev => ({ ...prev, [result.auctionItemId]: Number(result.bidAmount) }));
|
||||
});
|
||||
return () => unsubscribe(destination);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [SelectedAuction.auctionNumber]);
|
||||
|
||||
// Fully close the STOMP connection on unmount — a leaked client keeps
|
||||
// reconnecting every 5s and drains battery/data.
|
||||
const disconnectRef = useRef(disconnect);
|
||||
disconnectRef.current = disconnect;
|
||||
useEffect(() => () => disconnectRef.current(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (SelectedAuction.auctionCategories === 'One-time') { getAuction(); }
|
||||
getAllAcutionItems();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allBoolean.reload]);
|
||||
|
||||
// Move a bid one step in the given direction. The next value is computed from
|
||||
// the LIVE `prev` state (no stale closure on rapid taps) and clamped to the
|
||||
// pattern's [floor, ceil] by the config, so the button always moves the value
|
||||
// by a real amount and can never overshoot a bound.
|
||||
const stepBid = (item: any, direction: 'up' | 'down') => {
|
||||
setBidValues(prev => {
|
||||
let current = Number(prev[item.id]);
|
||||
if (!Number.isFinite(current)) { current = config.startValue(item); }
|
||||
const next = direction === 'up'
|
||||
? config.stepUp(current, item)
|
||||
: config.stepDown(current, item);
|
||||
return { ...prev, [item.id]: next };
|
||||
});
|
||||
};
|
||||
|
||||
const submitBid = async (item: any) => {
|
||||
const payload = {
|
||||
itemId: item.id,
|
||||
bidAmount: bidValues[item.id],
|
||||
auctionId: SelectedAuction.auctionId,
|
||||
contractorId,
|
||||
fullName: loginUser.fullName,
|
||||
roomId: SelectedAuction.auctionNumber,
|
||||
};
|
||||
setAllBoolean(prev => ({ ...prev, bidPayload: payload, showDialog: true }));
|
||||
};
|
||||
|
||||
const placeBidSubmit = async (data: any) => {
|
||||
setAllBoolean(prev => ({ ...prev, showDialog: false }));
|
||||
try {
|
||||
const result = await placeBid(data);
|
||||
if (result?.status && result.data) {
|
||||
if (result.message) { showSuccessAlert(result.message); }
|
||||
getAllAcutionItems();
|
||||
} else if (result?.message) {
|
||||
showErrorAlert(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('bidding', 'placing bid failed', { error: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const onBackPress = () => {
|
||||
setAllBoolean(prev => ({ ...prev, showBackDialog: true }));
|
||||
return true;
|
||||
};
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', onBackPress);
|
||||
return () => sub.remove();
|
||||
}, []),
|
||||
);
|
||||
|
||||
const styles = useMemo(() => makeStyles(colors, spacing, shadows), [colors, spacing, shadows]);
|
||||
|
||||
const biddingEndDate =
|
||||
SelectedAuction?.auctionStatus === 'Amended'
|
||||
? SelectedAuction?.amendedkeyDates?.auctionBidding?.endDateTime
|
||||
: SelectedAuction?.keyDates?.auctionBidding?.endDateTime;
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<InsideHeader title="Bidding Panel" showArrow />
|
||||
|
||||
{/* ── Top Info Band ── */}
|
||||
<Animated.View entering={FadeInDown.springify()} style={styles.infoBand}>
|
||||
{/* Department row */}
|
||||
<View style={styles.deptRow}>
|
||||
<View style={[styles.deptIcon, { backgroundColor: `${colors.primary}15` }]}>
|
||||
<Building2 size={16} color={colors.primary} strokeWidth={2} />
|
||||
</View>
|
||||
<AppText variant="bodySmall" color={colors.text} bold numberOfLines={1} style={styles.deptName as any}>
|
||||
{SelectedAuction.deptName ?? 'Auction Department'}
|
||||
</AppText>
|
||||
</View>
|
||||
|
||||
{/* Timer card */}
|
||||
<View style={[styles.timerCard, { backgroundColor: `${colors.primary}10`, borderColor: `${colors.primary}25` }]}>
|
||||
<View style={styles.timerLabelRow}>
|
||||
<Clock4 size={13} color={colors.primary} strokeWidth={2} />
|
||||
<AppText variant="caption" color={colors.primary} bold style={styles.timerLabel as any}>
|
||||
BIDDING ENDS IN
|
||||
</AppText>
|
||||
</View>
|
||||
<CountdownTimer
|
||||
endDate={biddingEndDate}
|
||||
myStyle={{ fontSize: 28, fontWeight: '900', color: colors.primary, letterSpacing: 1 }}
|
||||
size={28}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Auction chip */}
|
||||
<View style={styles.chipRow}>
|
||||
<View style={[styles.chip, { backgroundColor: `${colors.primary}12` }]}>
|
||||
<Gavel size={12} color={colors.primary} strokeWidth={2} />
|
||||
<AppText variant="caption" color={colors.primary} bold style={styles.chipText as any}>
|
||||
{SelectedAuction.auctionNumber}
|
||||
</AppText>
|
||||
</View>
|
||||
<View style={[styles.chip, { backgroundColor: SelectedAuction.auctionCategories === 'Live' ? `${colors.success}15` : `${colors.textSecondary}12` }]}>
|
||||
<View style={[styles.liveDot, { backgroundColor: SelectedAuction.auctionCategories === 'Live' ? colors.success : colors.textSecondary }]} />
|
||||
<AppText
|
||||
variant="caption"
|
||||
color={SelectedAuction.auctionCategories === 'Live' ? colors.success : colors.textSecondary}
|
||||
bold
|
||||
style={styles.chipText as any}
|
||||
>
|
||||
{SelectedAuction.auctionCategories ?? 'Auction'}
|
||||
</AppText>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* ── Items List ── */}
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={item => item.id.toString()}
|
||||
renderItem={({ item, index }) => (
|
||||
<AuctionBiddingCard
|
||||
item={{ ...item, index }}
|
||||
bidValue={bidValues[item.id] || 0}
|
||||
onIncrease={() => stepBid(item, 'up')}
|
||||
onDecrease={() => stepBid(item, 'down')}
|
||||
onPlaceBid={() => submitBid(item)}
|
||||
SelectedAuction={SelectedAuction}
|
||||
allBoolean={allBoolean}
|
||||
setAllBoolean={setAllBoolean}
|
||||
config={config}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
|
||||
{/* ── Success overlay ── */}
|
||||
{allBoolean.showSuccess && (
|
||||
<View style={styles.overlay}>
|
||||
<LottieView
|
||||
source={require('../../lottie/Success.json')}
|
||||
autoPlay
|
||||
loop={false}
|
||||
style={styles.successLottie}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ConfirmBidModal
|
||||
visible={allBoolean.showDialog}
|
||||
onClose={() => setAllBoolean(prev => ({ ...prev, showDialog: false }))}
|
||||
onConfirm={() => placeBidSubmit(allBoolean.bidPayload)}
|
||||
bidAmount={allBoolean.bidPayload?.bidAmount}
|
||||
/>
|
||||
|
||||
<GoBackModal
|
||||
visible={allBoolean.showBackDialog}
|
||||
onClose={() => setAllBoolean(prev => ({ ...prev, showBackDialog: false }))}
|
||||
onConfirm={() => {
|
||||
setAllBoolean(prev => ({ ...prev, showBackDialog: false }));
|
||||
Navigation.dispatch(
|
||||
CommonActions.reset({
|
||||
index: 0,
|
||||
routes: [{ name: NavigationString.Home }],
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuctionBiddingScreen;
|
||||
|
||||
/* ─── Styles factory ─── */
|
||||
const makeStyles = (colors: any, spacing: any, shadows: any) =>
|
||||
StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
|
||||
/* Info band */
|
||||
infoBand: {
|
||||
backgroundColor: colors.surface,
|
||||
paddingHorizontal: spacing.m,
|
||||
paddingVertical: spacing.m,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.border,
|
||||
gap: spacing.s,
|
||||
...shadows.light,
|
||||
},
|
||||
deptRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.s,
|
||||
},
|
||||
deptIcon: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 9,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
deptName: {
|
||||
flex: 1,
|
||||
} as any,
|
||||
|
||||
/* Timer */
|
||||
timerCard: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1.2,
|
||||
paddingVertical: spacing.m,
|
||||
paddingHorizontal: spacing.m,
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
timerLabelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
timerLabel: {
|
||||
letterSpacing: 1.2,
|
||||
} as any,
|
||||
|
||||
/* Chips */
|
||||
chipRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.s,
|
||||
},
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.s + 4,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 20,
|
||||
},
|
||||
chipText: {
|
||||
letterSpacing: 0.3,
|
||||
} as any,
|
||||
liveDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
},
|
||||
|
||||
listContent: {
|
||||
padding: spacing.m,
|
||||
paddingBottom: 120,
|
||||
},
|
||||
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: colors.overlay,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 99,
|
||||
},
|
||||
successLottie: { width: 200, height: 200 },
|
||||
});
|
||||