feat: architecture refactor — core layer, spreadsheet engine, envelope bidding, tests
CI / quality (push) Waiting to run

- Add src/core/ (config, errors, events, http, observability, storage, query)
- Add src/Spreadsheet/ engine with native POI backend and Excel bridge
- Add Envelope C spreadsheet flow and tender bidding envelope modules
- Consolidate components into src/components/, remove legacy Component/ and Hooks/
- Add registration state/validation, auth session guard, profile edit modules
- Add unit tests across axios, redux, bidding, registration, encryption, core
- Add CI workflow, docs (architecture, audit, DSC API spec), keystore example
- Remove tracked keystore binary; ignore scripts.zip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Abin Toppo
2026-07-16 10:20:22 +05:30
co-authored by Claude Fable 5
parent 9acec328a7
commit 1d7646d938
217 changed files with 11068 additions and 6855 deletions
+31
View File
@@ -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
+11
View File
@@ -36,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/
@@ -81,3 +89,6 @@ rn-config-output.json
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Local archives
scripts.zip
View File
+27 -27
View File
@@ -1,44 +1,44 @@
import React from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { LogBox, View } from 'react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { View } from 'react-native';
import { QueryClientProvider } from '@tanstack/react-query';
import { AlertNotificationRoot } from 'react-native-alert-notification';
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';
import AppStatusBar from './src/components/AppStatusBar';
LogBox.ignoreAllLogs(true);
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';
const App = () => {
const queryClient = new QueryClient();
const insets = useSafeAreaInsets()
global.TextEncoder = TextEncoder;
const insets = useSafeAreaInsets();
return (
<NavigationContainer ref={navigationRef}>
<QueryClientProvider client={queryClient}>
<GestureHandlerRootView style={{ flex: 1 }}>
<AlertNotificationRoot>
<Provider store={store}>
<PersistGate persistor={persistor}>
<View style={{ flex: 1, backgroundColor: colors.white, paddingBottom: insets.bottom }}>
<AppStatusBar />
<Routes />
</View>
</PersistGate>
</Provider>
</AlertNotificationRoot>
</GestureHandlerRootView>
</QueryClientProvider>
</NavigationContainer>
<ErrorBoundary>
<NavigationContainer ref={navigationRef}>
<QueryClientProvider client={queryClient}>
<GestureHandlerRootView style={{ flex: 1 }}>
<AlertNotificationRoot>
<Provider store={store}>
<PersistGate persistor={persistor}>
<View style={{ flex: 1, backgroundColor: colors.white, paddingBottom: insets.bottom }}>
<AppStatusBar />
<AuthSessionGuard />
<Routes />
</View>
</PersistGate>
</Provider>
</AlertNotificationRoot>
</GestureHandlerRootView>
</QueryClientProvider>
</NavigationContainer>
</ErrorBoundary>
);
};
export default App;
+117 -65
View File
@@ -1,97 +1,149 @@
This is a new [**React Native**](http://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](http://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](http://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](http://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](http://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](http://reactnative.dev/docs/integration-with-existing-apps).
- If you're curious to learn more about React Native, check out the [docs](http://reactnative.dev/docs/getting-started).
# Troubleshooting
If you're having issues getting the above steps to work, see the [Troubleshooting](http://reactnative.dev/docs/troubleshooting) page.
# Learn More
To learn more about React Native, take a look at the following resources:
- [React Native Website](http://reactnative.dev) - learn more about React Native.
- [Getting Started](http://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment.
- [Learn the Basics](http://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**.
- [Blog](http://reactnative.dev/blog) - read the latest official React Native **Blog** posts.
- [`@facebook/react-native`](http://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).
Binary file not shown.
+44 -4
View File
@@ -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 http://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 {
-2
View File
@@ -5,7 +5,6 @@
<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" />
@@ -16,7 +15,6 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true">
<activity
@@ -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,6 +9,19 @@ import com.facebook.react.defaults.DefaultReactActivityDelegate
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// FLAG_SECURE: the app displays sealed-bid amounts, Aadhaar OTPs and other
// regulated PII. Block screenshots, screen recording and the app-switcher
// thumbnail in release builds; debug builds stay capturable for QA.
if (!BuildConfig.DEBUG) {
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
}
}
/**
* Returns the name of the main component registered from JavaScript.
*/
@@ -11,6 +11,7 @@ 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
class MainApplication : Application(), ReactApplication {
@@ -18,8 +19,7 @@ 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())
}
override fun getJSMainModuleName(): String = "index"
@@ -0,0 +1,407 @@
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))
}
// Ask consumers (Excel) to recalculate; HyperFormula already did in-app.
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)
}
}
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()
}
@@ -1,6 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Allow ALL cleartext (http://) traffic for every build, including release.
Needed so the app can reach http://eproc-backend.lincpay/ over plain HTTP. -->
<!--
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="true" />
<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 -1
View File
@@ -1,7 +1,7 @@
buildscript {
ext {
buildToolsVersion = "35.0.0"
minSdkVersion = 24
minSdkVersion = 26
compileSdkVersion = 35
targetSdkVersion = 35
ndkVersion = "27.1.12297006"
+11
View File
@@ -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
+10 -3
View File
@@ -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'],
},
},
};
+153
View File
@@ -0,0 +1,153 @@
# 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** navigates to `'TenderDetail'`, an
unregistered route (a pre-existing latent bug). The intended destination is
ambiguous (the tile already renders an in-tile modal), so it awaits a product
decision rather than a guessed target.
---
## 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.
+363
View File
@@ -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`
+15
View File
@@ -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.
+4 -2
View File
@@ -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 />
+5
View File
@@ -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)/',
],
};
-8
View File
@@ -26,14 +26,6 @@ const config = {
/node_modules\/.*\/android\/build\/.*/,
/node_modules\/.*\/android\/\.gradle\/.*/,
]
},
watcher: {
additionalExclusions: [
// Also exclude from the watcher layer for safety
"**/android/.cxx/**",
"**/android/build/**",
"**/android/.gradle/**",
]
}
};
+247 -82
View File
@@ -19,6 +19,7 @@
"@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",
@@ -27,8 +28,10 @@
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-native": "5.0.0",
"exceljs": "4.4.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",
@@ -47,6 +50,7 @@
"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",
@@ -83,9 +87,9 @@
"@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",
"jsencrypt": "^3.5.4",
"prettier": "2.8.8",
"react-native-bundle-visualizer": "3.1.3",
"react-test-renderer": "19.0.0",
@@ -3820,6 +3824,21 @@
}
}
},
"node_modules/@shopify/flash-list": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-1.8.3.tgz",
"integrity": "sha512-vXuj6JyuMjONVOXjEhWFeaONPuWN/53Cl2LeyeM8TZ0JzUcNU+BE6iyga1/yyJeDf0K7YPgAE/PcUX2+DM1LiA==",
"license": "MIT",
"dependencies": {
"recyclerlistview": "4.2.3",
"tslib": "2.8.1"
},
"peerDependencies": {
"@babel/runtime": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
@@ -4880,21 +4899,21 @@
"license": "MIT"
},
"node_modules/archiver": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz",
"integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/archiver/-/archiver-4.0.2.tgz",
"integrity": "sha512-B9IZjlGwaxF33UN4oPbfBkyA4V1SxNLeIhR1qY8sRXSsbdUkEHrrOvwlYFPx+8uQeCe9M+FG6KgO+imDmQ79CQ==",
"license": "MIT",
"dependencies": {
"archiver-utils": "^2.1.0",
"async": "^3.2.4",
"async": "^3.2.0",
"buffer-crc32": "^0.2.1",
"glob": "^7.1.6",
"readable-stream": "^3.6.0",
"readdir-glob": "^1.1.2",
"tar-stream": "^2.2.0",
"zip-stream": "^4.1.0"
"tar-stream": "^2.1.2",
"zip-stream": "^3.0.1"
},
"engines": {
"node": ">= 10"
"node": ">= 8"
}
},
"node_modules/archiver-utils": {
@@ -5299,6 +5318,13 @@
"@babel/plugin-syntax-flow": "^7.12.1"
}
},
"node_modules/babel-plugin-transform-remove-console": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz",
"integrity": "sha512-88blrUrMX3SPiGkT1GnvVY8E/7A+k6oj3MNvUtTIxJflFzXTw1bHkuJ/y039ouhFMp2prRn5cQGzokViYi1dsg==",
"dev": true,
"license": "MIT"
},
"node_modules/babel-preset-current-node-syntax": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz",
@@ -5499,6 +5525,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -5806,6 +5833,15 @@
"node": ">=10"
}
},
"node_modules/chevrotain": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-6.5.0.tgz",
"integrity": "sha512-BwqQ/AgmKJ8jcMEjaSnfMybnKMgGTrtDKowfTP3pX4jwVy0kNjRsT/AP6h+wC3+3NC+X8X15VWBnTCQlX+wQFg==",
"license": "Apache-2.0",
"dependencies": {
"regexp-to-ast": "0.4.0"
}
},
"node_modules/chrome-launcher": {
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz",
@@ -6039,18 +6075,54 @@
}
},
"node_modules/compress-commons": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz",
"integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-3.0.0.tgz",
"integrity": "sha512-FyDqr8TKX5/X0qo+aVfaZ+PVmNJHJeckFBlq8jZGSJOgnynhfifoyl24qaqdUdDIBe0EVTHByN6NAkqYvE/2Xg==",
"license": "MIT",
"dependencies": {
"buffer-crc32": "^0.2.13",
"crc32-stream": "^4.0.2",
"crc32-stream": "^3.0.1",
"normalize-path": "^3.0.0",
"readable-stream": "^3.6.0"
"readable-stream": "^2.3.7"
},
"engines": {
"node": ">= 10"
"node": ">= 8"
}
},
"node_modules/compress-commons/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/compress-commons/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/compress-commons/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/compress-commons/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/compressible": {
@@ -6201,6 +6273,15 @@
}
}
},
"node_modules/crc": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz",
"integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==",
"license": "MIT",
"dependencies": {
"buffer": "^5.1.0"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@@ -6213,17 +6294,41 @@
"node": ">=0.8"
}
},
"node_modules/crc32-stream": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz",
"integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
"node_modules/crc/node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"crc-32": "^1.2.0",
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/crc32-stream": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-3.0.1.tgz",
"integrity": "sha512-mctvpXlbzsvK+6z8kJwSJ5crm7yBwrQMTybJzMw1O4lLGJqjlDCXY2Zw7KheiA6XBEcBmfLx1D88mjRGVJtY9w==",
"license": "MIT",
"dependencies": {
"crc": "^3.4.4",
"readable-stream": "^3.4.0"
},
"engines": {
"node": ">= 10"
"node": ">= 6.9.0"
}
},
"node_modules/create-jest": {
@@ -7656,29 +7761,30 @@
}
},
"node_modules/exceljs": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz",
"integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==",
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/exceljs/-/exceljs-3.10.0.tgz",
"integrity": "sha512-c48puhmEvXOBB/FqX0GLkMr7GLOySTHIbEaDSYiSXjSk0UMMAKwWpYT+VNv9IVnRso3d/8gMKF9k0lBbxUdMaw==",
"license": "MIT",
"dependencies": {
"archiver": "^5.0.0",
"dayjs": "^1.8.34",
"fast-csv": "^4.3.1",
"jszip": "^3.10.1",
"archiver": "^4.0.1",
"dayjs": "^1.8.26",
"fast-csv": "^4.1.2",
"jszip": "^3.4.0",
"proxyquire": "^2.1.3",
"readable-stream": "^3.6.0",
"saxes": "^5.0.1",
"tmp": "^0.2.0",
"unzipper": "^0.10.11",
"uuid": "^8.3.0"
"uuid": "^7.0.3"
},
"engines": {
"node": ">=8.3.0"
}
},
"node_modules/exceljs/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",
"integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"license": "MIT",
"bin": {
@@ -7892,6 +7998,19 @@
"node": ">=10"
}
},
"node_modules/fill-keys": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz",
"integrity": "sha512-tcgI872xXjwFF4xgQmLxi76GnwJG3g/3isB1l4/G5Z4zrbddGpBjqZCO9oEAcB5wX0Hj/5iQB3toxfO7in1hHA==",
"license": "MIT",
"dependencies": {
"is-object": "~1.0.1",
"merge-descriptors": "~1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -8666,6 +8785,16 @@
"node": ">=10.17.0"
}
},
"node_modules/hyperformula": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/hyperformula/-/hyperformula-2.7.1.tgz",
"integrity": "sha512-mpVF5zOyNpksZzgTaCQyRAzdC/X43+taz5x1n7zNbs/PUUv0AuLmsy2yfihCr+vihUzN/pk+gXBbOfNpXKOpgA==",
"license": "GPL-3.0-only",
"dependencies": {
"chevrotain": "^6.5.0",
"tiny-emitter": "^2.1.0"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -9115,6 +9244,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-object": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
"integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-path-inside": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
@@ -10371,7 +10509,6 @@
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/jsencrypt/-/jsencrypt-3.5.4.tgz",
"integrity": "sha512-kNjfYEMNASxrDGsmcSQh/rUTmcoRfSUkxnAz+MMywM8jtGu+fFEZ3nJjHM58zscVnwR0fYmG9sGkTDjqUdpiwA==",
"dev": true,
"license": "MIT"
},
"node_modules/jsesc": {
@@ -10678,7 +10815,6 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.defaults": {
@@ -11067,6 +11203,15 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/merge-options": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
@@ -11633,6 +11778,12 @@
"node": ">=10"
}
},
"node_modules/module-not-found-error": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz",
"integrity": "sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g==",
"license": "MIT"
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
@@ -12426,6 +12577,17 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/proxyquire": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz",
"integrity": "sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==",
"license": "MIT",
"dependencies": {
"fill-keys": "^1.0.2",
"module-not-found-error": "^1.0.1",
"resolve": "^1.11.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -13021,6 +13183,19 @@
"react-native": "*"
}
},
"node_modules/react-native-keychain": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/react-native-keychain/-/react-native-keychain-10.0.0.tgz",
"integrity": "sha512-YzPKSAnSzGEJ12IK6CctNLU79T1W15WDrElRQ+1/FsOazGX9ucFPTQwgYe8Dy8jiSEDJKM4wkVa3g4lD2Z+Pnw==",
"license": "MIT",
"workspaces": [
"KeychainExample",
"website"
],
"engines": {
"node": ">=16"
}
},
"node_modules/react-native-linear-gradient": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/react-native-linear-gradient/-/react-native-linear-gradient-2.8.3.tgz",
@@ -13318,25 +13493,19 @@
"node": ">= 6"
}
},
"node_modules/readdir-glob": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
"node_modules/recyclerlistview": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/recyclerlistview/-/recyclerlistview-4.2.3.tgz",
"integrity": "sha512-STR/wj/FyT8EMsBzzhZ1l2goYirMkIgfV3gYEPxI3Kf3lOnu6f7Dryhyw7/IkQrgX5xtTcDrZMqytvteH9rL3g==",
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.1.0"
}
},
"node_modules/readdir-glob/node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"lodash.debounce": "4.0.8",
"prop-types": "15.8.1",
"ts-object-utils": "0.0.5"
},
"engines": {
"node": ">=10"
"peerDependencies": {
"react": ">= 15.2.1",
"react-native": ">= 0.30.0"
}
},
"node_modules/redux": {
@@ -13409,6 +13578,12 @@
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
},
"node_modules/regexp-to-ast": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.4.0.tgz",
"integrity": "sha512-4qf/7IsIKfSNHQXSwial1IFmfM1Cc/whNBQqRwe0V2stPe7KmN1U0tWQiIx6JiirgSrisjE0eECdNf7Tav1Ntw==",
"license": "MIT"
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -13496,7 +13671,6 @@
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -14830,6 +15004,12 @@
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
"license": "MIT"
},
"node_modules/tiny-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==",
"license": "MIT"
},
"node_modules/tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
@@ -14837,9 +15017,9 @@
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"license": "MIT",
"engines": {
"node": ">=14.14"
@@ -14900,6 +15080,12 @@
"typescript": ">=4.2.0"
}
},
"node_modules/ts-object-utils": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/ts-object-utils/-/ts-object-utils-0.0.5.tgz",
"integrity": "sha512-iV0GvHqOmilbIKJsfyfJY9/dNHCs969z3so90dQWsO1eMMozvTpnB1MEaUbb3FYtZTGjv5sIy/xmslEz0Rg2TA==",
"license": "ISC"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -15703,38 +15889,17 @@
}
},
"node_modules/zip-stream": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz",
"integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-3.0.1.tgz",
"integrity": "sha512-r+JdDipt93ttDjsOVPU5zaq5bAyY+3H19bDrThkvuVxC0xMQzU1PJcS6D+KrP3u96gH9XLomcHPb+2skoDjulQ==",
"license": "MIT",
"dependencies": {
"archiver-utils": "^3.0.4",
"compress-commons": "^4.1.2",
"archiver-utils": "^2.1.0",
"compress-commons": "^3.0.0",
"readable-stream": "^3.6.0"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/zip-stream/node_modules/archiver-utils": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz",
"integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
"license": "MIT",
"dependencies": {
"glob": "^7.2.3",
"graceful-fs": "^4.2.0",
"lazystream": "^1.0.0",
"lodash.defaults": "^4.2.0",
"lodash.difference": "^4.5.0",
"lodash.flatten": "^4.4.0",
"lodash.isplainobject": "^4.0.6",
"lodash.union": "^4.6.0",
"normalize-path": "^3.0.0",
"readable-stream": "^3.6.0"
},
"engines": {
"node": ">= 10"
"node": ">= 8"
}
}
}
+6 -2
View File
@@ -24,14 +24,17 @@
"@reduxjs/toolkit": "2.11.2",
"@stomp/stompjs": "7.2.1",
"@tanstack/react-query": "5.90.12",
"@shopify/flash-list": "^1.7.6",
"axios": "1.13.2",
"buffer": "6.0.3",
"crypto-js": "^4.2.0",
"exceljs": "^3.4.0",
"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",
"hyperformula": "^2.7.1",
"jsencrypt": "^3.5.4",
"lottie-react-native": "7.3.4",
"lucide-react-native": "0.562.0",
"moment": "2.30.1",
@@ -50,6 +53,7 @@
"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",
@@ -86,9 +90,9 @@
"@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",
"jsencrypt": "^3.5.4",
"prettier": "2.8.8",
"react-native-bundle-visualizer": "3.1.3",
"react-test-renderer": "19.0.0",
+6 -3
View File
@@ -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).
+35 -120
View File
@@ -1,102 +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 AsyncStorage from '@react-native-async-storage/async-storage';
import { resetAndNavigate } from '../Navigation/NavigationService';
import NavigationString from '../Constant/NavigationString';
import { request } from '../core/http/client';
import { isAppError } from '../core/http/AppError';
import { logger } from '../core/observability/logger';
import { attachDevHttpLogging } from '../core/observability/httpLogging';
// ============================================================
// GLOBAL API LOGGER
// Logs EVERY axios call (apiCall wrapper + direct axios.*),
// including full URL, method, payload, params, headers,
// response data and full error response.
// React-Native friendly (no %c CSS — that doesn't work in Metro/Logcat).
// ============================================================
// Build the full URL exactly as it goes on the wire: baseURL + url + ?query
const buildFullUrl = (config: any = {}): string => {
const base = config.baseURL || '';
const path = config.url || '';
let full = base ? `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}` : path;
const params = config.params;
if (params && typeof params === 'object') {
const qs = Object.keys(params)
.filter(k => params[k] !== undefined && params[k] !== null)
.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
.join('&');
if (qs) full += (full.includes('?') ? '&' : '?') + qs;
}
return full;
};
// Make any payload safe/readable for logging. console.log already handles
// nested objects and circular refs, so we only special-case FormData
// (which otherwise prints as an opaque object).
const safe = (val: any): any => {
if (val == null) return val;
if (typeof FormData !== 'undefined' && val instanceof FormData) {
const parts = (val as any)?._parts;
return Array.isArray(parts)
? `[FormData] ${parts.map((p: any) => p?.[0]).join(', ')}`
: '[FormData]';
}
return val;
};
// Register interceptors only once (module may be re-imported on fast-refresh)
const g: any = globalThis as any;
if (!g.__API_LOGGER_INSTALLED__) {
g.__API_LOGGER_INSTALLED__ = true;
axios.interceptors.request.use(
config => {
const method = (config.method || 'GET').toUpperCase();
const fullUrl = buildFullUrl(config);
console.log('\n==================== 🚀 API REQUEST ====================');
console.log('METHOD →', method);
console.log('FULL URL →', fullUrl);
console.log('PARAMS →', safe(config.params) ?? {});
console.log('PAYLOAD →', safe(config.data) ?? {});
console.log('HEADERS →', safe(config.headers) ?? {});
console.log('========================================================');
return config;
},
error => {
console.log('❌ REQUEST SETUP ERROR →', error?.message, error);
return Promise.reject(error);
},
);
axios.interceptors.response.use(
response => {
const method = (response.config.method || 'GET').toUpperCase();
const fullUrl = buildFullUrl(response.config);
console.log('\n==================== ✅ API RESPONSE ====================');
console.log('METHOD →', method);
console.log('FULL URL →', fullUrl);
console.log('STATUS →', response.status);
console.log('RESPONSE →', safe(response.data));
console.log('=========================================================');
return response;
},
error => {
const cfg = error?.config || {};
const method = (cfg.method || 'GET').toUpperCase();
const fullUrl = buildFullUrl(cfg);
console.log('\n==================== ❌ API ERROR ====================');
console.log('METHOD →', method);
console.log('FULL URL →', fullUrl);
console.log('REQ PAYLOAD→', safe(cfg.data) ?? {});
console.log('STATUS →', error?.response?.status ?? 'NO RESPONSE / NETWORK');
console.log('MESSAGE →', error?.response?.data?.message || error?.message);
console.log('ERROR DATA →', safe(error?.response?.data));
console.log('FULL ERROR →', error?.toJSON ? error.toJSON() : safe(error));
console.log('======================================================');
return Promise.reject(error);
},
);
}
// 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',
@@ -105,38 +26,32 @@ export const apiCall = async <R, D = {}>(
params?: any,
contentType?: string
): Promise<R> => {
const token: any = store.getState().token.token;
try {
// Request/response/error are logged globally by the axios interceptors above.
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) {
// ===== AUTH ERROR =====
if (error?.response?.status === 401) {
console.warn('⚠ JWT ERROR → Token expired or malformed');
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;
}
return {
success: false,
message:
error?.response?.data?.message || 'No Response From Server',
...(error?.response?.data || {})
} as R;
return { success: false, message: 'No Response From Server' } as R;
}
};
+58
View File
@@ -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' });
});
});
-163
View File
@@ -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,
},
});
-45
View File
@@ -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,
},
});
-90
View File
@@ -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,
},
});
-6
View File
@@ -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,379 +0,0 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Dimensions, ScrollView } 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 (
<>
<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,
},
});
-18
View File
@@ -1,18 +0,0 @@
import React from 'react';
import { View, ActivityIndicator } from 'react-native';
interface LoaderType {
size?: 'small' | 'large';
color?: string;
style?: any;
}
const Loader = ({ size = "large", color = '#000', style }: LoaderType) => {
return (
<View style={[{ justifyContent: 'center', alignItems: 'center' , flex:1}, style]}>
<ActivityIndicator size={size} color={color} />
</View>
);
};
export default Loader;
-106
View File
@@ -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,67 +0,0 @@
import { StyleSheet, Text, TouchableOpacity, View, 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 (
<View
style={[styles.primaryButton, style]}
>
<TouchableOpacity
style={styles.touchable}
onPress={onPress}
activeOpacity={0.82}
disabled={disabled}
>
<Text style={styles.buttonText}>{title}</Text>
</TouchableOpacity>
</View>
);
};
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,
},
});
-256
View File
@@ -1,256 +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={() => {
}}>
<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',
},
});
-10
View File
@@ -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};
-69
View File
@@ -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;
+17 -7
View File
@@ -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',
+3 -3
View File
@@ -24,8 +24,8 @@ const NavigationString = {
ResetPassword:'ResetPassword',
KycWebViewScreen:'KycWebViewScreen',
StageThreeScreen:'StageThree',
DecryptionScreen:'DecryptionScreen',
DeviceBinding: 'DeviceBinding',
DscBinding: 'DscBinding'
}
DscBinding: 'DscBinding',
EnvelopeCSpreadsheet: 'EnvelopeCSpreadsheet'
} as const
export default NavigationString
+1 -1
View File
@@ -1,4 +1,4 @@
import { ALERT_TYPE, Dialog, Root, Toast } from 'react-native-alert-notification';
import { ALERT_TYPE, Toast } from 'react-native-alert-notification';
const showSuccessAlert = (text: string) => {
Toast.show({
+12 -4
View File
@@ -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;
-15
View File
@@ -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};
};
-20
View File
@@ -1,20 +0,0 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
export const useCreate = <T, V>(
url: string,
options?: UseMutationOptions<T, Error, V>
) => {
const queryClient = useQueryClient();
return useMutation<T, Error, V>({
mutationFn: async (variables: V) => {
const { data } = await apiClient.post<T>(url, variables);
return data;
},
onSuccess: () => {
// Invalidate queries if needed, handled by component or passed in options
},
...options,
});
};
-17
View File
@@ -1,17 +0,0 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
export const useDelete = <T>(
url: string,
options?: UseMutationOptions<T, Error, void>
) => {
const queryClient = useQueryClient();
return useMutation<T, Error, void>({
mutationFn: async () => {
const { data } = await apiClient.delete<T>(url);
return data;
},
...options,
});
};
-17
View File
@@ -1,17 +0,0 @@
import { useQuery, UseQueryOptions, QueryKey } from '@tanstack/react-query';
import apiClient from '../api/client';
export const useFetch = <T>(
key: QueryKey,
url: string,
options?: UseQueryOptions<T, Error>
) => {
return useQuery<T, Error>({
queryKey: key,
queryFn: async () => {
const { data } = await apiClient.get<T>(url);
return data;
},
...options,
});
};
-17
View File
@@ -1,17 +0,0 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
export const useUpdate = <T, V>(
url: string,
options?: UseMutationOptions<T, Error, V>
) => {
const queryClient = useQueryClient();
return useMutation<T, Error, V>({
mutationFn: async (variables: V) => {
const { data } = await apiClient.put<T>(url, variables);
return data;
},
...options,
});
};
+32
View File
@@ -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;
+10 -8
View File
@@ -1,7 +1,6 @@
import React, { useEffect } from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Dimensions,
@@ -24,10 +23,6 @@ import { useTheme } from '../Hooks/useTheme';
import { typography } from '../theme/typography';
// import EditableExcel from '../Screens/Tender/Component/EnvelopeCExcelPreview';
const { width } = Dimensions.get('window');
const TAB_BAR_HEIGHT = 70; // Slightly taller for premium feel
const TAB_BUTTON_WIDTH = width / 4;
const Tab = createBottomTabNavigator();
const TabButton = ({ route, isFocused, onPress }: any) => {
@@ -55,9 +50,11 @@ const TabButton = ({ route, isFocused, onPress }: any) => {
scale.value = withTiming(0, { duration: 200 });
translateY.value = withSpring(0, { damping: 12, stiffness: 100 });
}
// Intentionally runs only when isFocused changes; other deps are stable or recreated each render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFocused]);
const animatedPillStyle = useAnimatedStyle(() => {
useAnimatedStyle(() => {
return {
transform: [{ scale: scale.value }],
opacity: interpolate(scale.value, [0, 1], [0, 1], Extrapolation.CLAMP),
@@ -86,6 +83,9 @@ const TabButton = ({ route, isFocused, onPress }: any) => {
onPress={onPress}
style={styles.tabButton}
activeOpacity={0.8}
accessibilityRole="tab"
accessibilityState={{ selected: isFocused }}
accessibilityLabel={`${route.name} tab`}
>
<View style={styles.tabContent}>
<Animated.View style={animatedIconStyle}>
@@ -119,8 +119,8 @@ const TabButton = ({ route, isFocused, onPress }: any) => {
);
};
const CustomTabBar = ({ state, descriptors, navigation }: any) => {
const { colors, shadows } = useTheme();
const CustomTabBar = ({ state, navigation }: any) => {
const { colors } = useTheme();
const { width: windowWidth } = Dimensions.get('window');
const TAB_BAR_WIDTH = windowWidth - 40; // 20 margin each side
const TAB_PADDING_H = 10; // 5 padding left + 5 padding right
@@ -134,6 +134,8 @@ const CustomTabBar = ({ state, descriptors, navigation }: any) => {
stiffness: 150,
mass: 0.5,
});
// Intentionally runs only when the selected tab index changes; other deps are stable or recreated each render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.index]);
const animatedIndicatorStyle = useAnimatedStyle(() => {
-100
View File
@@ -1,100 +0,0 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity, Switch, Image } from 'react-native';
import { DrawerContentScrollView, DrawerItemList } from '@react-navigation/drawer';
import { useTheme } from '../Hooks/useTheme';
import AppText from '../components/AppText';
import { Moon, Sun, Monitor } from 'lucide-react-native';
import ImagePath from '../Constant/ImagePath';
const CustomDrawer = (props: any) => {
const { colors, isDarkMode, themeMode, toggleTheme } = useTheme();
const renderThemeOption = (mode: 'light' | 'dark' | 'system', label: string, Icon: any) => {
const isActive = themeMode === mode;
return (
<TouchableOpacity
style={[
styles.themeOption,
isActive && { backgroundColor: colors.primary + '20' } // 20% opacity
]}
onPress={() => toggleTheme(mode)}
>
<Icon size={20} color={isActive ? colors.primary : colors.textSecondary} />
<AppText variant="medium" style={{ marginLeft: 10, color: isActive ? colors.primary : colors.textSecondary }}>
{label}
</AppText>
</TouchableOpacity>
);
};
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<DrawerContentScrollView {...props} contentContainerStyle={{ paddingTop: 0 }}>
{/* Header */}
<View style={[styles.header, { backgroundColor: colors.surface, borderBottomColor: colors.border }]}>
<Image source={ImagePath.Icon.Logo} style={styles.logo} resizeMode="contain" />
<AppText variant="bold" size={20} style={{ color: colors.text }}>ProcureLinc</AppText>
<AppText variant="regular" size={12} style={{ color: colors.textSecondary }}>Smart Procurement</AppText>
</View>
<View style={{ flex: 1, paddingTop: 10 }}>
<DrawerItemList {...props} />
</View>
{/* Theme Switch Section */}
<View style={[styles.section, { borderTopColor: colors.border }]}>
<AppText variant="bold" style={{ marginLeft: 16, marginBottom: 10, color: colors.text }}>Running Theme</AppText>
<View style={styles.themeRow}>
{renderThemeOption('light', 'Light', Sun)}
{renderThemeOption('dark', 'Dark', Moon)}
{renderThemeOption('system', 'System', Monitor)}
</View>
</View>
</DrawerContentScrollView>
<View style={[styles.footer, { borderTopColor: colors.border }]}>
<AppText variant="regular" size={12} style={{ color: colors.textSecondary, textAlign: 'center' }}>
Version 1.0.0
</AppText>
</View>
</View>
);
};
const styles = StyleSheet.create({
header: {
padding: 20,
paddingTop: 50, // safe area approximation
alignItems: 'center',
borderBottomWidth: 1,
},
logo: {
width: 80,
height: 80,
borderRadius: 40,
marginBottom: 10,
},
section: {
padding: 10,
borderTopWidth: 1,
marginTop: 'auto',
marginBottom: 20
},
themeRow: {
flexDirection: 'row',
justifyContent: 'space-around',
},
themeOption: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
borderRadius: 12,
flexDirection: 'row'
},
footer: {
padding: 20,
borderTopWidth: 1,
}
});
export default CustomDrawer;
-40
View File
@@ -1,40 +0,0 @@
import React from 'react';
import { createDrawerNavigator } from '@react-navigation/drawer';
import BottomTab from './Bottom';
import CustomDrawer from './CustomDrawer';
import { useTheme } from '../Hooks/useTheme';
import NavigationString from '../Constant/NavigationString';
import { Dimensions } from 'react-native';
const Drawer = createDrawerNavigator();
const { width } = Dimensions.get('window');
const DrawerStack = () => {
const { colors } = useTheme();
return (
<Drawer.Navigator
drawerContent={(props) => <CustomDrawer {...props} />}
screenOptions={{
headerShown: false,
drawerType: 'slide',
drawerStyle: {
backgroundColor: colors.background,
width: width * 0.75,
},
drawerActiveTintColor: colors.primary,
drawerInactiveTintColor: colors.textSecondary,
overlayColor: 'rgba(0,0,0,0.5)',
}}
>
<Drawer.Screen
name="MainTabs"
component={BottomTab}
options={{ title: 'Home' }}
/>
{/* Add other drawer screens here that are NOT in the bottom tab */}
</Drawer.Navigator>
);
};
export default DrawerStack;
@@ -5,8 +5,9 @@ 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();
const Stack = createNativeStackNavigator<RootStackParamList>();
export default function MainStack() {
return (
@@ -41,9 +42,9 @@ export default function MainStack() {
<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.DecryptionScreen} component={Screens.DecryptionScreen} />
<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>
);
}
-5
View File
@@ -1,5 +0,0 @@
import * as React from 'react';
import MainStack from './MainStack';
export default function Routes() {
return <>{MainStack()} </>
}
+8
View File
@@ -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 />;
}
+37
View File
@@ -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 };
};
+18 -35
View File
@@ -1,3 +1,7 @@
/**
* Centralized permission broker — screens must request permissions through
* these helpers, never react-native-permissions directly.
*/
import {
check,
request,
@@ -6,16 +10,19 @@ 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
@@ -27,72 +34,48 @@ export const requestSinglePermission = async (
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;
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ export const auctionSlice = createSlice({
},
removeAuction: (state) => {
state.auction = '';
}
},
},
});
export const { setAuction , removeAuction} = auctionSlice.actions;
+57 -13
View File
@@ -1,21 +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 getToken = (state: any) => state.token.token;
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;
+34
View File
@@ -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);
});
});
-33
View File
@@ -1,33 +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';
import { themeSlice } from './Slices/ThemeSlice';
const rootReducer = combineReducers({
token: tokenSlice.reducer,
auction: auctionSlice.reducer,
selectPurchaseAuction: selectPurchaseAuctionReducer,
selectPurchaseTender: selectPurchaseTenderReducer,
tender: tenderSlice.reducer,
theme: themeSlice.reducer,
});
const persistConfig = {
key: 'root',
storage: AsyncStorage,
whitelist: ['token', 'theme'],
};
const persistReducers = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistReducers,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
serializableCheck: false,
}),
});
export const persistor = persistStore(store);
+78
View File
@@ -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());
}
});
+6
View File
@@ -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;
+3 -3
View File
@@ -6,11 +6,11 @@ import {
Animated,
Dimensions,
} from 'react-native';
import { InsideHeader } from '../../Component/Index';
import { InsideHeader } from '../../components';
import { useFetchLiveAuction, useFetchPurchaseAuction } from '../../Services/ApiCall/Hooks';
import LiveAuction from './LiveAuction';
import PurchaseAuction from './PurchaseAuction';
import { ShoppingCart, Lock, Radio, Package } from 'lucide-react-native';
import { ShoppingCart, Radio, Package } from 'lucide-react-native';
import { useNavigation } from '@react-navigation/native';
import NavigationString from '../../Constant/NavigationString';
import { useSelector } from 'react-redux';
@@ -28,7 +28,7 @@ const Auction = () => {
const { data: liveAuctionData = {} }: any = useFetchLiveAuction();
const { data: purchaseAuctionData = {} } = useFetchPurchaseAuction();
const SelectedPurchaseList = useSelector((state: any) => state.selectPurchaseAuction);
const reduxToken = useSelector((state: any) => state.token?.token);
useSelector((state: any) => state.token?.token);
const liveCount = Array.isArray(liveAuctionData?.result)
? liveAuctionData?.pages?.[0]?.totalCount
+8 -7
View File
@@ -7,12 +7,11 @@ import {
StyleSheet,
} from 'react-native';
import Animated, { FadeInDown, FadeOutUp } from 'react-native-reanimated';
import { RefreshCw } from 'lucide-react-native';
import LiveAuctionTile from './LiveAuctionTile';
import { useFetchLiveAuction } from '../../Services/ApiCall/Hooks';
import { SearchInput } from '../../Component/Serach/SeacrhInput';
import { Skelton } from '../../Component/Index';
import SkeltonList from '../../Component/Skelton/SkeltonList';
import { SearchInput } from '../../components/SearchInput';
import { Skeleton } from '../../components';
import SkeletonList from '../../components/SkeletonList';
import { useDispatch, useSelector } from 'react-redux';
import { togglePurchaseAuction } from '../../Redux/Slices/selectPurchaseAuction';
import { useTheme } from '../../Hooks/useTheme';
@@ -20,7 +19,7 @@ import AppText from '../../components/AppText';
const LiveAuction = () => {
const Dispatch = useDispatch();
const { colors, spacing, shadows } = useTheme();
const { colors, shadows } = useTheme();
const user = useSelector((state: any) => state.token?.token);
const onEndReachedCalledDuringMomentum = useRef(true);
const SelectedPurchaseList = useSelector((state: any) => state.selectPurchaseAuction);
@@ -64,6 +63,8 @@ const LiveAuction = () => {
/>
);
},
// Intentionally runs only on SelectedPurchaseList changes; other deps are stable or recreated each render.
// eslint-disable-next-line react-hooks/exhaustive-deps
[SelectedPurchaseList],
);
@@ -108,7 +109,7 @@ const LiveAuction = () => {
}, [data, hasNextPage, isFetchingNextPage, isPending]);
if (isLoading) {
return <Skelton />;
return <Skeleton />;
}
return (
@@ -138,7 +139,7 @@ const LiveAuction = () => {
{/* List */}
{isPending ? (
<SkeltonList />
<SkeletonList />
) : (
<FlatList
data={data?.result ?? []}
-1
View File
@@ -9,7 +9,6 @@ import {
Calendar,
Clock,
ShoppingCart,
Trash2,
CheckCircle2,
XCircle,
} from 'lucide-react-native';
+2 -4
View File
@@ -15,10 +15,6 @@ import {
Eye,
Layers,
ArrowLeft,
Clock,
IndianRupee,
Wrench,
Mail,
} from 'lucide-react-native';
import { useAuctionItem, useGetAuciton, useGetAucitonDocument } from '../../../Services/ApiCall/Hooks';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -91,6 +87,8 @@ const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
},
},
);
// 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';
@@ -1,11 +1,11 @@
import React, { memo, useState, useMemo } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, Modal } from 'react-native';
import { View, StyleSheet, TouchableOpacity, ScrollView, Platform, Modal } from 'react-native';
import { Package, IndianRupee, FileText, Download, ArrowLeft, Info, } from 'lucide-react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useAucitonItemDetail } from '../../../Services/ApiCall/Hooks';
import ReactNativeBlobUtil from 'react-native-blob-util';
import { AUCTION_ITEM_DOCUMENT_DOWNLOAD } from '../../../Services/ApiCall/ApiUrls';
import { Loader } from '../../../Component/Index';
import { Loader } from '../../../components';
import { useTheme } from '../../../Hooks/useTheme';
import AppText from '../../../components/AppText';
@@ -48,7 +48,7 @@ const AuctionItemDetail: React.FC<Props> = ({ visible, onClose, data }) => {
const downloadUrl = `${AUCTION_ITEM_DOCUMENT_DOWNLOAD}?auctionItemId=${id}`;
const response = await ReactNativeBlobUtil.config({
await ReactNativeBlobUtil.config({
fileCache: false,
addAndroidDownloads: {
useDownloadManager: true,
@@ -63,7 +63,7 @@ const AuctionItemDetail: React.FC<Props> = ({ visible, onClose, data }) => {
Accept: 'application/pdf',
});
const stat = await ReactNativeBlobUtil.fs.stat(path);
await ReactNativeBlobUtil.fs.stat(path);
} catch (error: any) {
console.log('Error:', error?.message || error);
+8 -9
View File
@@ -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/ApiCall/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 } from '../../components';
import { allBooleanProps } from './types/types';
import FilterModal from './Modal/FilterModal';
import NavigationString from '../../Constant/NavigationString';
@@ -20,7 +19,7 @@ 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';
@@ -65,11 +64,11 @@ 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 })) }} filter={false} />
{isPending ? <Skelton />
{isPending ? <Skeleton />
:
<FlatList
data={data.result ?? []}
renderItem={({ item, index }) => (
renderItem={({ item }) => (
<PurchaseAuctionTile
item={item}
onPressDetails={() => {
+4 -4
View File
@@ -15,17 +15,15 @@ import {
Receipt,
CheckCircle2,
Gavel,
Building2,
BadgeIndianRupee,
} from 'lucide-react-native';
import { InsideHeader } from '../../Component/Index';
import { InsideHeader } from '../../components';
import { useDispatch, useSelector } from 'react-redux';
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';
import Skeleton from '../../components/Skeleton';
import { useTheme } from '../../Hooks/useTheme';
import AppText from '../../components/AppText';
@@ -115,6 +113,8 @@ const PurchaseAuctionList = () => {
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]),
);
+1 -6
View File
@@ -2,7 +2,7 @@ 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 CountdownTimer from '../../components/CountdownTimer';
import AuctionDetail from './Modal/AuctionDetail';
import AppText from '../../components/AppText';
import { useTheme } from '../../Hooks/useTheme';
@@ -18,7 +18,6 @@ import {
const PurchaseAuctionTile = ({
item,
onPressDetails,
onStartBidding,
buttonEnable,
isAuctionEnd,
@@ -38,10 +37,6 @@ const PurchaseAuctionTile = ({
const biddingEnd = isAmended
? item?.amendedkeyDates?.auctionBidding?.endDateTime?.replace(' kk', '')
: item?.keyDates?.auctionBidding?.endDateTime?.replace(' kk', '');
const biddingStart = isAmended
? item?.amendedkeyDates?.auctionBidding?.startDateTime?.replace(' kk', '')
: item?.keyDates?.auctionBidding?.startDateTime?.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 };
+3 -1
View File
@@ -15,7 +15,7 @@ import Animated, {
withTiming,
} from 'react-native-reanimated';
import { ChevronDown, ChevronRight, Trash2, ShoppingBag, Gavel, Tag } from 'lucide-react-native';
import { InsideHeader } from '../../Component/Index';
import { InsideHeader } from '../../components';
import { useNavigation } from '@react-navigation/native';
import NavigationString from '../../Constant/NavigationString';
import { useDispatch, useSelector } from 'react-redux';
@@ -38,6 +38,8 @@ const PurchaseList = () => {
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);
@@ -1,40 +1,52 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
View,
FlatList,
StyleSheet,
Alert,
BackHandler,
} from 'react-native';
import LottieView from 'lottie-react-native';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { InsideHeader } from '../../Component/Index';
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,
FORWARD_AUCTION_ITEM,
GET_AUCTION_DETAILS,
REVERSE_AUCTION_ITEM,
SELECTED_AUCTION_ITEM,
} from '../../Services/ApiCall/ApiUrls';
import BiddingCard from './Component/BiddingCard';
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 '../../Component/Counter/CountdownTimer';
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';
const StartBidding = () => {
/**
* 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);
const Navigation: any = useNavigation();
const config = useMemo(
() => getBiddingConfig(SelectedAuction?.auctionPattern),
[SelectedAuction?.auctionPattern],
);
const [items, setItems] = useState<any>([]);
const [bidValues, setBidValues] = useState<Record<number, number>>({});
@@ -48,11 +60,10 @@ const StartBidding = () => {
reload: false,
});
const { subscribe, createRoom, placeBid } = useWebSocketService(
const { subscribe, unsubscribe, disconnect, createRoom, placeBid } = useWebSocketService(
Variables.webSocketUrl,
() => console.log('Connected!'),
(error: any) => console.log('WebSocket Error:', error),
loginUser.token,
() => logger.info('stomp', 'connected'),
(error: any) => logger.warn('stomp', `connection error: ${error}`),
);
const getAuction = async () => {
@@ -74,10 +85,8 @@ const StartBidding = () => {
const payload = { auctionNumber: SelectedAuction.auctionNumber };
let auctionItem: any[] = [];
if (SelectedAuction.auctionPattern === 'Forward') {
const res = await apiCall<any>('get', FORWARD_AUCTION_ITEM, {}, payload);
if (res?.statusCode === 200) auctionItem = res.data || [];
}
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 || []));
@@ -85,18 +94,15 @@ const StartBidding = () => {
if (SelectedAuction.auctionCategories === 'Live') {
const result = await createRoom(SelectedAuction.auctionNumber);
subscribe(`/topic/room/${SelectedAuction.auctionNumber}`, (data: any) => {
console.log('🔥 BID UPDATE:', data);
});
const bidAmountMap2 = new Map<number, { bidAmount: number; contractorId: number }>();
const liveBids = new Map<number, { bidAmount: number; contractorId: number }>();
(result || []).forEach((r: any) => {
bidAmountMap2.set(r.auctionItemId, { bidAmount: r.bidAmount, contractorId: r.contractorId });
liveBids.set(r.auctionItemId, { bidAmount: r.bidAmount, contractorId: r.contractorId });
});
const updatedFilteredItems = filteredItems.map(item => {
const bidData = bidAmountMap2.get(item.id);
const bidData = liveBids.get(item.id);
return {
...item,
bidAmount: bidData?.bidAmount ?? item.auctionStartValueFigure,
bidAmount: bidData?.bidAmount ?? config.startValue(item),
bidAvilable: bidData?.bidAmount ?? 0,
me: bidData?.contractorId === loginUser.contractorId,
};
@@ -106,14 +112,13 @@ const StartBidding = () => {
setBidValues(initialBids);
setItems(updatedFilteredItems);
} else {
if (SelectedAuction.rebidAllowed) setAllBoolean(prev => ({ ...prev, rebid: true }));
let result: any[] = [];
if (SelectedAuction.auctionCategories !== 'Live') result = await getOneTimeBidValue();
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: item.auctionStartValueFigure, contractorId: item.contractorId };
if (!bid) { return { ...item, bidAmount: config.startValue(item), contractorId: item.contractorId }; }
return {
...item,
bidAmount: bid.bidAmount,
@@ -130,34 +135,41 @@ const StartBidding = () => {
setItems(mergedData);
}
} catch (error) {
console.error('Error fetching auction items:', error);
logger.error('bidding', 'fetching auction items failed', { error: String(error) });
}
};
subscribe(`/topic/room/${SelectedAuction.auctionNumber}`, async (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]: result.bidAmount }));
});
// 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]: 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();
if (SelectedAuction.auctionCategories === 'One-time') { getAuction(); }
getAllAcutionItems();
subscribe(`/topic/room/${SelectedAuction.auctionNumber}`, () => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allBoolean.reload]);
useEffect(() => {
const initial: Record<number, number> = {};
items.forEach((item: any) => { initial[item.id] = item.bidAmount + item.bidVariationValue; });
setBidValues(initial);
}, []);
const handleUpdate = (id: number, val: number) => {
setBidValues(prev => ({ ...prev, [id]: prev[id] + val }));
};
@@ -177,10 +189,15 @@ const StartBidding = () => {
const placeBidSubmit = async (data: any) => {
setAllBoolean(prev => ({ ...prev, showDialog: false }));
try {
const result: any = await placeBid(data);
if (result?.status && result.data) getAllAcutionItems();
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) {
console.error('Error placing bid:', error);
logger.error('bidding', 'placing bid failed', { error: String(error) });
}
};
@@ -192,7 +209,7 @@ const StartBidding = () => {
};
const sub = BackHandler.addEventListener('hardwareBackPress', onBackPress);
return () => sub.remove();
}, [Navigation]),
}, []),
);
const styles = makeStyles(colors, spacing, shadows);
@@ -260,15 +277,16 @@ const StartBidding = () => {
data={items}
keyExtractor={item => item.id.toString()}
renderItem={({ item, index }) => (
<BiddingCard
<AuctionBiddingCard
item={{ ...item, index }}
bidValue={bidValues[item.id] || 0}
onIncrease={() => handleUpdate(item.id, item.bidVariationValue)}
onDecrease={() => handleUpdate(item.id, -item.bidVariationValue)}
onIncrease={() => handleUpdate(item.id, config.step(item))}
onDecrease={() => handleUpdate(item.id, -config.step(item))}
onPlaceBid={() => submitBid(item)}
SelectedAuction={SelectedAuction}
allBoolean={allBoolean}
setAllBoolean={setAllBoolean}
config={config}
/>
)}
contentContainerStyle={styles.listContent}
@@ -282,7 +300,7 @@ const StartBidding = () => {
source={require('../../lottie/Success.json')}
autoPlay
loop={false}
style={{ width: 200, height: 200 }}
style={styles.successLottie}
/>
</View>
)}
@@ -311,7 +329,7 @@ const StartBidding = () => {
);
};
export default StartBidding;
export default AuctionBiddingScreen;
/* ─── Styles factory ─── */
const makeStyles = (colors: any, spacing: any, shadows: any) =>
@@ -399,4 +417,5 @@ const makeStyles = (colors: any, spacing: any, shadows: any) =>
alignItems: 'center',
zIndex: 99,
},
successLottie: { width: 200, height: 200 },
});
@@ -1,25 +1,48 @@
import React, { memo, useCallback, useRef, useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Platform, TextInput, Alert } from 'react-native';
import { View, TouchableOpacity, StyleSheet, TextInput } from 'react-native';
import Animated, { FadeInUp, Layout, interpolate, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
import colors_const from '../../../Constant/Color';
import AuctionItemDetail from '../../Auction/Modal/AuctionItemDetail';
import MainStyle from '../../../Styles/MainStyle';
import { useTheme } from '../../../Hooks/useTheme';
import AppText from '../../../components/AppText';
import { Loader } from '../../../Component/Index';
import { Loader } from '../../../components';
import { useSelector } from 'react-redux';
import axios from 'axios';
import Variables from '../../../Constant/Variable';
import { env } from '../../../core/config/env';
import { request } from '../../../core/http/client';
import { isAppError } from '../../../core/http/AppError';
import { logger } from '../../../core/observability/logger';
import { showErrorAlert, showSuccessAlert } from '../../../Constant/ShowDailog';
import { BiddingPatternConfig } from '../biddingPattern';
const BiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid, SelectedAuction, allBoolean, setAllBoolean }: any) => {
type Props = {
item: any;
bidValue: number;
onIncrease: () => void;
onDecrease: () => void;
onPlaceBid: () => void;
SelectedAuction: any;
allBoolean: any;
setAllBoolean: React.Dispatch<React.SetStateAction<any>>;
config: BiddingPatternConfig;
};
/**
* The single bidding card for BOTH auction patterns. All forward/reverse
* behavior differences come from `config` (see biddingPattern.ts) — never
* branch on the pattern inside this component.
*/
const AuctionBiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid, SelectedAuction, allBoolean, setAllBoolean, config }: Props) => {
const { colors } = useTheme();
const styles = React.useMemo(() => makeStyles(colors), [colors]);
const Token = useSelector((state: any) => state.token.token)
const Token = useSelector((state: any) => state.token.token);
const [showDetail, setShowDetail] = useState(false);
const [value, setValue] = useState(`${item.bidAmount}`);
const [allState, setAllState] = useState({ isLoading: false })
const isMin = bidValue <= item.bidAmount;
const [allState, setAllState] = useState({ isLoading: false });
const canIncrease = config.canIncrease(bidValue, item);
const canDecrease = config.canDecrease(bidValue, item);
const bodyFull = config.isBodyFull(allBoolean, item);
const submitVisible = config.showSubmit(allBoolean, item);
// 🔒 Persist expand state safely
const expandedRef = useRef(false);
@@ -31,7 +54,7 @@ const BiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid, Selec
damping: 15,
stiffness: 140,
});
}, []);
}, [animation]);
const bodyStyle = useAnimatedStyle(() => ({
height: interpolate(animation.value, [0, 1], [0, 190]),
@@ -51,22 +74,26 @@ const BiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid, Selec
},
],
}));
const MAX_VALUE = Number(item.bidAmount);
const handleChange = (text: string) => {
let cleaned = text.replace(/[^0-9.]/g, '');
if ((cleaned.match(/\./g) || []).length > 1) return;
const cleaned = text.replace(/[^0-9.]/g, '');
if ((cleaned.match(/\./g) || []).length > 1) { return; }
if (cleaned === '') {
setValue('');
return;
}
if (!config.acceptManualInput(Number(cleaned), item)) { return; }
setValue(cleaned);
};
const oneTimePlaceBid = async () => {
const validationError = config.validateManualBid(Number(value), item);
if (validationError) {
showErrorAlert(validationError);
return;
}
try {
if (Number(value) < MAX_VALUE) {
showErrorAlert("Enter the greather vlaue to current bid value")
return false
}
setAllState((prev: any) => ({ ...prev, isLoading: true }))
setAllState((prev: any) => ({ ...prev, isLoading: true }));
const payload = {
itemId: item.id,
bidAmount: Number(value),
@@ -75,28 +102,41 @@ const BiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid, Selec
fullName: Token.fullName,
};
const res = await request<any>({
method: 'post',
url: `${env.socketBaseUrl}bidding/placeBidForAuction`,
data: [payload],
});
const res = await axios.post(
`${Variables.socketUrl}bidding/placeBidForAuction`,
[payload],
{ headers: { Authentication: `Bearer ${Token.token}` } }
);
if (res.data.statusCode === 200) {
showSuccessAlert(res.data.message);
if (res.statusCode === 200) {
showSuccessAlert(res.message);
}
setAllBoolean((prev: any) => ({ ...prev, reload: !prev.reload }))
setAllBoolean((prev: any) => ({ ...prev, reload: !prev.reload }));
} catch (error) {
if (axios.isAxiosError(error)) {
showErrorAlert(error.response?.data.message)
if (isAppError(error) && error.statusCode !== undefined) {
showErrorAlert(error.message);
} else {
console.log('UNKNOWN ERROR:', error);
logger.error('bidding', 'manual bid failed', { error: String(error) });
}
} finally {
setAllState((prev: any) => ({ ...prev, isLoading: false }))
setAllState((prev: any) => ({ ...prev, isLoading: false }));
}
}
};
const manualInput = (allBoolean.oneTimeBid || allBoolean.rebid) && (
<View style={[styles.inputBox2, config.inlineManualInput ? styles.inputBox2Inline : styles.inputBox2Block]}>
<AppText variant="body" color={colors.textSecondary} bold style={styles.currency2 as any}>₹</AppText>
<TextInput
value={value}
onChangeText={handleChange}
keyboardType="decimal-pad"
placeholder="0.00"
style={[styles.input, { color: colors.text }]}
placeholderTextColor={colors.textSecondary}
/>
</View>
);
return (
<View>
@@ -109,19 +149,18 @@ const BiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid, Selec
{/* HEADER */}
<TouchableOpacity
activeOpacity={0.7}
onPress={() => { setShowDetail(true) }}
onPress={() => { setShowDetail(true); }}
style={styles.cardHeader}
>
<View style={{ flex: 1 }}>
<View style={styles.flex1}>
<AppText variant="h4" color={colors.text} bold style={styles.productName as any}>{item.productName}</AppText>
{/* auctionStartValueFigure */}
<AppText variant="caption" color={colors.textSecondary} style={styles.itemIdText as any}>
Initial Amt : {item?.auctionStartValueFigure}
Initial Amt : ₹{config.startValue(item)}
</AppText>
</View>
<TouchableOpacity style={styles.headerRight} onPress={toggleExpand}>
<View style={[MainStyle.flexBetween, { gap: 10 }]}>
<View style={[MainStyle.flexBetween, styles.gap10]}>
<View style={[styles.badge, item.me ? styles.meBadge : styles.liveBadge]}>
<AppText variant="caption" bold style={[styles.badgeText as any, item.me ? styles.meText : styles.liveText]}>
{item.me ? 'LEADING' : 'LIVE'}
@@ -143,77 +182,83 @@ const BiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid, Selec
<View style={styles.priceRow}>
<View>
<AppText variant="caption" color={colors.textSecondary} semiBold style={styles.label as any}>Current Bid</AppText>
<AppText variant="h2" color={colors.text} bold style={styles.mainPrice as any}>₹{item?.bidAmount || item.auctionStartValueFigure}</AppText>
<AppText variant="h2" color={colors.text} bold style={styles.mainPrice as any}>₹{item?.bidAmount || config.startValue(item)}</AppText>
</View>
<View style={{ alignItems: 'flex-end' }}>
<AppText variant="caption" color={colors.textSecondary} semiBold style={styles.label as any}>Increment</AppText>
<View style={styles.alignEnd}>
<AppText variant="caption" color={colors.textSecondary} semiBold style={styles.label as any}>{config.stepLabel}</AppText>
<AppText variant="body" color={colors.primary} bold style={styles.incrementText as any}>
+₹{item.bidVariationValue}
{config.stepPrefix}₹{config.step(item)}
</AppText>
</View>
</View>
{/* COLLAPSIBLE BODY */}
<Animated.View style={[styles.collapsibleContainer, (!allBoolean.rebid && item.contractorId == undefined) ? bodyStyle : bodyStyle2]}>
<Animated.View style={[styles.collapsibleContainer, bodyFull ? bodyStyle : bodyStyle2]}>
<View style={styles.divider} />
<AppText variant="bodySmall" color={colors.textSecondary} semiBold style={styles.bidLabel as any}>Set Your Bid</AppText>
{!allBoolean.oneTimeBid && <View style={styles.stepperContainer}>
<TouchableOpacity
{(!allBoolean.oneTimeBid || config.inlineManualInput) && <View style={styles.stepperContainer}>
{!allBoolean.oneTimeBid && <TouchableOpacity
onPress={onDecrease}
disabled={isMin}
style={[styles.stepBtn, isMin && styles.disabledBtn]}
disabled={!canDecrease}
style={[styles.stepBtn, !canDecrease && styles.disabledBtn]}
accessibilityRole="button"
accessibilityLabel="Decrease bid amount"
accessibilityState={{ disabled: !canDecrease }}
>
<AppText variant="h3" color="#FFF" bold style={styles.stepText as any}>−</AppText>
</TouchableOpacity>
</TouchableOpacity>}
<View style={styles.inputBox}>
{!allBoolean.oneTimeBid && <View style={styles.inputBox} accessibilityLabel={`Current bid amount ${bidValue} rupees`}>
<AppText variant="body" color={colors.textSecondary} bold style={styles.currency as any}>₹</AppText>
<AppText variant="h4" color={colors.text} bold style={styles.bidValueText as any}>{bidValue}</AppText>
</View>
</View>}
<TouchableOpacity onPress={onIncrease} style={styles.stepBtn}>
{config.inlineManualInput && manualInput}
{!allBoolean.oneTimeBid && <TouchableOpacity
onPress={onIncrease}
disabled={!canIncrease}
style={[styles.stepBtn, !canIncrease && styles.disabledBtn]}
accessibilityRole="button"
accessibilityLabel="Increase bid amount"
accessibilityState={{ disabled: !canIncrease }}
>
<AppText variant="h3" color="#FFF" bold style={styles.stepText as any}>+</AppText>
</TouchableOpacity>
</TouchableOpacity>}
</View>}
{
(allBoolean.oneTimeBid || allBoolean.rebid) && <View style={styles.inputBox2}>
<AppText variant="body" color={colors.textSecondary} bold style={styles.currency2 as any}>₹</AppText>
<TextInput
value={value}
onChangeText={handleChange}
keyboardType="decimal-pad"
placeholder="0.00"
style={[styles.input, { color: colors.text }]}
placeholderTextColor={colors.textSecondary}
/>
</View>
}
{!config.inlineManualInput && manualInput}
{(!allBoolean.rebid && item.contractorId == undefined) ? <TouchableOpacity
{submitVisible ? <TouchableOpacity
style={[styles.submitBtn]}
onPress={(allBoolean.oneTimeBid || allBoolean.rebid) ? oneTimePlaceBid : onPlaceBid}
accessibilityRole="button"
accessibilityLabel="Confirm bid"
accessibilityState={{ busy: allState.isLoading }}
>
{
allState.isLoading ? <Loader size='small' color="#FFF" /> : <AppText variant="button" color="#FFF" bold style={styles.submitBtnText as any}>Confirm Bid</AppText>
allState.isLoading ? <Loader size="small" color="#FFF" /> : <AppText variant="button" color="#FFF" bold style={styles.submitBtnText as any}>Confirm Bid</AppText>
}
</TouchableOpacity> : null}
</Animated.View>
</Animated.View>
:
<AuctionItemDetail data={item} onClose={() => { setShowDetail(false) }} visible={showDetail} />
<AuctionItemDetail data={item} onClose={() => { setShowDetail(false); }} visible={showDetail} />
}
</View>
)
);
};
export default memo(BiddingCard);
export default memo(AuctionBiddingCard);
const makeStyles = (colors: any) => StyleSheet.create({
flex1: { flex: 1 },
gap10: { gap: 10 },
alignEnd: { alignItems: 'flex-end' },
inputBox2: {
flexDirection: 'row',
alignItems: 'center',
@@ -221,8 +266,13 @@ const makeStyles = (colors: any) => StyleSheet.create({
borderRadius: 12,
height: 45,
paddingHorizontal: 12,
},
inputBox2Block: {
width: '100%',
marginBottom: 20
marginBottom: 20,
},
inputBox2Inline: {
flex: 1,
},
currency2: {
marginRight: 4,
@@ -231,7 +281,7 @@ const makeStyles = (colors: any) => StyleSheet.create({
flex: 1,
fontSize: 18,
fontWeight: '700',
width: '100%'
width: '100%',
},
card: {
borderRadius: 24,
@@ -1,65 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Text, View } from 'react-native';
import moment from 'moment';
import LottieView from 'lottie-react-native';
import colors from '../../Constant/Color';
const AuctionTimer = ({ SelectedAuction, setAllBoolean }: any) => {
const [timeLeft, setTimeLeft] = useState('00:00:00');
useEffect(() => {
if (!SelectedAuction?.keyDates?.auctionBidding?.endDateTime) return;
const format = 'DD-MM-YYYY HH:mm:ss';
const endTime = moment(
SelectedAuction.keyDates.auctionBidding.endDateTime,
format
);
const timer = setInterval(() => {
const now = moment();
const diff = moment.duration(endTime.diff(now));
if (diff.asSeconds() <= 0) {
setTimeLeft('00:00:00');
clearInterval(timer);
setAllBoolean((prev: any) => ({
...prev,
timeEnd: true,
}));
return;
}
const hours = String(Math.floor(diff.asHours())).padStart(2, '0');
const minutes = String(diff.minutes()).padStart(2, '0');
const seconds = String(diff.seconds()).padStart(2, '0');
setTimeLeft(`${hours}:${minutes}:${seconds}`);
}, 1000);
return () => clearInterval(timer);
}, [SelectedAuction]);
return (
<View
style={{
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 5,
}}
>
<LottieView
source={require('../../lottie/clock.json')}
autoPlay
loop
style={{ width: 20, height: 20 }}
/>
<Text style={{ fontSize: 18, color: colors.black }}>{timeLeft}</Text>
</View>
);
};
export default AuctionTimer;
@@ -19,7 +19,7 @@ const ConfirmBidModal = ({ visible, onClose, onConfirm, bidAmount, isLoading }:
return (
<View style={styles.overlay}>
<View style={[styles.modalCard, { backgroundColor: colors.surface, ...shadows.large }]}>
<View style={[styles.modalCard, { backgroundColor: colors.surface, ...shadows.strong }]}>
<TouchableOpacity style={[styles.closeBtn, { backgroundColor: colors.background }]} onPress={onClose} disabled={isLoading}>
<X size={18} color={colors.textSecondary} strokeWidth={2.5} />
@@ -19,7 +19,7 @@ const STEPS = [
];
const EmdComplete = ({ onNext }: { onNext?: () => void }) => {
const { colors, spacing, shadows } = useTheme();
const { colors, shadows } = useTheme();
return (
<View style={[styles.root, { backgroundColor: colors.background }]}>
@@ -17,7 +17,7 @@ const GoBackModal = ({ visible, onClose, onConfirm }: Props) => {
return (
<View style={styles.overlay}>
<View style={[styles.modalCard, { backgroundColor: colors.surface, ...shadows.large }]}>
<View style={[styles.modalCard, { backgroundColor: colors.surface, ...shadows.strong }]}>
<TouchableOpacity style={[styles.closeBtn, { backgroundColor: colors.background }]} onPress={onClose}>
<X size={18} color={colors.textSecondary} strokeWidth={2.5} />
@@ -1,358 +0,0 @@
import React, { memo, useCallback, useRef, useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Platform, TextInput } from 'react-native';
import Animated, { FadeInUp, Layout, interpolate, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
import colors_const from '../../../Constant/Color';
import AuctionItemDetail from '../../Auction/Modal/AuctionItemDetail';
import MainStyle from '../../../Styles/MainStyle';
import { useTheme } from '../../../Hooks/useTheme';
import AppText from '../../../components/AppText';
import axios, { all } from 'axios';
import Variables from '../../../Constant/Variable';
import { useSelector } from 'react-redux';
import { showErrorAlert, showSuccessAlert } from '../../../Constant/ShowDailog';
import { Loader } from '../../../Component/Index';
const ReverseBiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid, SelectedAuction, allBoolean, setAllBoolean }: any) => {
const { colors } = useTheme();
const styles = React.useMemo(() => makeStyles(colors), [colors]);
const Token = useSelector((state: any) => state.token.token)
const [allState, setAllState] = useState({ isLoading: false })
const [showDetail, setShowDetail] = useState(false);
const [value, setValue] = useState(`${item.bidAmount}`);
const canDecrease = bidValue > item.emdAmount;
const canIncrease = bidValue < item.bidAmount;
// Animation Part Rohit Start
const expandedRef = useRef(false);
const animation = useSharedValue(0);
const toggleExpand = useCallback(() => {
expandedRef.current = !expandedRef.current;
animation.value = withSpring(expandedRef.current ? 1 : 0, {
damping: 15,
stiffness: 140,
});
}, []);
const bodyStyle = useAnimatedStyle(() => ({
height: interpolate(animation.value, [0, 1], [0, 190]),
opacity: interpolate(animation.value, [0, 0.5, 1], [0, 0, 1]),
marginTop: interpolate(animation.value, [0, 1], [0, 15]),
}));
const bodyStyle2 = useAnimatedStyle(() => ({
height: interpolate(animation.value, [0, 1], [0, 100]),
opacity: interpolate(animation.value, [0, 0.5, 1], [0, 0, 1]),
marginTop: interpolate(animation.value, [0, 1], [0, 15]),
}));
const arrowStyle = useAnimatedStyle(() => ({
transform: [
{
rotate: `${interpolate(animation.value, [0, 1], [0, 180])}deg`,
},
],
}));
// Animation Part Rohit End
const MAX_VALUE = Number(item.auctionStartValue);
const handleChange = (text: string) => {
let cleaned = text.replace(/[^0-9.]/g, '');
if ((cleaned.match(/\./g) || []).length > 1) return;
if (cleaned === '') {
setValue('');
return;
}
const numericValue = Number(cleaned);
if (numericValue < 0) return;
if (numericValue > MAX_VALUE) return;
setValue(cleaned);
};
const oneTimePlaceBid = async () => {
try {
setAllState((prev: any) => ({ ...prev, isLoading: true }))
const payload = {
itemId: item.id,
bidAmount: Number(value),
auctionId: SelectedAuction.auctionId,
contractorId: Token.contractorId,
fullName: Token.fullName,
};
const res = await axios.post(
`${Variables.socketUrl}bidding/placeBidForAuction`,
[payload],
{ headers: { Authentication: `Bearer ${Token.token}` } }
);
if (res.data.statusCode === 200) {
showSuccessAlert(res.data.message);
}
setAllBoolean((prev: any) => ({ ...prev, reload: !prev.reload }))
} catch (error) {
if (axios.isAxiosError(error)) {
showErrorAlert(error.response?.data.message)
} else {
console.log('UNKNOWN ERROR:', error);
}
} finally {
setAllState((prev: any) => ({ ...prev, isLoading: false }))
}
}
return (
<View>
{!showDetail ? (
<Animated.View
layout={Layout.springify()}
entering={FadeInUp.delay(100)}
style={[
styles.card,
item.me ? styles.leadingCard : undefined,
{
borderColor: item.me ? colors.success : colors.error,
backgroundColor: item.me ? `${colors.success}10` : `${colors.error}10`,
},
]}
>
{/* HEADER */}
<TouchableOpacity
activeOpacity={0.7}
onPress={() => setShowDetail(true)}
style={styles.cardHeader}
>
<View style={{ flex: 1 }}>
<AppText variant="h4" color={colors.text} bold style={styles.productName as any}>{item.productName}</AppText>
<AppText variant="caption" color={colors.textSecondary} style={styles.itemIdText as any}>
Initial Amt : ₹{item.auctionStartValue}
</AppText>
</View>
<TouchableOpacity style={styles.headerRight} onPress={toggleExpand}>
<View style={[MainStyle.flexBetween, { gap: 10 }]}>
<View
style={[
styles.badge,
item.me ? styles.meBadge : styles.liveBadge,
]}
>
<AppText
variant="caption"
bold
style={[
styles.badgeText as any,
item.me ? styles.meText : styles.liveText,
]}
>
{item.me ? 'LEADING' : 'LIVE'}
</AppText>
</View>
<Animated.Text style={[styles.arrow, { color: colors.textSecondary }, arrowStyle]}>
▼
</Animated.Text>
</View>
<AppText variant="caption" color={colors.textSecondary} style={styles.itemIdText as any}>
{SelectedAuction?.auctionNumber}
</AppText>
</TouchableOpacity>
</TouchableOpacity>
{/* PRICE INFO */}
<View style={styles.priceRow}>
<View>
<AppText variant="caption" color={colors.textSecondary} semiBold style={styles.label as any}>Current Bid</AppText>
<AppText variant="h2" color={colors.text} bold style={styles.mainPrice as any}>₹{item.bidAmount || item.auctionStartValue}</AppText>
</View>
<View style={{ alignItems: 'flex-end' }}>
<AppText variant="caption" color={colors.textSecondary} semiBold style={styles.label as any}>Decrement</AppText>
<AppText variant="body" color={colors.primary} bold style={styles.incrementText as any}>
-₹{item.emdAmount}
</AppText>
</View>
</View>
{/* COLLAPSIBLE BODY */}
{/* <Animated.View style={[styles.collapsibleContainer, (allBoolean.oneTimeBid && allBoolean.rebid) ? bodyStyle : (allBoolean.oneTimeBid && !item.me) ? bodyStyle : bodyStyle2]}> */}
<Animated.View style={[styles.collapsibleContainer, bodyStyle]}>
<View style={styles.divider} />
<AppText variant="bodySmall" color={colors.textSecondary} semiBold style={styles.bidLabel as any}>Set Your Bid</AppText>
<View style={styles.stepperContainer}>
{/* DECREASE */}
{!allBoolean.oneTimeBid && <TouchableOpacity
onPress={onDecrease}
disabled={!canDecrease}
style={[
styles.stepBtn,
!canDecrease && styles.disabledBtn,
]}
>
<AppText variant="h3" color="#FFF" bold style={styles.stepText as any}>−</AppText>
</TouchableOpacity>}
{/* VALUE */}
{!allBoolean.oneTimeBid && <View style={styles.inputBox}>
<AppText variant="body" color={colors.textSecondary} bold style={styles.currency as any}>₹</AppText>
<AppText variant="h4" color={colors.text} bold style={styles.bidValueText as any}>{bidValue}</AppText>
</View>}
{
(allBoolean.oneTimeBid || allBoolean.rebid) && <View style={styles.inputBox2}>
<AppText variant="body" color={colors.textSecondary} bold style={styles.currency2 as any}>₹</AppText>
<TextInput
value={value}
onChangeText={handleChange}
keyboardType="decimal-pad"
placeholder="0.00"
style={[styles.input, { color: colors.text }]}
placeholderTextColor={colors.textSecondary}
/>
</View>
}
{/* INCREASE */}
{!allBoolean.oneTimeBid && <TouchableOpacity
onPress={onIncrease}
disabled={!canIncrease}
style={[
styles.stepBtn,
!canIncrease && styles.disabledBtn,
]}
>
<AppText variant="h3" color="#FFF" bold style={styles.stepText as any}>+</AppText>
</TouchableOpacity>}
</View>
<TouchableOpacity
style={[styles.submitBtn]}
onPress={(allBoolean.oneTimeBid || allBoolean.rebid) ? oneTimePlaceBid : onPlaceBid}
>
{
allState.isLoading ? <Loader size='small' color="#FFF" /> : <AppText variant="button" color="#FFF" bold style={styles.submitBtnText as any}>Confirm Bid</AppText>
}
</TouchableOpacity>
{/* {(allBoolean.oneTimeBid && allBoolean.rebid) ?
<TouchableOpacity
style={[styles.submitBtn]}
onPress={(allBoolean.oneTimeBid || allBoolean.rebid) ? oneTimePlaceBid : onPlaceBid}
>
{
allState.isLoading ? <Loader size='small' color={colors.white} /> : <Text style={styles.submitBtnText}>Confirm Bid</Text>
}
</TouchableOpacity> :
(allBoolean.oneTimeBid && !item.me) ? <TouchableOpacity
style={[styles.submitBtn]}
onPress={(allBoolean.oneTimeBid || allBoolean.rebid) ? oneTimePlaceBid : onPlaceBid}
>
{
allState.isLoading ? <Loader size='small' color={colors.white} /> : <Text style={styles.submitBtnText}>Confirm Bid</Text>
}
</TouchableOpacity> : null
} */}
</Animated.View>
</Animated.View>
) : (
<AuctionItemDetail
data={item}
onClose={() => setShowDetail(false)}
visible={showDetail}
/>
)}
</View>
);
};
export default ReverseBiddingCard;
const makeStyles = (colors: any) => StyleSheet.create({
inputBox2: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background,
borderRadius: 12,
height: 45,
paddingHorizontal: 12,
width: '100%'
},
currency2: {
marginRight: 4,
} as any,
input: {
flex: 1,
fontSize: 18,
fontWeight: '700',
width: '100%'
},
card: {
borderRadius: 24,
padding: 20,
marginBottom: 16,
borderWidth: 1,
overflow: 'hidden',
},
leadingCard: {
borderWidth: 2,
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
headerRight: { flexDirection: 'column', alignItems: 'center', gap: 8 },
productName: {} as any,
itemIdText: { marginTop: 2 } as any,
badge: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 8 },
liveBadge: { backgroundColor: `${colors.error}20` },
meBadge: { backgroundColor: `${colors.success}20` },
badgeText: { fontSize: 10 } as any,
liveText: { color: colors.error },
meText: { color: colors.success },
arrow: { fontSize: 10 },
priceRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 20 },
label: {
textTransform: 'uppercase',
} as any,
mainPrice: {} as any,
incrementText: {} as any,
collapsibleContainer: { overflow: 'hidden' },
divider: { height: 1, backgroundColor: colors.border, marginVertical: 10 },
bidLabel: { marginBottom: 10 } as any,
stepperContainer: { flexDirection: 'row', gap: 12, marginBottom: 20 },
stepBtn: {
width: 45,
height: 45,
backgroundColor: colors.primary,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
disabledBtn: { backgroundColor: colors.border },
stepText: {} as any,
inputBox: {
flex: 1,
height: 45,
backgroundColor: colors.background,
borderRadius: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
currency: { marginRight: 4 } as any,
bidValueText: {} as any,
submitBtn: {
height: 50,
backgroundColor: colors.primary,
borderRadius: 15,
justifyContent: 'center',
alignItems: 'center',
},
submitBtnText: {} as any,
});
@@ -7,7 +7,7 @@ import { useSelector } from 'react-redux'
import { apiCall } from '../../../Axios/Axios'
import { TECHNICAL_DOCUMENT_SUBMIT, TECHNICAL_VALUE_SUBMIT } from '../../../Services/ApiCall/ApiUrls'
import { Props } from '../Type/BidType'
import { Loader } from '../../../Component/Index'
import { Loader } from '../../../components'
import { useNavigation } from '@react-navigation/native'
import NavigationString from '../../../Constant/NavigationString'
import { useTheme } from '../../../Hooks/useTheme'
@@ -15,13 +15,16 @@ import AppText from '../../../components/AppText'
import { Upload, FileText, X } from 'lucide-react-native'
const TemplateFormModal: React.FC<Props> = ({ visible, template, setAllBoolean }) => {
if (!template) return null
const SelectedAuction = useSelector((state: any) => state.auction.auction)
const Navigation: any = useNavigation()
const { colors, spacing, typography } = useTheme()
const styles = useMemo(() => makeStyles(colors, spacing, typography), [colors, spacing, typography])
const [loading, setLoading] = useState(false)
// Hooks must all run before this guard — a conditional return above any
// hook changes the hook order between renders and crashes React.
if (!template) return null
const initialValues = template?.labels?.reduce((acc: any, label: any) => {
acc[label.labelName] = ''
return acc
@@ -254,7 +257,7 @@ const TemplateFormModal: React.FC<Props> = ({ visible, template, setAllBoolean }
})
}}
>
{({ handleSubmit, values, errors, touched, setFieldValue }) => (
{({ handleSubmit, values, setFieldValue }) => (
<>
{template.labels.map((label: any) =>
renderInput(
@@ -298,7 +301,7 @@ const TemplateFormModal: React.FC<Props> = ({ visible, template, setAllBoolean }
export default TemplateFormModal
const makeStyles = (colors: any, spacing: any, typography: any) =>
const makeStyles = (colors: any, spacing: any, _typography: any) =>
StyleSheet.create({
screen: {
flex: 1,
+5 -5
View File
@@ -12,7 +12,7 @@ import {
import { pick } from '@react-native-documents/picker';
import Animated, { FadeInDown, FadeOutUp, Layout } from 'react-native-reanimated';
import BouncyCheckbox from 'react-native-bouncy-checkbox';
import { InsideHeader, Skelton } from '../../Component/Index';
import { InsideHeader, Skeleton } from '../../components';
import EmdComplete from './Component/EmdComplete';
import NavigationString from '../../Constant/NavigationString';
import { useNavigation } from '@react-navigation/native';
@@ -34,12 +34,9 @@ import AppText from '../../components/AppText';
import {
FileText,
Upload,
CheckCircle2,
ShieldCheck,
Paperclip,
ChevronRight,
PackageCheck,
BadgeIndianRupee,
} from 'lucide-react-native';
const Emd = () => {
@@ -350,6 +347,9 @@ const Emd = () => {
}
};
// Run once on mount; AlreadyEmdCheck is redeclared each render, so listing
// it as a dependency would re-fire the check on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { AlreadyEmdCheck(); }, []);
const styles = makeStyles(colors, spacing, shadows, typography);
@@ -410,7 +410,7 @@ const Emd = () => {
)}
{(isPending || isInitialLoading) ? (
<Skelton />
<Skeleton />
) : allBoolean.isEmdComplete ? (
<EmdComplete onNext={EmdContinueHandle} />
) : allBoolean.emdType ? (
-349
View File
@@ -1,349 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { View, FlatList, StyleSheet, Platform, Alert, BackHandler, Text, ToastAndroid } from 'react-native';
import LottieView from 'lottie-react-native';
import { InsideHeader } from '../../Component/Index';
import colors from '../../Constant/Color';
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, REVERSE_AUCTION_ITEM, SELECTED_AUCTION_ITEM } from '../../Services/ApiCall/ApiUrls';
import { CommonActions, useFocusEffect, useNavigation } from '@react-navigation/native';
import NavigationString from '../../Constant/NavigationString';
import CountdownTimer from '../../Component/Counter/CountdownTimer';
import { useTheme } from '../../Hooks/useTheme';
import AppText from '../../components/AppText';
import ReverseBiddingCard from './Component/ReverseBiddingCard';
import ConfirmBidModal from './Component/ConfirmBidModal';
import GoBackModal from './Component/GoBackModal';
const StartReverseBidding = () => {
const { colors } = useTheme();
const styles = React.useMemo(() => makeStyles(colors), [colors]);
const SelectedAuction = useSelector((state: any) => state.auction.auction);
const loginUser = useSelector((state: any) => state.token.token)
const Navigation: any = useNavigation()
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, createRoom, placeBid } = useWebSocketService(
Variables.webSocketUrl,
() => console.log('Connected!'),
(error) => console.log('WebSocket Error:', error),
loginUser.token,
);
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: any) => ({ ...prev, oneTimeBid: true }))
}
}
const getOneTimeBidValue = async () => {
try {
const selectedRes = await apiCall<any>('get', AUCTION_ONE_TIME_BID, {}, { auctionId: SelectedAuction.auctionId });
if (selectedRes.statusCode == 200) {
return selectedRes.data
} else {
return []
}
} catch (error) {
}
}
const getAllAcutionItems = async () => {
try {
const payload = { auctionNumber: SelectedAuction.auctionNumber };
let auctionItem: any[] = [];
if (SelectedAuction.auctionPattern === 'Reverse') {
const res = await apiCall<any>('get', REVERSE_AUCTION_ITEM, {}, 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);
subscribe(`/topic/room/${SelectedAuction.auctionNumber}`, data => {
console.log('🔥 BID UPDATE:', data);
});
const bidAmountMap2 = new Map<number, { bidAmount: number; contractorId: number }>();
(result || []).forEach((r: any) => {
bidAmountMap2.set(r.auctionItemId, {
bidAmount: r.bidAmount,
contractorId: r.contractorId,
});
});
// Check One Time bid enable butoon
if (SelectedAuction.auctionCategories != 'Live') {
const myBid = result.find(
(bid: any) => bid.contractorId === loginUser.contractorId
);
if (myBid) {
setAllBoolean((prev: any) => ({ ...prev, oneTimeBid: true }))
}
}
const bidAmountMap = new Map<number, number>();
(result || []).forEach((r: any) => {
bidAmountMap.set(r.auctionItemId, r.bidAmount);
});
const updatedFilteredItems = filteredItems.map(item => {
const bidData = bidAmountMap2.get(item.id);
return {
...item,
bidAmount: bidData?.bidAmount ?? item.auctionStartValue,
bidAvilable: bidData?.bidAmount ?? 0,
me: bidData?.contractorId == loginUser.contractorId ? true : false
};
});
const initialBids: Record<number, number> = {};
updatedFilteredItems.forEach(item => {
initialBids[item.id] = item.bidAmount;
});
setBidValues(initialBids);
setItems(updatedFilteredItems);
} else {
let result = []
if (SelectedAuction.rebidAllowed) {
setAllBoolean((prev: any) => ({ ...prev, rebid: true }))
}
if (SelectedAuction.auctionCategories != 'Live') {
result = 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: item.auctionStartValue,
contractorId: item.contractorId,
};
}
return {
...item,
bidAmount: bid.bidAmount,
bidId: bid.bidId,
contractorId: bid.contractorId,
contractorName: bid.contractorName,
me: bid.contractorId === loginUser.contractorId,
bidTime: bid.bidTime,
};
});
const initialBids: Record<number, number> = {};
mergedData.forEach(item => {
initialBids[item.id] = item.bidAmount;
});
setBidValues(initialBids);
setItems(mergedData)
setBidValues(initialBids);
}
} catch (error) {
console.error('Error fetching auction items:', error);
}
};
subscribe(`/topic/room/${SelectedAuction.auctionNumber}`, async (message) => {
const result = message[message.length - 1]
const updatedBidItem = (prev: any[]) =>
prev.map(item =>
item.id === result.auctionItemId
? { ...item, bidAmount: result.bidAmount, me: result.contractorId == loginUser.contractorId ? true : false }
: item
);
setItems(updatedBidItem);
setBidValues(prev => ({
...prev,
[result.auctionItemId]: result.bidAmount,
}));
try {
} catch (err) {
console.log('JSON parse error:', err);
}
});
useEffect(() => {
if (SelectedAuction.auctionCategories == 'One-time') {
getAuction()
}
getAllAcutionItems();
subscribe(`/topic/room/${SelectedAuction.auctionNumber}`, (data) => { });
}, [allBoolean.reload]);
useEffect(() => {
const initial: Record<number, number> = {};
items.forEach((item: any) => {
initial[item.id] = item.bidAmount + item.bidVariationValue;
});
setBidValues(initial);
}, []);
const handleUpdate = (id: number, val: number) => {
setBidValues(prev => ({ ...prev, [id]: prev[id] + val }));
};
const submitBid = async (item: any) => {
const payload = {
itemId: item.id,
bidAmount: bidValues[item.id],
auctionId: SelectedAuction.auctionId,
contractorId: loginUser.contractorId,
fullName: loginUser.fullName,
roomId: SelectedAuction.auctionNumber,
};
setAllBoolean((prev: any) => ({ ...prev, bidPayload: payload, showDialog: true }));
};
const placeBidSubmit = async (data: any) => {
setAllBoolean((prev: any) => ({ ...prev, showDialog: false }));
try {
const result: any = await placeBid(data);
if (result?.status && result.data) {
const updatedBidItem = (prev: any[]) =>
prev.map(item =>
item.id === result.data.auctionItemId
? { ...item, bidAmount: result.data.bidAmount, me: result.data?.contractorId == loginUser.contractorId ? true : false }
: item
);
getAllAcutionItems()
}
} catch (error) {
console.error('Error placing bid:', error);
}
};
useFocusEffect(
useCallback(() => {
const onBackPress = () => {
setAllBoolean((prev: any) => ({ ...prev, showBackDialog: true }));
return true; // block default back action
};
const subscription = BackHandler.addEventListener(
'hardwareBackPress',
onBackPress
);
return () => subscription.remove();
}, [Navigation])
);
return (
<View style={styles.container}>
<InsideHeader title="Bidding Panel" showArrow />
<View style={styles.container2}>
<AppText variant="h3" color={colors.text} bold style={styles.title as any}>
{SelectedAuction.deptName}
</AppText>
<View style={styles.timerWrapper}>
<CountdownTimer
endDate={SelectedAuction.keyDates.auctionBidding.endDateTime}
myStyle={{ fontSize: 30 }}
size={30}
/>
</View>
</View>
<FlatList
data={items}
keyExtractor={item => item.id.toString()}
renderItem={({ item, index }) => (
<ReverseBiddingCard
item={{ ...item, index }}
bidValue={bidValues[item.id] || 0}
onIncrease={() => handleUpdate(item.id, item.emdAmount)}
onDecrease={() => handleUpdate(item.id, -item.emdAmount)}
onPlaceBid={() => { submitBid(item) }}
SelectedAuction={SelectedAuction}
allBoolean={allBoolean}
setAllBoolean={setAllBoolean}
/>
)}
contentContainerStyle={{ padding: 16 }}
/>
{allBoolean.showSuccess && (
<View style={styles.overlay}>
<LottieView
source={require('../../lottie/Success.json')}
autoPlay
loop={false}
style={{ width: 200, height: 200 }}
/>
</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 StartReverseBidding;
const makeStyles = (colors: any) => StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
container2: {
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 8,
},
title: {
marginBottom: 6,
} as any,
timerWrapper: {
alignItems: 'center',
},
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: colors.overlay, justifyContent: 'center', alignItems: 'center', zIndex: 99 },
});
@@ -0,0 +1,86 @@
import { getBiddingConfig } from '../biddingPattern';
import { FORWARD_AUCTION_ITEM, REVERSE_AUCTION_ITEM } from '../../../Services/ApiCall/ApiUrls';
describe('getBiddingConfig', () => {
it('returns the reverse config only for exactly "Reverse"', () => {
expect(getBiddingConfig('Reverse').pattern).toBe('Reverse');
// Forward is the safe default for anything else (Forward, undefined, junk).
expect(getBiddingConfig('Forward').pattern).toBe('Forward');
expect(getBiddingConfig(undefined).pattern).toBe('Forward');
expect(getBiddingConfig('anything').pattern).toBe('Forward');
});
});
describe('forward pattern', () => {
const cfg = getBiddingConfig('Forward');
const item = { auctionStartValueFigure: 100, bidVariationValue: 10, bidAmount: 150 };
it('reads the forward start-value and step fields', () => {
expect(cfg.itemsUrl).toBe(FORWARD_AUCTION_ITEM);
expect(cfg.startValue(item)).toBe(100);
expect(cfg.step(item)).toBe(10);
expect(cfg.stepLabel).toBe('Increment');
expect(cfg.stepPrefix).toBe('+');
expect(cfg.inlineManualInput).toBe(false);
});
it('allows unbounded increases but blocks decreasing below the current bid', () => {
expect(cfg.canIncrease(999999, item)).toBe(true);
expect(cfg.canDecrease(160, item)).toBe(true); // 160 > 150
expect(cfg.canDecrease(150, item)).toBe(false); // not strictly greater
expect(cfg.canDecrease(140, item)).toBe(false);
});
it('rejects a manual bid below the current bid', () => {
expect(cfg.validateManualBid(149, item)).toBe('Enter a value greater than the current bid value');
expect(cfg.validateManualBid(150, item)).toBeNull();
expect(cfg.validateManualBid(200, item)).toBeNull();
});
it('accepts any typed input (forward is unbounded)', () => {
expect(cfg.acceptManualInput(5, item)).toBe(true);
});
it('shows body/submit only for a fresh (un-bid) item when not rebidding', () => {
const fresh = { contractorId: undefined };
const alreadyBid = { contractorId: 7 };
expect(cfg.isBodyFull({ oneTimeBid: false, rebid: false }, fresh)).toBe(true);
expect(cfg.showSubmit({ oneTimeBid: false, rebid: false }, fresh)).toBe(true);
expect(cfg.showSubmit({ oneTimeBid: false, rebid: false }, alreadyBid)).toBe(false);
expect(cfg.showSubmit({ oneTimeBid: false, rebid: true }, fresh)).toBe(false);
});
});
describe('reverse pattern', () => {
const cfg = getBiddingConfig('Reverse');
const item = { auctionStartValue: 1000, emdAmount: 50, bidAmount: 800 };
it('reads the reverse start-value and step fields', () => {
expect(cfg.itemsUrl).toBe(REVERSE_AUCTION_ITEM);
expect(cfg.startValue(item)).toBe(1000);
expect(cfg.step(item)).toBe(50);
expect(cfg.stepLabel).toBe('Decrement');
expect(cfg.stepPrefix).toBe('-');
expect(cfg.inlineManualInput).toBe(true);
});
it('caps increases at the current bid and decreases at the EMD floor', () => {
expect(cfg.canIncrease(700, item)).toBe(true); // 700 < 800
expect(cfg.canIncrease(800, item)).toBe(false);
expect(cfg.canDecrease(51, item)).toBe(true); // 51 > 50
expect(cfg.canDecrease(50, item)).toBe(false);
});
it('accepts typed input only within [0, auctionStartValue]', () => {
expect(cfg.acceptManualInput(0, item)).toBe(true);
expect(cfg.acceptManualInput(1000, item)).toBe(true);
expect(cfg.acceptManualInput(1001, item)).toBe(false);
expect(cfg.acceptManualInput(-1, item)).toBe(false);
});
it('always shows body + submit (reverse has no fresh-item gate)', () => {
expect(cfg.isBodyFull({ oneTimeBid: true, rebid: true }, item)).toBe(true);
expect(cfg.showSubmit({ oneTimeBid: false, rebid: false }, item)).toBe(true);
expect(cfg.validateManualBid(123, item)).toBeNull();
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Pattern configuration for the unified auction bidding screen/card.
*
* Forward and Reverse bidding were 90%-duplicated screens that drifted apart.
* Every behavioral difference between them is captured HERE, explicitly, so
* the screen and card contain zero `if (pattern === ...)` logic and a bidding
* fix can never again land in one pattern but not the other.
*/
import { FORWARD_AUCTION_ITEM, REVERSE_AUCTION_ITEM } from '../../Services/ApiCall/ApiUrls';
export type AuctionPattern = 'Forward' | 'Reverse';
export type BiddingFlags = {
oneTimeBid: boolean;
rebid: boolean;
};
export type BiddingPatternConfig = {
pattern: AuctionPattern;
/** Endpoint returning this pattern's auction items. */
itemsUrl: string;
/** Opening amount — the backend uses a different field per pattern. */
startValue: (item: any) => number;
/** Stepper step size. */
step: (item: any) => number;
stepLabel: 'Increment' | 'Decrement';
stepPrefix: '+' | '-';
/** Forward bids may rise without bound; reverse bids may not exceed the current bid. */
canIncrease: (bidValue: number, item: any) => boolean;
/** Forward bids may not drop below the current bid; reverse not below the EMD amount. */
canDecrease: (bidValue: number, item: any) => boolean;
/** Gate applied while typing a manual (one-time/rebid) amount. */
acceptManualInput: (value: number, item: any) => boolean;
/** Submit-time validation for manual bids; returns an error message or null. */
validateManualBid: (value: number, item: any) => string | null;
/** Reverse renders the manual input inline within the stepper row. */
inlineManualInput: boolean;
/** Whether the collapsible body opens at full height. */
isBodyFull: (flags: BiddingFlags, item: any) => boolean;
/** Whether the Confirm Bid button is rendered. */
showSubmit: (flags: BiddingFlags, item: any) => boolean;
};
const forward: BiddingPatternConfig = {
pattern: 'Forward',
itemsUrl: FORWARD_AUCTION_ITEM,
startValue: item => item?.auctionStartValueFigure,
step: item => item?.bidVariationValue,
stepLabel: 'Increment',
stepPrefix: '+',
canIncrease: () => true,
canDecrease: (bidValue, item) => bidValue > item.bidAmount,
acceptManualInput: () => true,
validateManualBid: (value, item) =>
value < Number(item.bidAmount) ? 'Enter a value greater than the current bid value' : null,
inlineManualInput: false,
isBodyFull: (flags, item) => !flags.rebid && item.contractorId == undefined,
showSubmit: (flags, item) => !flags.rebid && item.contractorId == undefined,
};
const reverse: BiddingPatternConfig = {
pattern: 'Reverse',
itemsUrl: REVERSE_AUCTION_ITEM,
startValue: item => item?.auctionStartValue,
step: item => item?.emdAmount,
stepLabel: 'Decrement',
stepPrefix: '-',
canIncrease: (bidValue, item) => bidValue < item.bidAmount,
canDecrease: (bidValue, item) => bidValue > item.emdAmount,
acceptManualInput: (value, item) => value >= 0 && value <= Number(item?.auctionStartValue),
validateManualBid: () => null,
inlineManualInput: true,
isBodyFull: () => true,
showSubmit: () => true,
};
export const getBiddingConfig = (pattern: string | undefined): BiddingPatternConfig =>
pattern === 'Reverse' ? reverse : forward;
+10 -3
View File
@@ -24,16 +24,20 @@ const Home = () => {
const userData = useSelector(getToken);
// Hooks for data
const { data: dashboardData, refetch: refetchDashboard, isPending: isLoadingDashboard } = useDashboard();
const { data: tenderData, refetch: refetchTender, isPending: isLoadingTender } = useTenderDashboard();
const { data: dashboardData, refetch: refetchDashboard, isPending: isLoadingDashboard, isFetching: isFetchingDashboard } = useDashboard();
const { data: tenderData, refetch: refetchTender, isPending: isLoadingTender, isFetching: isFetchingTender } = useTenderDashboard();
// Reload the dashboard data every time the Home tab gains focus.
useFocusEffect(
useCallback(() => {
refetchDashboard();
refetchTender();
}, [])
}, [refetchDashboard, refetchTender])
);
// True while a background refresh (on tab focus) is in flight and data is already shown.
const isRefreshing = (isFetchingDashboard || isFetchingTender) && !(isLoadingDashboard || isLoadingTender);
useEffect(() => {
if (dashboardData) {
setAuctionStats(dashboardData.data || {});
@@ -84,6 +88,9 @@ const Home = () => {
</View>
</View>
<View style={styles.headerActions}>
{isRefreshing && (
<ActivityIndicator size="small" color={colors.primary} style={{ marginRight: 12 }} />
)}
<TouchableOpacity onPress={() => setIsMenuVisible(true)}>
<Menu size={24} color={colors.text} />
</TouchableOpacity>
+5 -4
View File
@@ -4,7 +4,6 @@ import { SafeAreaView } from "react-native-safe-area-context";
import { WebView } from "react-native-webview";
import { CommonActions } from "@react-navigation/native";
import colors from "../../Constant/Color";
import { BASE_URL } from "../../Services/ApiCall/ApiUrls";
const SUCCESS_DELAY_MS = 5000; // 5 seconds
@@ -25,6 +24,8 @@ export const KycWebViewScreen = ({ route, navigation }: any) => {
useEffect(() => {
return () => {
if (pollTimerRef.current) {
// Intentionally reads the ref at cleanup; the timer id stored on the ref is exactly what must be cleared.
// eslint-disable-next-line react-hooks/exhaustive-deps
clearInterval(pollTimerRef.current);
}
if (successTimeoutRef.current) {
@@ -66,16 +67,16 @@ export const KycWebViewScreen = ({ route, navigation }: any) => {
}, SUCCESS_DELAY_MS);
};
const handleLoadStart = (event: any) => {
const handleLoadStart = (_event: any) => {
setIsLoading(true);
setLoadError(false);
};
const handleLoadEnd = (event: any) => {
const handleLoadEnd = (_event: any) => {
setIsLoading(false);
};
const handleError = (event: any) => {
const handleError = (_event: any) => {
setIsLoading(false);
setLoadError(true);
};
-1
View File
@@ -6,7 +6,6 @@ import {
Modal,
TextInput,
TouchableOpacity,
Alert,
KeyboardAvoidingView,
Platform,
TouchableWithoutFeedback,
+20 -134
View File
@@ -1,25 +1,25 @@
import React, { useState, useEffect, useCallback } from 'react';
import { View, Text, StyleSheet, KeyboardAvoidingView, Platform, ScrollView, TouchableOpacity, Dimensions, StatusBar, TextInput, Image, ActivityIndicator, Keyboard } from 'react-native';
import { View, Text, StyleSheet, KeyboardAvoidingView, Platform, ScrollView, TouchableOpacity, Dimensions, TextInput, Image, ActivityIndicator, Keyboard } from 'react-native';
import { Formik } from 'formik';
import * as Yup from 'yup';
import { useDispatch } from 'react-redux';
import { useNavigation, CommonActions } from '@react-navigation/native';
import { useNavigation } from '@react-navigation/native';
import { Mail, Lock, Eye, EyeOff, ArrowRight } from 'lucide-react-native';
import Animated, { FadeInDown, FadeInUp, FadeInRight, FadeOutLeft, useSharedValue, useAnimatedStyle, withRepeat, withTiming, Easing, interpolate, interpolateColor } from 'react-native-reanimated';
import Animated, { FadeInDown, FadeInUp, FadeInRight, FadeOutLeft, useSharedValue, useAnimatedStyle, withTiming, interpolate, interpolateColor } from 'react-native-reanimated';
import LinearGradient from 'react-native-linear-gradient';
import { BlurView } from '@react-native-community/blur';
import { useTheme } from '../../Hooks/useTheme';
import { useCreate } from '../../Hooks/useCreate';
import { setToken } from '../../Redux/Slices/Token';
import NavigationString from '../../Constant/NavigationString';
import ImagePath from '../../Constant/ImagePath';
import { TENDER_LOGIN } from '../../Services/ApiCall/ApiUrls';
import { showErrorAlert, showToast } from '../../Constant/ShowDailog';
import AppBackground from '../../components/AppBackground';
import FontsFamily from '../../Constant/FontsFamily';
import { useRagistrationState } from '../../Services/ApiCall/Hooks';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useLogin, useRagistrationState } from '../../Services/ApiCall/Hooks';
import { performLogout } from '../../Services/Auth/session';
import { applyRouteDecision, decideRegistrationRoute } from '../../Services/Auth/registrationRouting';
import { logger } from '../../core/observability/logger';
const { width, height } = Dimensions.get('window');
@@ -36,7 +36,7 @@ const CAROUSEL_TEXTS = [
];
const Login = () => {
const { colors, spacing, shadows, isDarkMode } = useTheme();
const { colors, isDarkMode } = useTheme();
const navigation = useNavigation<any>();
const dispatch = useDispatch();
@@ -65,155 +65,39 @@ const Login = () => {
bgTransition.value = withTiming(1, { duration: 1200 });
};
// Animation Shared Values for Background Blobs
const blob1Pos = useSharedValue(0);
const blob2Pos = useSharedValue(0);
const blob3Pos = useSharedValue(0);
useEffect(() => {
// Animate Blobs
blob1Pos.value = withRepeat(
withTiming(1, { duration: 8000, easing: Easing.inOut(Easing.sin) }),
-1,
true
);
blob2Pos.value = withRepeat(
withTiming(1, { duration: 10000, easing: Easing.inOut(Easing.quad) }),
-1,
true
);
blob3Pos.value = withRepeat(
withTiming(1, { duration: 12000, easing: Easing.inOut(Easing.cubic) }),
-1,
true
);
}, []);
const blob1Style = useAnimatedStyle(() => ({
transform: [
{ translateX: interpolate(blob1Pos.value, [0, 1], [0, 50]) },
{ translateY: interpolate(blob1Pos.value, [0, 1], [0, 30]) },
{ scale: interpolate(blob1Pos.value, [0, 1], [1, 1.2]) },
],
}));
const blob2Style = useAnimatedStyle(() => ({
transform: [
{ translateX: interpolate(blob2Pos.value, [0, 1], [0, -40]) },
{ translateY: interpolate(blob2Pos.value, [0, 1], [0, -50]) },
{ scale: interpolate(blob2Pos.value, [0, 1], [1, 1.1]) },
],
}));
const blob3Style = useAnimatedStyle(() => ({
transform: [
{ translateX: interpolate(blob3Pos.value, [0, 1], [0, 30]) },
{ translateY: interpolate(blob3Pos.value, [0, 1], [0, 40]) },
{ scale: interpolate(blob3Pos.value, [0, 1], [1, 1.3]) },
],
}));
const animatedHeaderStyle = useAnimatedStyle(() => ({
transform: [{ translateY: headerTranslateY.value }],
}));
const animatedFormStyle = useAnimatedStyle(() => ({
opacity: formOpacity.value,
transform: [{ translateY: interpolate(formOpacity.value, [0, 1], [20, 0]) }],
}));
// Primary brand color
const PRIMARY = '#e8a44c';
const ACCENT_1 = '#fbbf24'; // Amber-400
const ACCENT_2 = '#f59e0b'; // Amber-500
// Mutations
const loginMutation = useCreate<any, any>(TENDER_LOGIN, {
onSuccess: (res) => handleSuccess(res),
onError: (err) => showErrorAlert(err.message || 'Login failed')
});
const routeByOrgType = useCallback((params: { otherId: any; orgType: any }) => {
const orgType = String(params.orgType ?? '');
if (orgType === '4' || orgType === '5' || orgType === '6') {
navigation.replace(NavigationString.StageThreeScreen, params);
return;
}
if (orgType === '7') {
navigation.replace(NavigationString.Partnership, params);
return;
}
navigation.replace('Dashboard');
}, [navigation]);
const loginMutation = useLogin();
const handleGetData = useCallback(async () => {
try {
const res = await registrationStateMutate();
console.log("splash", res);
if (res?.status === false) {
navigation.reset({
index: 0,
routes: [{ name: NavigationString.Login }],
});
const decision = decideRegistrationRoute(res);
if (decision) {
applyRouteDecision(navigation, decision);
return;
}
if (res?.data?.message === 'No Response From Server' && res?.data?.success === false) {
navigation.reset({
index: 0,
routes: [{ name: NavigationString.Login }],
});
return;
}
if (res?.statusCode === 404 || res?.statusCode === 401) {
navigation.replace('Registration');
return;
}
if (res?.statusCode === 200 && res?.data) {
const { registrationStatus, contOtherId, contOrganization } = res.data;
const params = { otherId: contOtherId, orgType: contOrganization };
switch (registrationStatus) {
case null:
case undefined:
navigation.replace(NavigationString.Registration);
break;
case 'Pending':
routeByOrgType(params);
break;
case 'Complete':
case 'Completed':
navigation.replace(NavigationString.Home);
break;
default:
navigation.replace(NavigationString.Login);
break;
}
return;
}
console.error('API error:', res);
logger.warn('auth', 'unexpected registration-state response', { statusCode: res?.statusCode });
showToast(res?.message || 'Something went wrong');
navigation.replace('Registration');
} catch (error: any) {
console.error('Registration status error:', error);
logger.error('auth', 'registration status resolution failed', { error: String(error) });
showToast(error?.message || 'Something went wrong');
if (error?.response?.status === 404) {
navigation.replace('Registration');
} else {
await AsyncStorage.removeItem('persist:root');
performLogout();
navigation.replace(NavigationString.Login);
}
}
}, [navigation, registrationStateMutate, routeByOrgType]);
}, [navigation, registrationStateMutate]);
const handleSuccess = (res: any) => {
dispatch(setToken({ ...res.data, token: res.token, userType: 'tender' }));
console.log('handleSuccess', res)
console.log('==================== 🔑 AUTH TOKEN ====================');
console.log('TOKEN →', res?.token);
console.log('=======================================================');
if (res.statusCode === 200) {
handleGetData()
} else {
@@ -225,8 +109,10 @@ const Login = () => {
if (isLoading) return; // guard against double submit
Keyboard.dismiss(); // smooth the navigation transition (no keyboard mid-replace)
const payload = { emailAdd: values.email, password: values.password, device: "mobileApp" };
console.log('payload------------', payload)
loginMutation.mutate(payload);
loginMutation.mutate(payload, {
onSuccess: res => handleSuccess(res),
onError: (err: any) => showErrorAlert(err?.message || 'Login failed'),
});
};
// Covers the WHOLE sign-in flow: login request + registration-state resolve
+40 -59
View File
@@ -1,5 +1,4 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import axios from 'axios';
import React, { useState, useEffect, useRef } from 'react';
import {
StyleSheet,
@@ -18,6 +17,7 @@ import {
} from 'react-native';
import { Eye, EyeOff } from 'lucide-react-native';
import { BASE_URL } from '../../Services/ApiCall/ApiUrls';
import { request } from '../../core/http/client';
import colors from '../../Constant/Color';
interface ResetPasswordProps {
@@ -26,7 +26,7 @@ interface ResetPasswordProps {
}
const ResetPassword: React.FC<ResetPasswordProps> = ({ route, navigation }) => {
const [email, setEmail] = useState<string>(route?.params?.email || '');
const [email] = useState<string>(route?.params?.email || '');
const [otp, setOtp] = useState<string[]>(['', '', '', '', '', '']);
const [newPassword, setNewPassword] = useState<string>('');
const [confirmPassword, setConfirmPassword] = useState<string>('');
@@ -45,7 +45,7 @@ const ResetPassword: React.FC<ResetPasswordProps> = ({ route, navigation }) => {
const otpInputs = useRef<Array<TextInput | null>>([]);
const newPasswordRef = useRef<TextInput>(null);
const confirmPasswordRef = useRef<TextInput>(null);
const resendTimerRef = useRef<NodeJS.Timeout | null>(null);
const resendTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
checkResendCooldown();
@@ -107,6 +107,8 @@ const ResetPassword: React.FC<ResetPasswordProps> = ({ route, navigation }) => {
if (fullOtp.length === 6 && !isOtpVerified) {
handleSubmitOtp();
}
// Intentionally runs only on otp changes; other deps are stable or recreated each render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [otp]);
const handleOtpChange = (value: string, index: number) => {
@@ -145,20 +147,13 @@ const ResetPassword: React.FC<ResetPasswordProps> = ({ route, navigation }) => {
setOtpError('');
try {
const res = await axios.post(
`${BASE_URL}eproc/verify-otp?email=${encodeURIComponent(email)}&otp=${fullOtp}`,
null,
{
headers: {
'accept': '*/*',
'Content-Type': 'application/json'
}
}
);
const res = await request<any>({
method: 'post',
url: `${BASE_URL}eproc/verify-otp`,
params: { email, otp: fullOtp },
});
console.log('API Response OTP verification:', res.data);
if (res.data?.statusCode === 200) {
if (res?.statusCode === 200) {
ToastAndroid.show('OTP verified successfully!', ToastAndroid.SHORT);
setIsOtpVerified(true);
@@ -166,16 +161,11 @@ const ResetPassword: React.FC<ResetPasswordProps> = ({ route, navigation }) => {
newPasswordRef.current?.focus();
}, 100);
} else {
throw new Error(res.data?.message || 'OTP verification failed');
throw new Error(res?.message || 'OTP verification failed');
}
} catch (error) {
console.error('Verification Error:', error);
const errorMessage = axios.isAxiosError(error)
? error.response?.data?.message || error.message
: error instanceof Error
? error.message
: 'OTP verification failed';
const errorMessage =
error instanceof Error ? error.message : 'OTP verification failed';
setOtpError(errorMessage);
ToastAndroid.show(errorMessage, ToastAndroid.SHORT);
@@ -216,20 +206,21 @@ const ResetPassword: React.FC<ResetPasswordProps> = ({ route, navigation }) => {
setLoading(true);
try {
const res = await axios.post(
`${BASE_URL}eproc/reset-password?email=${encodeURIComponent(email)}&otp=${otp.join('')}&newPassword=${encodeURIComponent(newPassword)}&confirmPassword=${encodeURIComponent(confirmPassword)}`,
null,
{
headers: {
'accept': '*/*',
'Content-Type': 'application/json'
}
}
);
// TODO(backend): credentials belong in the request body, not the
// query string (they land in server/proxy logs) — needs an
// endpoint contract change.
const res = await request<any>({
method: 'post',
url: `${BASE_URL}eproc/reset-password`,
params: {
email,
otp: otp.join(''),
newPassword,
confirmPassword,
},
});
console.log('API Response password reset:', res.data);
if (res.data?.statusCode === 200) {
if (res?.statusCode === 200) {
Alert.alert(
'Success',
'Your password has been reset successfully.',
@@ -240,14 +231,11 @@ const ResetPassword: React.FC<ResetPasswordProps> = ({ route, navigation }) => {
}]
);
} else {
throw new Error(res.data?.message || 'Password reset failed');
throw new Error(res?.message || 'Password reset failed');
}
} catch (err) {
const errorMessage = axios.isAxiosError(err)
? err.response?.data?.message || err.message
: err instanceof Error
? err.message
: 'Failed to reset password. Please try again.';
const errorMessage =
err instanceof Error ? err.message : 'Failed to reset password. Please try again.';
setError(errorMessage);
ToastAndroid.show(errorMessage, ToastAndroid.SHORT);
@@ -262,31 +250,24 @@ const ResetPassword: React.FC<ResetPasswordProps> = ({ route, navigation }) => {
setResendLoading(true);
try {
const res = await axios.post(
`${BASE_URL}eproc/send-otp-forgot-password?email=${encodeURIComponent(email)}`,
null,
{
headers: {
'accept': '*/*',
}
}
);
const res = await request<any>({
method: 'post',
url: `${BASE_URL}eproc/send-otp-forgot-password`,
params: { email },
});
if (res.data?.statusCode === 200) {
if (res?.statusCode === 200) {
ToastAndroid.show('OTP sent successfully', ToastAndroid.SHORT);
setOtp(['', '', '', '', '', '']);
setIsOtpVerified(false);
setOtpError('');
startResendCooldown();
} else {
throw new Error(res.data?.message || 'Failed to resend OTP');
throw new Error(res?.message || 'Failed to resend OTP');
}
} catch (error) {
const errorMessage = axios.isAxiosError(error)
? error.response?.data?.message || error.message
: error instanceof Error
? error.message
: 'Failed to resend OTP';
const errorMessage =
error instanceof Error ? error.message : 'Failed to resend OTP';
ToastAndroid.show(errorMessage, ToastAndroid.SHORT);
} finally {
+324
View File
@@ -0,0 +1,324 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
KeyboardAvoidingView,
Modal,
Platform,
ScrollView,
StyleSheet,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { Save, X } from 'lucide-react-native';
import { useUpdateUserProfile } from '../../Services/ApiCall/Hooks';
import { useTheme } from '../../Hooks/useTheme';
import AppText from '../../components/AppText';
import { ContractorData, UpdateProfilePayload } from './profileTypes';
interface EditProfileModalProps {
visible: boolean;
onClose: () => void;
data: ContractorData;
onSuccess: () => void;
}
/** Personal-information edit form; owns its validation and update mutation. */
const EditProfileModal: React.FC<EditProfileModalProps> = ({
visible,
onClose,
data,
onSuccess,
}) => {
const { colors } = useTheme();
const styles = useMemo(() => createStyles(colors), [colors]);
const [formData, setFormData] = useState<UpdateProfilePayload>({
firstName: data.firstName || '',
middleName: data.middleName || '',
lastName: data.lastName || '',
mobileNo: data.mobileNo || '',
emailId: data.emailId || '',
fullname: data.fullName || '',
});
const [errors, setErrors] = useState<Partial<UpdateProfilePayload>>({});
const updateProfileMutation = useUpdateUserProfile();
useEffect(() => {
if (visible) {
setFormData({
firstName: data.firstName || '',
middleName: data.middleName || '',
lastName: data.lastName || '',
mobileNo: data.mobileNo || '',
emailId: data.emailId || '',
fullname: data.fullName || '',
});
setErrors({});
}
}, [visible, data]);
const validateForm = (): boolean => {
const newErrors: Partial<UpdateProfilePayload> = {};
if (!formData.firstName.trim()) {
newErrors.firstName = 'First name is required';
}
if (!formData.lastName.trim()) {
newErrors.lastName = 'Last name is required';
}
if (!formData.emailId.trim()) {
newErrors.emailId = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.emailId)) {
newErrors.emailId = 'Invalid email format';
}
if (!formData.mobileNo.trim()) {
newErrors.mobileNo = 'Mobile number is required';
} else if (!/^\d{10}$/.test(formData.mobileNo.replace(/\s/g, ''))) {
newErrors.mobileNo = 'Invalid mobile number';
}
const generatedFullName = `${formData.firstName} ${formData.middleName} ${formData.lastName}`.trim();
setFormData(prev => ({ ...prev, fullname: generatedFullName }));
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = async () => {
if (!validateForm()) {
return;
}
try {
await updateProfileMutation.mutateAsync(formData);
Alert.alert('Success', 'Profile updated successfully');
onSuccess();
onClose();
} catch (error: any) {
Alert.alert('Error', error?.message || 'Failed to update profile');
}
};
return (
<Modal
visible={visible}
animationType="slide"
transparent={true}
onRequestClose={onClose}
>
<KeyboardAvoidingView
style={styles.modalOverlay}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={[styles.modalContent, { backgroundColor: colors.background }]}>
<View style={styles.modalHeader}>
<AppText variant="h3" style={styles.modalTitle}>Edit Personal Information</AppText>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<X size={24} color={colors.text} />
</TouchableOpacity>
</View>
<ScrollView style={styles.formContainer} showsVerticalScrollIndicator={false}>
<View style={styles.inputGroup}>
<AppText variant="bodySmall" style={styles.inputLabel}>First Name *</AppText>
<TextInput
style={[styles.input, errors.firstName && styles.inputError]}
value={formData.firstName}
onChangeText={(text) => setFormData({ ...formData, firstName: text })}
placeholder="Enter first name"
placeholderTextColor={colors.textSecondary}
/>
{errors.firstName && (
<AppText variant="caption" style={styles.errorText}>{errors.firstName}</AppText>
)}
</View>
<View style={styles.inputGroup}>
<AppText variant="bodySmall" style={styles.inputLabel}>Middle Name</AppText>
<TextInput
style={styles.input}
value={formData.middleName}
onChangeText={(text) => setFormData({ ...formData, middleName: text })}
placeholder="Enter middle name"
placeholderTextColor={colors.textSecondary}
/>
</View>
<View style={styles.inputGroup}>
<AppText variant="bodySmall" style={styles.inputLabel}>Last Name *</AppText>
<TextInput
style={[styles.input, errors.lastName && styles.inputError]}
value={formData.lastName}
onChangeText={(text) => setFormData({ ...formData, lastName: text })}
placeholder="Enter last name"
placeholderTextColor={colors.textSecondary}
/>
{errors.lastName && (
<AppText variant="caption" style={styles.errorText}>{errors.lastName}</AppText>
)}
</View>
<View style={styles.inputGroup}>
<AppText variant="bodySmall" style={styles.inputLabel}>Email *</AppText>
<TextInput
style={[styles.input, errors.emailId && styles.inputError]}
value={formData.emailId}
onChangeText={(text) => setFormData({ ...formData, emailId: text })}
placeholder="Enter email"
placeholderTextColor={colors.textSecondary}
keyboardType="email-address"
autoCapitalize="none"
/>
{errors.emailId && (
<AppText variant="caption" style={styles.errorText}>{errors.emailId}</AppText>
)}
</View>
<View style={styles.inputGroup}>
<AppText variant="bodySmall" style={styles.inputLabel}>Mobile Number *</AppText>
<TextInput
style={[styles.input, errors.mobileNo && styles.inputError]}
value={formData.mobileNo}
onChangeText={(text) => setFormData({ ...formData, mobileNo: text })}
placeholder="Enter mobile number"
placeholderTextColor={colors.textSecondary}
keyboardType="phone-pad"
maxLength={10}
/>
{errors.mobileNo && (
<AppText variant="caption" style={styles.errorText}>{errors.mobileNo}</AppText>
)}
</View>
</ScrollView>
<View style={styles.modalFooter}>
<TouchableOpacity
style={[styles.button, styles.cancelButton]}
onPress={onClose}
disabled={updateProfileMutation.isPending}
>
<AppText variant="button" style={styles.cancelButtonText}>Cancel</AppText>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.saveButton]}
onPress={handleSave}
disabled={updateProfileMutation.isPending}
>
{updateProfileMutation.isPending ? (
<ActivityIndicator size="small" color={colors.primary} />
) : (
<>
<Save size={18} color={colors.buttonText} />
<AppText variant="button" style={styles.saveButtonText}>Save</AppText>
</>
)}
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
);
};
const createStyles = (colors: any) => StyleSheet.create({
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
modalContent: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: '90%',
paddingTop: 20,
borderRadius: 30,
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
modalTitle: {
fontSize: 20,
fontWeight: '700',
color: colors.text,
},
closeButton: {
padding: 4,
},
formContainer: {
padding: 20,
maxHeight: 500,
},
inputGroup: {
marginBottom: 20,
},
inputLabel: {
fontSize: 14,
fontWeight: '600',
color: colors.text,
marginBottom: 8,
},
input: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
padding: 14,
fontSize: 15,
color: colors.text,
backgroundColor: colors.surface,
},
inputError: {
borderColor: colors.error,
},
errorText: {
fontSize: 12,
color: colors.error,
marginTop: 4,
},
modalFooter: {
flexDirection: 'row',
padding: 20,
gap: 12,
borderTopWidth: 1,
borderTopColor: colors.border,
},
button: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 8,
},
cancelButton: {
backgroundColor: colors.background,
},
saveButton: {
backgroundColor: colors.primary,
},
cancelButtonText: {
fontSize: 16,
fontWeight: '600',
color: colors.text,
},
saveButtonText: {
fontSize: 16,
fontWeight: '600',
color: colors.buttonText,
},
});
export default EditProfileModal;
+20 -400
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import React, { useMemo, useState } from 'react';
import {
View,
ScrollView,
@@ -6,19 +6,10 @@ import {
TouchableOpacity,
RefreshControl,
ActivityIndicator,
Dimensions,
Platform,
ViewStyle,
TextStyle,
Modal,
TextInput,
KeyboardAvoidingView,
Alert,
StatusBar,
Image,
} from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import Animated, { FadeInDown, FadeInUp, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
import Animated, { FadeInUp } from 'react-native-reanimated';
import {
AlertCircle,
UserCircle,
@@ -45,70 +36,22 @@ import {
Star,
List,
Factory,
CheckCircle2,
CheckCircle,
CalendarClock,
ShieldCheck,
LucideIcon,
Edit,
X,
Save,
ChevronRight,
LogOut
} from 'lucide-react-native';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { useContractorProfile, useUpdateUserProfile, useESetuAdharVarification } from '../../Services/ApiCall/Hooks';
import { useContractorProfile, useESetuAdharVarification } from '../../Services/ApiCall/Hooks';
import { useSelector } from 'react-redux';
import ConfirmDialog from '../../Constant/Dialog/ConfirmDialog';
import { generateTransactionId } from '../../Helper/uniqueId';
import NavigationString from '../../Constant/NavigationString';
import { useTheme } from '../../Hooks/useTheme';
import AppText from '../../components/AppText';
import ImagePath from '../../Constant/ImagePath';
const { width } = Dimensions.get('window');
// Type Definitions
interface ContractorData {
contractorId: number;
firstName: string;
middleName: string;
lastName: string;
fullName: string;
emailId: string;
mobileNo: string;
company: string;
contractorType: string;
dialCode: string;
twoFactorAuthEnabled: boolean;
colorCodeHSL: string | null;
isActive: string;
isEnabled: boolean | null;
role: string;
contractorStatus: boolean;
designation: string | null;
contOtherId: number;
contCompanyType: string | null;
contOrganization: string;
agentAddress: string;
agentPincode: string;
agentCity: string;
agentState: string | null;
agentWebsite: string;
agentRegno: string;
orgPAN: string;
orgPANdob: string;
orgPANname: string;
gistnNo: string;
comAadharNo: string;
contractorClass: string;
socialCategory: string;
wizardStepStatus: string;
registrationStatus: string;
indianOrgName: string;
expiryDate: string;
msmeStatus: string;
}
import EditProfileModal from './EditProfileModal';
import { ContractorData, hasSectionData, isValidValue } from './profileTypes';
interface ProfileHeaderProps {
data: ContractorData;
@@ -144,37 +87,12 @@ interface StatItem {
onPress?: () => void;
}
interface UpdateProfilePayload {
firstName: string;
middleName: string;
lastName: string;
mobileNo: string;
emailId: string;
fullname: string;
}
// Helper function to check if value is valid
const isValidValue = (value: any): boolean => {
if (value === null || value === undefined) return false;
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 && trimmed.toLowerCase() !== 'n/a';
}
return true;
};
// Helper function to check if section has any valid data
const hasSectionData = (values: any[]): boolean => {
return values.some(value => isValidValue(value));
};
// Main Component
const ProfileTab: React.FC = () => {
const { colors, spacing, shadows, isDarkMode } = useTheme();
const { colors } = useTheme();
const styles = useMemo(() => createStyles(colors), [colors]);
const { data, refetch, isLoading, error } = useContractorProfile();
const user = useSelector((state: any) => state.token?.token);
console.log("User: ", user)
const Navigation: any = useNavigation();
const { mutateAsync: eSetuAdharVarification, isPending } = useESetuAdharVarification();
@@ -190,9 +108,12 @@ const ProfileTab: React.FC = () => {
}
};
useFocusEffect(() => {
setIsNavigating(false)
})
// useFocusEffect requires a memoized callback, or the effect re-runs on every render.
useFocusEffect(
React.useCallback(() => {
setIsNavigating(false);
}, []),
);
const handleProceedVerification = async () => {
try {
@@ -383,216 +304,6 @@ const ProfileTab: React.FC = () => {
);
};
// Edit Profile Modal Component
interface EditProfileModalProps {
visible: boolean;
onClose: () => void;
data: ContractorData;
onSuccess: () => void;
}
const EditProfileModal: React.FC<EditProfileModalProps> = ({
visible,
onClose,
data,
onSuccess,
}) => {
const { colors } = useTheme();
const styles = useMemo(() => createStyles(colors), [colors]);
const [formData, setFormData] = useState<UpdateProfilePayload>({
firstName: data.firstName || '',
middleName: data.middleName || '',
lastName: data.lastName || '',
mobileNo: data.mobileNo || '',
emailId: data.emailId || '',
fullname: data.fullName || '',
});
const [errors, setErrors] = useState<Partial<UpdateProfilePayload>>({});
const updateProfileMutation = useUpdateUserProfile();
useEffect(() => {
if (visible) {
setFormData({
firstName: data.firstName || '',
middleName: data.middleName || '',
lastName: data.lastName || '',
mobileNo: data.mobileNo || '',
emailId: data.emailId || '',
fullname: data.fullName || '',
});
setErrors({});
}
}, [visible, data]);
const validateForm = (): boolean => {
const newErrors: Partial<UpdateProfilePayload> = {};
if (!formData.firstName.trim()) {
newErrors.firstName = 'First name is required';
}
if (!formData.lastName.trim()) {
newErrors.lastName = 'Last name is required';
}
if (!formData.emailId.trim()) {
newErrors.emailId = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.emailId)) {
newErrors.emailId = 'Invalid email format';
}
if (!formData.mobileNo.trim()) {
newErrors.mobileNo = 'Mobile number is required';
} else if (!/^\d{10}$/.test(formData.mobileNo.replace(/\s/g, ''))) {
newErrors.mobileNo = 'Invalid mobile number';
}
const generatedFullName = `${formData.firstName} ${formData.middleName} ${formData.lastName}`.trim();
setFormData(prev => ({ ...prev, fullname: generatedFullName }));
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = async () => {
if (!validateForm()) {
return;
}
try {
const result = await updateProfileMutation.mutateAsync(formData);
Alert.alert('Success', 'Profile updated successfully');
onSuccess();
onClose();
} catch (error: any) {
Alert.alert('Error', error?.message || 'Failed to update profile');
}
};
return (
<Modal
visible={visible}
animationType="slide"
transparent={true}
onRequestClose={onClose}
>
<KeyboardAvoidingView
style={styles.modalOverlay}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={[styles.modalContent, { backgroundColor: colors.background }]}>
<View style={styles.modalHeader}>
<AppText variant="h3" style={styles.modalTitle}>Edit Personal Information</AppText>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<X size={24} color={colors.text} />
</TouchableOpacity>
</View>
<ScrollView style={styles.formContainer} showsVerticalScrollIndicator={false}>
<View style={styles.inputGroup}>
<AppText variant="bodySmall" style={styles.inputLabel}>First Name *</AppText>
<TextInput
style={[styles.input, errors.firstName && styles.inputError]}
value={formData.firstName}
onChangeText={(text) => setFormData({ ...formData, firstName: text })}
placeholder="Enter first name"
placeholderTextColor={colors.textSecondary}
/>
{errors.firstName && (
<AppText variant="caption" style={styles.errorText}>{errors.firstName}</AppText>
)}
</View>
<View style={styles.inputGroup}>
<AppText variant="bodySmall" style={styles.inputLabel}>Middle Name</AppText>
<TextInput
style={styles.input}
value={formData.middleName}
onChangeText={(text) => setFormData({ ...formData, middleName: text })}
placeholder="Enter middle name"
placeholderTextColor={colors.textSecondary}
/>
</View>
<View style={styles.inputGroup}>
<AppText variant="bodySmall" style={styles.inputLabel}>Last Name *</AppText>
<TextInput
style={[styles.input, errors.lastName && styles.inputError]}
value={formData.lastName}
onChangeText={(text) => setFormData({ ...formData, lastName: text })}
placeholder="Enter last name"
placeholderTextColor={colors.textSecondary}
/>
{errors.lastName && (
<AppText variant="caption" style={styles.errorText}>{errors.lastName}</AppText>
)}
</View>
<View style={styles.inputGroup}>
<AppText variant="bodySmall" style={styles.inputLabel}>Email *</AppText>
<TextInput
style={[styles.input, errors.emailId && styles.inputError]}
value={formData.emailId}
onChangeText={(text) => setFormData({ ...formData, emailId: text })}
placeholder="Enter email"
placeholderTextColor={colors.textSecondary}
keyboardType="email-address"
autoCapitalize="none"
/>
{errors.emailId && (
<AppText variant="caption" style={styles.errorText}>{errors.emailId}</AppText>
)}
</View>
<View style={styles.inputGroup}>
<AppText variant="bodySmall" style={styles.inputLabel}>Mobile Number *</AppText>
<TextInput
style={[styles.input, errors.mobileNo && styles.inputError]}
value={formData.mobileNo}
onChangeText={(text) => setFormData({ ...formData, mobileNo: text })}
placeholder="Enter mobile number"
placeholderTextColor={colors.textSecondary}
keyboardType="phone-pad"
maxLength={10}
/>
{errors.mobileNo && (
<AppText variant="caption" style={styles.errorText}>{errors.mobileNo}</AppText>
)}
</View>
</ScrollView>
<View style={styles.modalFooter}>
<TouchableOpacity
style={[styles.button, styles.cancelButton]}
onPress={onClose}
disabled={updateProfileMutation.isPending}
>
<AppText variant="button" style={styles.cancelButtonText}>Cancel</AppText>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.saveButton]}
onPress={handleSave}
disabled={updateProfileMutation.isPending}
>
{updateProfileMutation.isPending ? (
<ActivityIndicator size="small" color={colors.primary} />
) : (
<>
<Save size={18} color={colors.buttonText} />
<AppText variant="button" style={styles.saveButtonText}>Save</AppText>
</>
)}
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
);
};
// Profile Header Component - OPTIMIZED
const ProfileHeader: React.FC<ProfileHeaderProps> = React.memo(({ data, displayName }) => {
const { colors } = useTheme();
@@ -810,7 +521,7 @@ const createStyles = (colors: any) => StyleSheet.create({
shadowRadius: 4,
},
retryButtonText: {
color: colors.white,
color: colors.buttonText,
fontSize: 16,
fontWeight: '600',
},
@@ -828,11 +539,11 @@ const createStyles = (colors: any) => StyleSheet.create({
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: colors.card,
backgroundColor: colors.surface,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 4,
borderColor: colors.card,
borderColor: colors.surface,
elevation: 4,
shadowColor: colors.shadow,
shadowOffset: { width: 0, height: 2 },
@@ -852,7 +563,7 @@ const createStyles = (colors: any) => StyleSheet.create({
height: 24,
borderRadius: 12,
borderWidth: 3,
borderColor: colors.card, // Updated
borderColor: colors.surface, // Updated
justifyContent: 'center',
alignItems: 'center',
},
@@ -860,7 +571,7 @@ const createStyles = (colors: any) => StyleSheet.create({
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.white,
backgroundColor: colors.buttonText,
},
headerName: {
fontSize: 26,
@@ -894,7 +605,7 @@ const createStyles = (colors: any) => StyleSheet.create({
},
statCard: {
flex: 1,
backgroundColor: colors.card,
backgroundColor: colors.surface,
borderRadius: 16,
padding: 16,
alignItems: 'center',
@@ -920,7 +631,7 @@ const createStyles = (colors: any) => StyleSheet.create({
section: {
marginHorizontal: 16,
marginTop: 24,
backgroundColor: colors.card,
backgroundColor: colors.surface,
borderRadius: 20,
padding: 20,
elevation: 2,
@@ -984,7 +695,7 @@ const createStyles = (colors: any) => StyleSheet.create({
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: colors.card, // Updated
backgroundColor: colors.surface, // Updated
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
@@ -1007,97 +718,6 @@ const createStyles = (colors: any) => StyleSheet.create({
lineHeight: 21,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
modalContent: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: '90%',
paddingTop: 20,
borderRadius: 30
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
modalTitle: {
fontSize: 20,
fontWeight: '700',
color: colors.text,
},
closeButton: {
padding: 4,
},
formContainer: {
padding: 20,
maxHeight: 500,
},
inputGroup: {
marginBottom: 20,
},
inputLabel: {
fontSize: 14,
fontWeight: '600',
color: colors.text,
marginBottom: 8,
},
input: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
padding: 14,
fontSize: 15,
color: colors.text,
backgroundColor: colors.card, // Updated
},
inputError: {
borderColor: colors.error,
},
errorText: {
fontSize: 12,
color: colors.error,
marginTop: 4,
},
modalFooter: {
flexDirection: 'row',
padding: 20,
gap: 12,
borderTopWidth: 1,
borderTopColor: colors.border,
},
button: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 8,
},
cancelButton: {
backgroundColor: colors.background, // Updated
},
saveButton: {
backgroundColor: colors.primary,
},
cancelButtonText: {
fontSize: 16,
fontWeight: '600',
color: colors.text,
},
saveButtonText: {
fontSize: 16,
fontWeight: '600',
color: colors.white,
},
navigationOverlay: {
position: 'absolute',
top: 0,
+64
View File
@@ -0,0 +1,64 @@
/** Shared types and value helpers for the Profile feature. */
export interface ContractorData {
contractorId: number;
firstName: string;
middleName: string;
lastName: string;
fullName: string;
emailId: string;
mobileNo: string;
company: string;
contractorType: string;
dialCode: string;
twoFactorAuthEnabled: boolean;
colorCodeHSL: string | null;
isActive: string;
isEnabled: boolean | null;
role: string;
contractorStatus: boolean;
designation: string | null;
contOtherId: number;
contCompanyType: string | null;
contOrganization: string;
agentAddress: string;
agentPincode: string;
agentCity: string;
agentState: string | null;
agentWebsite: string;
agentRegno: string;
orgPAN: string;
orgPANdob: string;
orgPANname: string;
gistnNo: string;
comAadharNo: string;
contractorClass: string;
socialCategory: string;
wizardStepStatus: string;
registrationStatus: string;
indianOrgName: string;
expiryDate: string;
msmeStatus: string;
}
export interface UpdateProfilePayload {
firstName: string;
middleName: string;
lastName: string;
mobileNo: string;
emailId: string;
fullname: string;
}
/** True when the value is renderable profile data (not empty/null/"N/A"). */
export const isValidValue = (value: any): boolean => {
if (value === null || value === undefined) { return false; }
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 && trimmed.toLowerCase() !== 'n/a';
}
return true;
};
/** True when at least one of the section's values is renderable. */
export const hasSectionData = (values: any[]): boolean => values.some(isValidValue);
+4 -4
View File
@@ -9,12 +9,12 @@ import {
} from 'react-native';
import React, { useMemo, useState } from 'react';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import PrimaryButton from '../../Component/PrimaryButton/PrimaryButton';
import PrimaryButton from '../../components/PrimaryButton';
import MyInput from '../../components/MyInput';
import { showToast } from '../../Constant/ShowDailog';
import { usePartnerShipSubmit } from '../../Services/ApiCall/Hooks';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { NavigationString } from '../../Constant/AllImports';
import NavigationString from '../../Constant/NavigationString';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { useTheme } from '../../Hooks/useTheme';
import AppText from '../../components/AppText';
@@ -246,7 +246,7 @@ const Partnership = () => {
</SafeAreaView>
<View style={styles.footer}>
<PrimaryButton title={loading ? 'Loading...' : 'Submit'} onPress={handleSubmit} />
<PrimaryButton title={loading ? 'Loading...' : 'Submit'} onPress={handleSubmit} style={{ marginBottom: 20 }} />
</View>
</View>
);
@@ -254,7 +254,7 @@ const Partnership = () => {
export default Partnership;
const makeStyles = (colors: any, spacing: any, shadows: any, typography: any) => StyleSheet.create({
const makeStyles = (colors: any, spacing: any, _shadows: any, _typography: any) => StyleSheet.create({
header: {
paddingHorizontal: spacing.m,
paddingBottom: spacing.m,
+150 -425
View File
@@ -1,27 +1,28 @@
import React, { useEffect, useMemo, useState } from 'react';
import { View, TouchableOpacity, StyleSheet, Platform, KeyboardAvoidingView, ScrollView, BackHandler, ToastAndroid, Alert } from 'react-native';
import React, { useEffect, useMemo, useReducer } from 'react';
import { View, TouchableOpacity, StyleSheet, Platform, KeyboardAvoidingView, ScrollView, BackHandler, ToastAndroid } from 'react-native';
import { ArrowLeft } from 'lucide-react-native';
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { ApiResponse, DocumentsType, FormDataType, OrgType, UserData } from './types/types';
import { FormDataType } from './types/types';
import { pick } from '@react-native-documents/picker';
import axios from 'axios';
import StageOne from './ragistrationStages/StageOne';
import StageTwo from './ragistrationStages/StageTwo';
import StageZero from './ragistrationStages/StageZero';
import StageThree from './ragistrationStages/StageThree';
import StageFour from './ragistrationStages/StageFour';
import PrimaryButton from '../../Component/PrimaryButton/PrimaryButton';
import StepProgressBar from '../../Component/StepProgressBar';
import PrimaryButton from '../../components/PrimaryButton';
import StepProgressBar from '../../components/StepProgressBar';
import { formatDateInput } from '../../Helper/formatDateInput';
import { showToast } from '../../Constant/ShowDailog';
import { useCityDropdown, useContractorStageTwoSubmit, usefetchMasterData, useSendAadhaarOtp, useVerifyAadhaarOtp, useVerifyGstNumber, useVerifyPan } from '../../Services/ApiCall/Hooks';
import { useCityDropdown, useContractorStageTwoSubmit, useFetchMasterData, useSendAadhaarOtp, useVerifyAadhaarOtp, useVerifyPan } from '../../Services/ApiCall/Hooks';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useSelector } from 'react-redux';
import Animated, { FadeInUp, FadeInDown } from 'react-native-reanimated';
import { useTheme } from '../../Hooks/useTheme';
import AppText from '../../components/AppText';
import NavigationString from '../../Constant/NavigationString';
import { logger } from '../../core/observability/logger';
import { initialRegistrationState, registrationReducer } from './registrationState';
import { validatePanFormat, validateStage } from './registrationValidation';
type RouteParams = {
userData?: {
@@ -33,192 +34,96 @@ type RouteParams = {
};
};
/**
* Multi-stage registration wizard. All wizard state lives in
* registrationState.ts (one reducer); per-stage validation in
* registrationValidation.ts. This component orchestrates API calls and
* renders the current stage.
*/
const Registration: React.FC = () => {
const { colors, spacing, typography, shadows } = useTheme();
const navigation = useNavigation<any>();
const { params } = useRoute<RouteProp<Record<string, RouteParams>, string>>();
const userData = params as RouteParams;
const [stage, setStage] = useState(0);
const insets = useSafeAreaInsets();
const [focusedField, setFocusedField] = useState<null | string>(null);
const [selectedOrg, setSelectedOrg] = useState<OrgType>(0);
const [documents, setDocuments] = useState<DocumentsType>({
panCard: null,
gstnCertificate: null,
registrationCertificate: null,
msmefile: null,
constractorClass: null,
aadharCard: null,
});
const [formData, setFormData] = useState<FormDataType>({
firstName: '',
middleName: '',
lastName: '',
email: '',
mobileNumber: '',
organizationName: '',
officialWebsite: '',
registrationNumber: '',
contractorClass: '',
orgType: '',
registrationDetail: '',
socialCategory: '',
orgPanNumber: '',
dateOfIncorp: '',
orgPanName: '',
gstnNo: '',
aadhaarNumber: '',
aadhaarOtp: '',
address: '',
state: '',
city: '',
pinCode: '',
agentContact2: '',
expiryDate: '',
});
const [loader, setloader] = useState(false);
const [aadhaarVerifying, setAadhaarVerifying] = useState(false);
const [aadhaarVerified, setAadhaarVerified] = useState(false);
const [panVerifying, setPanVerifying] = useState(false);
const [panVerified, setPanVerified] = useState(false);
const [contractorClassItems, setContractorClassItems] = useState([]);
const [allOrganizations, setAllOrganizations] = useState([]);
const [allSocialCategories, setAllSocialCategories] = useState([]);
const [allMsmeOrgs, setAllMsmeOrgs] = useState([]);
const [adharreference, setadharreference] = useState('');
const [adharotpsend, setadharotpsend] = useState(false);
const [gstverifying, setgstverifying] = useState(false);
const [gstverified, setgstverified] = useState(false);
const [states, setstates] = useState([]);
const [cities, setCities] = useState([]);
const [allMSME, setAllMSME] = useState([]);
const [loading, setLoading] = useState(false);
const [state, dispatch] = useReducer(registrationReducer, initialRegistrationState);
const { stage, formData, documents, selectedOrg, focusedField } = state;
const { mutateAsync: verifyPanHandlerRes } = useVerifyPan();
const { mutateAsync: sendAadhaarOtp } = useSendAadhaarOtp();
const { mutateAsync: verifyAadhaarOtp } = useVerifyAadhaarOtp();
const { mutateAsync: verifyGstNumber } = useVerifyGstNumber();
const { mutateAsync: contractorStageTwoSubmit } = useContractorStageTwoSubmit();
const { data: masterData } = usefetchMasterData();
const { mutate: cityDropdownMutate } = useCityDropdown();
const { data: masterData } = useFetchMasterData();
const user = useSelector((s: any) => s.token.token);
const steps = ['', '', '', '', ''];
const styles = useMemo(() => makeStyles(colors, spacing, shadows, typography), [colors, spacing, shadows, typography]);
useEffect(() => {
if (!masterData?.data) return;
const contractorClasses = masterData.data.contractorClasses || [];
const msmeOrgs = masterData.data.msmeOrgs || [];
const mappedmsmeOrgs = msmeOrgs.map((item: any) => ({
label: item.comName,
value: item.msmeId,
}));
const orgs = masterData.data.organizationTypes || [];
const mappedcontractorClasses = contractorClasses.map((item: any) => ({
label: item.contClassName || '',
value: item.contClassId,
}));
const mapped = orgs.map((item: any) => ({
label: item.organizationType,
value: item.orgId,
}));
const socialCategories = masterData.data.socialCategories || [];
const mappedsocialCategories = socialCategories.map((item: any) => ({
label: item.categoryName,
value: item.socialCategoryId,
}));
const stateList = masterData.data.states || [];
const mappedStates = stateList.map((item: any) => ({
label: item.stateName,
value: item.stateId,
}));
setAllMsmeOrgs(mappedmsmeOrgs);
setAllSocialCategories(mappedsocialCategories);
setContractorClassItems(mappedcontractorClasses);
setAllOrganizations(mapped);
setstates(mappedStates);
if (!masterData?.data) { return; }
const mapList = (list: any[], label: string, value: string) =>
(list || []).map((item: any) => ({ label: item[label] || '', value: item[value] }));
dispatch({
type: 'MASTER_DATA_LOADED',
lists: {
contractorClasses: mapList(masterData.data.contractorClasses, 'contClassName', 'contClassId'),
organizations: mapList(masterData.data.organizationTypes, 'organizationType', 'orgId'),
socialCategories: mapList(masterData.data.socialCategories, 'categoryName', 'socialCategoryId'),
msmeOrgs: mapList(masterData.data.msmeOrgs, 'comName', 'msmeId'),
states: mapList(masterData.data.states, 'stateName', 'stateId'),
},
});
}, [masterData?.data]);
const { mutate: cityDropdownMutate } = useCityDropdown();
const { data: MasterData } = usefetchMasterData();
const user = useSelector((state: any) => state.token.token);
const steps = ['', '', '', '', ''];
useEffect(() => {
const fetchUserData = async (user: any) => {
try {
if (user) {
const parsedUser = user;
setFormData(prev => ({
...prev,
firstName: parsedUser?.firstName || userData?.userData?.firstName,
lastName: parsedUser?.lastName || userData?.userData?.lastName,
mobileNumber: parsedUser?.mobileNo || userData?.userData?.mobileNo,
middleName: parsedUser?.middleName || userData?.userData?.middleName,
email: parsedUser?.email || userData?.userData?.email,
}));
} else {
console.log('No user data found');
}
} catch (error) {
console.error('Error fetching user data:', error);
}
};
fetchUserData(user);
if (user || userData?.userData) {
dispatch({
type: 'PREFILL_CONTACT',
values: {
firstName: user?.firstName || userData?.userData?.firstName,
lastName: user?.lastName || userData?.userData?.lastName,
mobileNumber: user?.mobileNo || userData?.userData?.mobileNo,
middleName: user?.middleName || userData?.userData?.middleName,
email: user?.email || userData?.userData?.email,
},
});
}
// Intentionally runs only on mount; user/userData are stable route params.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const isPANLinkedToGSTIN = (pan: string, gstin: string): { valid: boolean; message: string } => {
pan = pan.toUpperCase();
gstin = gstin.toUpperCase();
const panRegex = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
if (!panRegex.test(pan)) {
return { valid: false, message: "Invalid PAN format. It should be like 'ABCDE1234F'." };
}
return { valid: true, message: 'PAN and GSTIN are correctly linked.' };
};
const handleChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
dispatch({ type: 'SET_FIELD', field: field as keyof FormDataType, value });
};
const setFocusedField = (field: string | null) =>
dispatch({ type: 'SET_FOCUSED_FIELD', field });
const verifyPanHandler = async () => {
setPanVerifying(true);
dispatch({ type: 'PAN_VERIFY_START' });
const payload = {
dob: formData?.dateOfIncorp.trim(),
name: formData?.orgPanName.trim(),
panNumber: formData?.orgPanNumber.trim(),
};
const result = await verifyPanHandlerRes(payload);
if (result) {
setPanVerified(true);
} else {
setPanVerified(false);
}
setPanVerifying(false);
dispatch({ type: 'PAN_VERIFY_DONE', verified: !!result });
};
const sendAdharOTPHandler = async (text: string) => {
setAadhaarVerifying(true);
setadharreference('');
dispatch({ type: 'AADHAAR_OTP_REQUEST' });
const payload = {
aadhaarNumber: text.trim(),
};
const response = await sendAadhaarOtp(payload);
const response = await sendAadhaarOtp({ aadhaarNumber: text.trim() });
if (response) {
setadharotpsend(true);
setadharreference(response);
dispatch({ type: 'AADHAAR_OTP_DONE', sent: true, refNumber: response });
} else {
setadharotpsend(false);
dispatch({ type: 'AADHAAR_OTP_DONE', sent: false, refNumber: '' });
ToastAndroid.show('Failed to send Aadhaar OTP', ToastAndroid.SHORT);
}
setAadhaarVerifying(false);
};
const verifyAdharOtpHandler = async () => {
@@ -226,33 +131,18 @@ const Registration: React.FC = () => {
ToastAndroid.show('Please enter a valid 6-digit OTP', ToastAndroid.SHORT);
return;
}
setAadhaarVerifying(true);
const payload = {
dispatch({ type: 'AADHAAR_VERIFY_START' });
const response = await verifyAadhaarOtp({
otp: formData.aadhaarOtp,
refNumber: adharreference,
};
const response = await verifyAadhaarOtp(payload);
refNumber: state.aadhaarRefNumber,
});
if (response) {
setAadhaarVerified(true);
dispatch({ type: 'AADHAAR_VERIFY_DONE', verified: true });
ToastAndroid.show('Aadhaar Verified', ToastAndroid.SHORT);
} else {
dispatch({ type: 'AADHAAR_VERIFY_DONE' });
ToastAndroid.show('Verification failed', ToastAndroid.SHORT);
}
setAadhaarVerifying(false);
};
const verifyGSTHandler = async () => {
setgstverifying(true);
const payload = {
gstNumber: formData?.gstnNo.trim(),
};
const response = await verifyGstNumber(payload);
if (response) {
setgstverified(true);
} else {
ToastAndroid.show('GST verification failed', ToastAndroid.SHORT);
}
setgstverifying(false);
};
const handleDocumentPick = async (field: string) => {
@@ -264,19 +154,15 @@ const Registration: React.FC = () => {
});
if (response && response?.length > 0) {
const selectedFile = response[0];
setDocuments(prev => ({
...prev,
[field]: selectedFile,
}));
dispatch({ type: 'SET_DOCUMENT', field, file: response[0] });
}
} catch (err) {
console.error('Document pick error:', err);
logger.error('registration', 'document pick failed', { error: String(err) });
}
};
const handleSubmit = async () => {
setLoading(true);
dispatch({ type: 'SUBMIT_START' });
const dtoData = {
agentAddress: formData.address,
@@ -297,7 +183,8 @@ const Registration: React.FC = () => {
orgPAN: formData.orgPanNumber,
comAadharNo: formData.aadhaarNumber,
gistnNo: formData.gstnNo,
stateName: MasterData?.data?.states?.find((state: any) => state.stateId == formData.state)?.stateName || '',
// eslint-disable-next-line eqeqeq
stateName: masterData?.data?.states?.find((s: any) => s.stateId == formData.state)?.stateName || '',
};
try {
@@ -311,185 +198,93 @@ const Registration: React.FC = () => {
fileData.append('regfile', documents.registrationCertificate as any);
const res = await contractorStageTwoSubmit(fileData);
console.log('Stage Two Res : ', res)
if (res.statusCode === 200) {
if (res) {
showToast('Form submitted successfully!');
showToast('Form submitted successfully!');
const orgType = selectedOrg;
const params = { otherId: res?.data?.contOtherId, orgType: orgType };
console.log("params : ", params)
if ([4, 5, 6].includes(orgType)) {
navigation.replace(NavigationString.StageThreeScreen as never, params as never);
} else if (orgType === 7) {
navigation.replace(NavigationString.Partnership as never, { otherId: res?.data?.contOtherId } as never);
} else {
navigation.replace(NavigationString.Home as never);
}
const orgType = selectedOrg;
const routeParams = { otherId: res?.data?.contOtherId, orgType: orgType };
if ([4, 5, 6].includes(orgType as number)) {
navigation.replace(NavigationString.StageThreeScreen as never, routeParams as never);
} else if (orgType === 7) {
navigation.replace(NavigationString.Partnership as never, { otherId: res?.data?.contOtherId } as never);
} else {
showToast('Form submission failed');
navigation.replace(NavigationString.Home as never);
}
} else {
showToast(res.message || 'Form submission failed. Please try again.');
}
} catch (error: any) {
console.log("Error in form submission")
if (axios.isAxiosError(error)) {
} else {
console.error('Unexpected error', error);
}
console.error('Form submission failed:', error);
logger.error('registration', 'stage-two submission failed', { error: String(error) });
showToast(error.message || 'Form submission failed. Please try again.');
} finally {
setLoading(false);
dispatch({ type: 'SUBMIT_END' });
}
};
const fetchCity = async (stateId: number) => {
const fetchCity = (stateId: number) => {
cityDropdownMutate(stateId, {
onSuccess: (data) => {
setCities(data);
onSuccess: data => dispatch({ type: 'CITIES_LOADED', cities: data }),
onError: error => {
dispatch({ type: 'CITIES_LOADED', cities: [] });
logger.error('registration', 'city fetch failed', { error: String(error) });
},
onError: (error) => {
setCities([]);
console.error('Error in city fetch:', error);
}
});
};
console.log('formdata : ', formData);
useEffect(() => {
if (formData?.state) {
fetchCity(Number(formData.state));
}
// Intentionally re-runs only when the selected state changes; fetchCity is recreated each render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formData?.state]);
useEffect(() => {
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (stage > 1) {
setStage(prev => prev - 1);
dispatch({ type: 'PREV_STAGE' });
return true;
} else {
return false;
}
return false;
});
return () => backHandler.remove();
}, [stage]);
const validateStage = (stage: number) => {
console.log(stage, 'validateStage');
switch (stage) {
case 0:
if (!formData.organizationName?.trim()) {
showToast('Please fill all required details.');
return false;
}
return true;
const handleNextPress = () => {
const validation = validateStage(stage, { formData, documents, selectedOrg });
if (!validation.valid) {
showToast(validation.message || 'Please fill all required fields.');
return;
}
case 1:
if (selectedOrg == 8) {
if (!formData.orgType || !formData.socialCategory) {
showToast('Please enter all valid details.');
return false;
}
} else {
if (
!formData.registrationNumber ||
!formData.registrationDetail ||
!formData.socialCategory ||
!formData.orgType
) {
showToast('Please enter all valid details.');
return false;
}
}
return true;
if (stage === 2) {
if (!state.panVerified) {
showToast('Please verify PAN details.');
return;
}
if (!state.aadhaarVerified) {
showToast('Please verify Aadhar Number.');
return;
}
const panCheck = validatePanFormat(formData.orgPanNumber);
if (!panCheck.valid) {
showToast(panCheck.message || 'Invalid PAN.');
return;
}
}
case 2:
if (
!formData.orgPanNumber ||
!formData.dateOfIncorp ||
!formData.orgPanName ||
(selectedOrg == 8 && !formData.gstnNo) ||
!formData.aadhaarNumber ||
!formData.aadhaarOtp
) {
Alert.alert('aadhar otp : ', formData?.aadhaarOtp);
showToast('Please fill all required KYC and Aadhaar details.');
return false;
}
if (formData.aadhaarNumber.length !== 12) {
showToast('Aadhaar number must be 12 digits.');
return false;
}
if (formData.aadhaarOtp.length !== 6) {
showToast('OTP must be 6 digits.');
return false;
}
return true;
case 3:
if (!formData.address || !formData.state || !formData.city || !formData.pinCode) {
showToast('Please fill all required address details.');
return false;
}
if (formData.pinCode.length !== 6) {
showToast('Please enter a valid 6-digit pin code.');
return false;
}
return true;
case 4:
if (selectedOrg == 8) {
if (!documents.panCard || !documents.aadharCard) {
showToast('Please upload all required documents.');
return false;
}
} else {
if (formData?.contractorClass !== 1) {
if (!documents.constractorClass) {
showToast('Please upload CONTRACTOR CLASS documents.');
return false;
}
}
if (formData?.registrationDetail !== 3) {
if (!documents.msmefile) {
showToast('Please upload MSME FILE.');
return false;
}
}
if (!documents.gstnCertificate || !documents.registrationCertificate) {
showToast('Please upload all required documents.');
return false;
}
}
return true;
default:
return true;
if (stage === 4) {
handleSubmit();
} else {
dispatch({ type: 'NEXT_STAGE' });
}
};
// ── Stage Titles ──
const stageTitles = [
'Personal & Contact',
'Registration Details',
'KYC Verification',
'Address Details',
'Upload Documents',
];
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<View style={[styles.root, { backgroundColor: colors.background }]}>
<KeyboardAvoidingView
style={{ flex: 1 }}
style={styles.flex1}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : -30}
>
@@ -499,43 +294,25 @@ const Registration: React.FC = () => {
bounces={false}
showsVerticalScrollIndicator={false}
>
<View style={{ paddingBottom: 20, marginTop: insets.top, flex: 1 }}>
<View style={[styles.stageArea, { marginTop: insets.top }]}>
{/* ═══ Back Button ═══ */}
<View style={styles.headerRow}>
<TouchableOpacity
onPress={() => {
if (stage > 0) {
setStage(prev => prev - 1);
}
}}
onPress={() => dispatch({ type: 'PREV_STAGE' })}
style={styles.backBtn}
activeOpacity={0.7}
>
<ArrowLeft size={22} color={colors.text} />
</TouchableOpacity>
<AppText variant="h3" color={colors.text} style={{ fontWeight: '700' }}>
<AppText variant="h3" color={colors.text} style={styles.title as any}>
Registration
</AppText>
<View style={{ width: 36 }} />
<View style={styles.headerSpacer} />
</View>
{/* ═══ Progress Bar ═══ */}
<StepProgressBar currentStep={stage} steps={steps} />
{/* ═══ Stage Title ═══
<Animated.View
key={`title-${stage}`}
entering={FadeInUp.duration(300)}
style={styles.stageTitleContainer}
>
<AppText variant="h3" color={colors.text} style={styles.stageTitle}>
{stageTitles[stage]}
</AppText>
<AppText variant="caption" color={colors.textSecondary}>
Step {stage + 1} of {steps.length}
</AppText>
</Animated.View> */}
{/* ═══ Stage Content ═══ */}
<Animated.View
key={`stage-${stage}`}
@@ -545,9 +322,7 @@ const Registration: React.FC = () => {
{stage === 0 && (
<StageZero
formData={formData}
handleChange={(key: string, value: string) =>
handleChange(key as keyof FormDataType, value)
}
handleChange={handleChange}
focusedField={focusedField}
setFocusedField={setFocusedField}
styles={styles}
@@ -557,18 +332,14 @@ const Registration: React.FC = () => {
{stage === 1 && (
<StageOne
formData={formData}
handleChange={(key: string, value: string) =>
handleChange(key as keyof FormDataType, value)
}
handleChange={handleChange}
focusedField={focusedField}
setFocusedField={setFocusedField}
contractorClassItems={contractorClassItems}
allOrganizations={allOrganizations}
allMsmeOrgs={allMsmeOrgs}
allSocialCategories={allSocialCategories}
allMSME={allMSME}
dropdownStyle={{}}
setSelectedOrg={setSelectedOrg}
contractorClassItems={state.masterLists.contractorClasses}
allOrganizations={state.masterLists.organizations}
allMsmeOrgs={state.masterLists.msmeOrgs}
allSocialCategories={state.masterLists.socialCategories}
setSelectedOrg={(org: any) => dispatch({ type: 'SET_SELECTED_ORG', org })}
selectedOrg={selectedOrg}
/>
)}
@@ -576,25 +347,20 @@ const Registration: React.FC = () => {
{stage === 2 && (
<StageTwo
formData={formData}
handleChange={(key: string, value: string) =>
handleChange(key as keyof FormDataType, value)
}
handleChange={handleChange}
focusedField={focusedField}
setFocusedField={setFocusedField}
panVerified={panVerified}
setPanVerified={setPanVerified}
panVerified={state.panVerified}
setPanVerified={(verified: boolean) => dispatch({ type: 'SET_PAN_VERIFIED', verified })}
verifyPanHandler={verifyPanHandler}
panVerifying={panVerifying}
aadhaarVerified={aadhaarVerified}
setAadhaarVerified={setAadhaarVerified}
panVerifying={state.panVerifying}
aadhaarVerified={state.aadhaarVerified}
setAadhaarVerified={(verified: boolean) => dispatch({ type: 'SET_AADHAAR_VERIFIED', verified })}
sendAdharOTPHandler={sendAdharOTPHandler}
adharotpsend={adharotpsend}
setadharotpsend={setadharotpsend}
adharotpsend={state.aadhaarOtpSent}
setadharotpsend={(sent: boolean) => dispatch({ type: 'SET_AADHAAR_OTP_SENT', sent })}
verifyAdharOtpHandler={verifyAdharOtpHandler}
aadhaarVerifying={aadhaarVerifying}
verifyGSTHandler={verifyGSTHandler}
gstverifying={gstverifying}
color={colors}
aadhaarVerifying={state.aadhaarVerifying}
styles={styles}
formatDateInput={formatDateInput}
selectedOrg={selectedOrg}
@@ -604,16 +370,12 @@ const Registration: React.FC = () => {
{stage === 3 && (
<StageThree
formData={formData}
handleChange={(key: string, value: string) =>
handleChange(key as keyof FormDataType, value)
}
handleChange={handleChange}
setFocusedField={setFocusedField}
focusedField={focusedField}
states={states}
cities={cities}
fetchCity={() => {
fetchCity(Number(formData.state));
}}
states={state.masterLists.states}
cities={state.cities}
fetchCity={() => fetchCity(Number(formData.state))}
dropdownStyle={{}}
styles={styles}
/>
@@ -634,51 +396,9 @@ const Registration: React.FC = () => {
{/* ═══ Next / Submit Button ═══ */}
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<PrimaryButton
title={stage === 4 ? (loading ? 'Loading...' : 'Submit') : 'Next'}
style={{ backgroundColor: colors.primary }}
onPress={() => {
if (validateStage(stage)) {
switch (stage) {
case 0:
case 1:
setStage(prev => prev + 1);
break;
case 2:
if (!panVerified) {
showToast('Please verify PAN details.');
return;
}
if (!aadhaarVerified) {
showToast('Please verify Aadhar Number.');
return;
}
const { valid, message } = isPANLinkedToGSTIN(
formData.orgPanNumber,
formData.gstnNo,
);
if (!valid) {
showToast(message);
return;
}
setStage(prev => prev + 1);
break;
case 3:
setStage(prev => prev + 1);
break;
case 4:
handleSubmit();
break;
default:
console.log(stage, 'stage');
setStage(prev => prev + 1);
break;
}
} else {
showToast('Please fill all required fields.');
}
}}
title={stage === 4 ? (state.submitting ? 'Loading...' : 'Submit') : 'Next'}
style={{ backgroundColor: colors.primary, marginBottom: 20 }}
onPress={handleNextPress}
/>
</Animated.View>
</ScrollView>
@@ -691,6 +411,11 @@ export default Registration;
// ── Styles ────────────────────────────────────────────────────────────
const makeStyles = (colors: any, spacing: any, shadows: any, typography: any) => StyleSheet.create({
root: { flex: 1 },
flex1: { flex: 1 },
stageArea: { paddingBottom: 20, flex: 1 },
title: { fontWeight: '700' } as any,
headerSpacer: { width: 36 },
scrollContainer: {
flexGrow: 1,
paddingHorizontal: spacing.m,
+10 -7
View File
@@ -9,7 +9,7 @@ import {
} from 'react-native';
import React, { useMemo, useState } from 'react';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import PrimaryButton from '../../Component/PrimaryButton/PrimaryButton';
import PrimaryButton from '../../components/PrimaryButton';
import MyInput from '../../components/MyInput';
import { showToast } from '../../Constant/ShowDailog';
import { useContractorStageThreeSubmit } from '../../Services/ApiCall/Hooks';
@@ -17,6 +17,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { useTheme } from '../../Hooks/useTheme';
import AppText from '../../components/AppText';
import NavigationString from '../../Constant/NavigationString';
import { logger } from '../../core/observability/logger';
import { Trash2, Building2 } from 'lucide-react-native';
type Company = {
@@ -84,16 +86,17 @@ const StageThreeScreen = () => {
shPANNo: p.panNumber,
}));
console.log('stage 3 payload : ', payload);
const response = await stageThreeSubmit(payload);
console.log('✅ company submitted successfully:', response);
if (response.statusCode === 200 || response.statusCode === 201) {
navigation.replace('Dashboard');
// Was navigation.replace('Dashboard') — a route that never existed, so
// the navigation was silently dropped and the user was stranded on the
// form. Completed registration lands on Home (matches Splash/Login).
navigation.replace(NavigationString.Home);
showToast('Submitted successfully!');
}
} catch (error: any) {
console.error('❌ Submission failed:', error.message);
logger.error('registration', 'stage-three submission failed', { error: String(error) });
showToast('Submission failed. Please try again.');
} finally {
setLoading(false);
@@ -241,7 +244,7 @@ const StageThreeScreen = () => {
</SafeAreaView>
<View style={styles.footer}>
<PrimaryButton title={loading ? 'Loading...' : 'Submit'} onPress={handleSubmit} />
<PrimaryButton title={loading ? 'Loading...' : 'Submit'} onPress={handleSubmit} style={{ marginBottom: 20 }} />
</View>
</View>
);
@@ -249,7 +252,7 @@ const StageThreeScreen = () => {
export default StageThreeScreen;
const makeStyles = (colors: any, spacing: any, shadows: any, typography: any) => StyleSheet.create({
const makeStyles = (colors: any, spacing: any, _shadows: any, _typography: any) => StyleSheet.create({
header: {
paddingHorizontal: spacing.m,
paddingBottom: spacing.m,
@@ -0,0 +1,58 @@
import { initialRegistrationState, registrationReducer, RegistrationState } from '../registrationState';
const s = (over: Partial<RegistrationState> = {}): RegistrationState => ({
...initialRegistrationState,
...over,
});
describe('registrationReducer', () => {
it('navigates stages and never goes below 0', () => {
expect(registrationReducer(s(), { type: 'NEXT_STAGE' }).stage).toBe(1);
expect(registrationReducer(s({ stage: 2 }), { type: 'PREV_STAGE' }).stage).toBe(1);
expect(registrationReducer(s({ stage: 0 }), { type: 'PREV_STAGE' }).stage).toBe(0);
});
it('updates a single form field immutably', () => {
const next = registrationReducer(s(), { type: 'SET_FIELD', field: 'orgPanNumber', value: 'ABCDE1234F' });
expect(next.formData.orgPanNumber).toBe('ABCDE1234F');
expect(next.formData.firstName).toBe('');
expect(initialRegistrationState.formData.orgPanNumber).toBe('');
});
it('prefills contact fields without clobbering others', () => {
const next = registrationReducer(s(), {
type: 'PREFILL_CONTACT',
values: { firstName: 'A', email: 'a@b.c' },
});
expect(next.formData.firstName).toBe('A');
expect(next.formData.email).toBe('a@b.c');
expect(next.formData.lastName).toBe('');
});
it('runs the PAN verification lifecycle', () => {
let state = registrationReducer(s(), { type: 'PAN_VERIFY_START' });
expect(state.panVerifying).toBe(true);
state = registrationReducer(state, { type: 'PAN_VERIFY_DONE', verified: true });
expect(state).toMatchObject({ panVerifying: false, panVerified: true });
});
it('resets the ref number when requesting a new Aadhaar OTP', () => {
let state = s({ aadhaarRefNumber: 'old-ref' });
state = registrationReducer(state, { type: 'AADHAAR_OTP_REQUEST' });
expect(state).toMatchObject({ aadhaarVerifying: true, aadhaarRefNumber: '' });
state = registrationReducer(state, { type: 'AADHAAR_OTP_DONE', sent: true, refNumber: 'ref-1' });
expect(state).toMatchObject({ aadhaarVerifying: false, aadhaarOtpSent: true, aadhaarRefNumber: 'ref-1' });
});
it('leaves the verified flag untouched on a failed Aadhaar verify', () => {
let state = s({ aadhaarVerified: true, aadhaarVerifying: true });
state = registrationReducer(state, { type: 'AADHAAR_VERIFY_DONE' });
expect(state).toMatchObject({ aadhaarVerifying: false, aadhaarVerified: true });
});
it('stores a picked document under its field', () => {
const file = { name: 'pan.pdf' };
const next = registrationReducer(s(), { type: 'SET_DOCUMENT', field: 'panCard', file });
expect((next.documents as any).panCard).toBe(file);
});
});
@@ -0,0 +1,80 @@
import { initialRegistrationState } from '../registrationState';
import { validatePanFormat, validateStage } from '../registrationValidation';
const base = () => ({
formData: { ...initialRegistrationState.formData },
documents: { ...initialRegistrationState.documents },
selectedOrg: 1 as any,
});
describe('validateStage', () => {
it('stage 0 requires an organization name', () => {
expect(validateStage(0, base()).valid).toBe(false);
const input = base();
input.formData.organizationName = 'Acme';
expect(validateStage(0, input).valid).toBe(true);
});
it('stage 1 requires fewer fields for individual (org 8)', () => {
const individual = base();
individual.selectedOrg = 8;
individual.formData.orgType = '2';
individual.formData.socialCategory = '1';
expect(validateStage(1, individual).valid).toBe(true);
const company = base();
company.formData.orgType = '2';
company.formData.socialCategory = '1';
expect(validateStage(1, company).valid).toBe(false); // missing regno/detail
});
it('stage 2 enforces Aadhaar and OTP lengths', () => {
const input = base();
Object.assign(input.formData, {
orgPanNumber: 'ABCDE1234F',
dateOfIncorp: '01/01/2020',
orgPanName: 'Acme',
aadhaarNumber: '123456789012',
aadhaarOtp: '123456',
});
expect(validateStage(2, input).valid).toBe(true);
input.formData.aadhaarNumber = '1234';
expect(validateStage(2, input)).toMatchObject({
valid: false,
message: 'Aadhaar number must be 12 digits.',
});
});
it('stage 3 enforces the pin code length', () => {
const input = base();
Object.assign(input.formData, { address: 'x', state: '1', city: '2', pinCode: '12345' });
expect(validateStage(3, input)).toMatchObject({
valid: false,
message: 'Please enter a valid 6-digit pin code.',
});
input.formData.pinCode = '123456';
expect(validateStage(3, input).valid).toBe(true);
});
it('stage 4 requires only PAN + Aadhaar files for individual (org 8)', () => {
const input = base();
input.selectedOrg = 8;
expect(validateStage(4, input).valid).toBe(false);
(input.documents as any).panCard = { name: 'pan.pdf' };
(input.documents as any).aadharCard = { name: 'aadhaar.pdf' };
expect(validateStage(4, input).valid).toBe(true);
});
});
describe('validatePanFormat', () => {
it('accepts a valid PAN in any case', () => {
expect(validatePanFormat('abcde1234f').valid).toBe(true);
expect(validatePanFormat('ABCDE1234F').valid).toBe(true);
});
it('rejects malformed PANs', () => {
expect(validatePanFormat('1234').valid).toBe(false);
expect(validatePanFormat('ABCDE12345').valid).toBe(false);
});
});
@@ -8,7 +8,6 @@ import {
Building2,
ScrollText,
CheckCircle,
Upload,
} from 'lucide-react-native';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { useTheme } from '../../../Hooks/useTheme';
@@ -35,7 +34,7 @@ const StageFour: React.FC<UploadDocumentsSectionProps> = ({
formData,
selectedOrg,
}) => {
const { colors, spacing, shadows } = useTheme();
const { colors, spacing } = useTheme();
const documentItems: DocumentItem[] = [
{ key: 'panCard', label: 'PAN Card', icon: 'pan' },
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { View, TouchableOpacity } from 'react-native';
import DatePicker from 'react-native-date-picker';
import MyInput from '../../../components/MyInput';
import MyDropdown from '../../../Component/Dropdown/MyDropdown';
import MyDropdown from '../../../components/MyDropdown';
import { DropdownOption } from '../types/types';
import { useTheme } from '../../../Hooks/useTheme';
import AppText from '../../../components/AppText';
@@ -23,8 +23,6 @@ interface Props {
allOrganizations: DropdownOption[];
allMsmeOrgs: DropdownOption[];
allSocialCategories: DropdownOption[];
allMSME: DropdownOption[];
dropdownStyle: object;
setSelectedOrg: (val: number) => void;
selectedOrg: string | number;
}
@@ -38,7 +36,6 @@ const StageOne: React.FC<Props> = ({
allOrganizations,
allMsmeOrgs,
allSocialCategories,
dropdownStyle,
setSelectedOrg,
selectedOrg,
}) => {
@@ -1,8 +1,7 @@
import React from 'react';
import { View } from 'react-native';
import { DropdownOption } from '../types/types';
import MyInput from '../../../components/MyInput';
import MyDropdown from '../../../Component/Dropdown/MyDropdown';
import MyDropdown from '../../../components/MyDropdown';
import { useTheme } from '../../../Hooks/useTheme';
import AppText from '../../../components/AppText';

Some files were not shown because too many files have changed in this diff Show More