diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..4cec539
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.gitignore b/.gitignore
index be881d0..352638e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/.scannerwork/.sonar_lock b/.scannerwork/.sonar_lock
deleted file mode 100644
index e69de29..0000000
diff --git a/App.tsx b/App.tsx
index 736ac98..eb14d7d 100644
--- a/App.tsx
+++ b/App.tsx
@@ -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 (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
};
export default App;
-
diff --git a/README.md b/README.md
index 0db004b..b5af6a7 100644
--- a/README.md
+++ b/README.md
@@ -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 R key twice or select **"Reload"** from the **Dev Menu**, accessed via Ctrl + M (Windows/Linux) or Cmd ⌘ + M (macOS).
-- **iOS**: Press R 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).
diff --git a/android/app/appKeyStore b/android/app/appKeyStore
deleted file mode 100644
index 76c6a52..0000000
Binary files a/android/app/appKeyStore and /dev/null differ
diff --git a/android/app/build.gradle b/android/app/build.gradle
index ddd4804..d9cf815 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -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 {
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 15c600a..ce3c4c2 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -5,7 +5,6 @@
-
@@ -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">
=
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"
diff --git a/android/app/src/main/java/com/procure/excel/ExcelModule.kt b/android/app/src/main/java/com/procure/excel/ExcelModule.kt
new file mode 100644
index 0000000..266e3be
--- /dev/null
+++ b/android/app/src/main/java/com/procure/excel/ExcelModule.kt
@@ -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 {
+ val map = HashMap()
+ 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)
+ }
+}
+
+
diff --git a/android/app/src/main/java/com/procure/excel/ExcelPackage.kt b/android/app/src/main/java/com/procure/excel/ExcelPackage.kt
new file mode 100644
index 0000000..0ce8ea3
--- /dev/null
+++ b/android/app/src/main/java/com/procure/excel/ExcelPackage.kt
@@ -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 =
+ listOf(ExcelModule(reactContext))
+
+ override fun createViewManagers(reactContext: ReactApplicationContext): List> =
+ emptyList()
+}
+
+
diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml
index aae6c12..44f1f42 100644
--- a/android/app/src/main/res/xml/network_security_config.xml
+++ b/android/app/src/main/res/xml/network_security_config.xml
@@ -1,6 +1,23 @@
-
+
-
+
+
+
+
+ eproc-backend.lincpay
+
+
+
+
+ localhost
+ 10.0.2.2
+ 192.168.6.194
+ 192.168.29.226
+
diff --git a/android/build.gradle b/android/build.gradle
index 9766946..9a0a8f0 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -1,7 +1,7 @@
buildscript {
ext {
buildToolsVersion = "35.0.0"
- minSdkVersion = 24
+ minSdkVersion = 26
compileSdkVersion = 35
targetSdkVersion = 35
ndkVersion = "27.1.12297006"
diff --git a/android/keystore.properties.example b/android/keystore.properties.example
new file mode 100644
index 0000000..4892109
--- /dev/null
+++ b/android/keystore.properties.example
@@ -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
diff --git a/babel.config.js b/babel.config.js
index 1e57359..7d92d8b 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -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'],
+ },
+ },
};
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..93bdc75
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -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()` | 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.(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>`);
+ broaden accessibility labels; i18n scaffolding.
diff --git a/docs/ARCHITECTURE_AUDIT.md b/docs/ARCHITECTURE_AUDIT.md
new file mode 100644
index 0000000..9438b39
--- /dev/null
+++ b/docs/ARCHITECTURE_AUDIT.md
@@ -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()`, `useNavigation()`, `useRoute()` 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`
diff --git a/docs/MOBILE_BID_DSC_API_SPEC.md b/docs/MOBILE_BID_DSC_API_SPEC.md
new file mode 100644
index 0000000..d7ac37e
--- /dev/null
+++ b/docs/MOBILE_BID_DSC_API_SPEC.md
@@ -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.
diff --git a/index.js b/index.js
index 86a0f72..8d0da95 100644
--- a/index.js
+++ b/index.js
@@ -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 = () => (
diff --git a/jest.config.js b/jest.config.js
index 8eb675e..daea970 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -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)/',
+ ],
};
diff --git a/metro.config.js b/metro.config.js
index dee28ce..3de5d60 100644
--- a/metro.config.js
+++ b/metro.config.js
@@ -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/**",
- ]
}
};
diff --git a/package-lock.json b/package-lock.json
index 33c4651..a92b848 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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"
}
}
}
diff --git a/package.json b/package.json
index e6803ba..b12ceef 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/sonar-project.properties b/sonar-project.properties
index 934b69d..9316fd5 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -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).
diff --git a/src/Axios/Axios.ts b/src/Axios/Axios.ts
index 025b23e..cd6b52f 100644
--- a/src/Axios/Axios.ts
+++ b/src/Axios/Axios.ts
@@ -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 (
method: 'get' | 'post' | 'patch' | 'delete' | 'put',
@@ -105,38 +26,32 @@ export const apiCall = async (
params?: any,
contentType?: string
): Promise => {
- 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({
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;
}
};
diff --git a/src/Axios/__tests__/apiCall.test.ts b/src/Axios/__tests__/apiCall.test.ts
new file mode 100644
index 0000000..7febfa9
--- /dev/null
+++ b/src/Axios/__tests__/apiCall.test.ts
@@ -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;
+
+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('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('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('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('get', '/x');
+ expect(res).toEqual({ success: false, message: 'No Response From Server' });
+ });
+});
diff --git a/src/Component/Card/Card.tsx b/src/Component/Card/Card.tsx
deleted file mode 100644
index 8184491..0000000
--- a/src/Component/Card/Card.tsx
+++ /dev/null
@@ -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 = ({
- 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 (
-
- {/* Glass Base */}
- {Platform.OS === 'ios' && (
-
- )}
-
- {/* Card Body */}
-
- {/* Title */}
- {title}
-
- {/* Stats */}
-
-
- {leftValue}
- {leftLabel}
-
-
-
-
-
- {rightValue}
- {rightLabel}
-
-
-
- {/* CTA */}
-
-
- {buttonText}
-
-
-
-
- );
-};
-
-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,
- },
-});
diff --git a/src/Component/Counter/Counter.tsx b/src/Component/Counter/Counter.tsx
deleted file mode 100644
index 5180f1b..0000000
--- a/src/Component/Counter/Counter.tsx
+++ /dev/null
@@ -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 (
-
- {displayValue}
-
- );
-};
-
-export default Counter
-
-const styles = StyleSheet.create({
- container: {
- backgroundColor: '#f2f2f2',
- padding: 5,
- borderRadius: 5,
- },
- counterText: {
- fontSize: 20,
- fontWeight: 'bold',
- color: colors.primary,
- },
-});
diff --git a/src/Component/Header/Header.tsx b/src/Component/Header/Header.tsx
deleted file mode 100644
index 1d663c5..0000000
--- a/src/Component/Header/Header.tsx
+++ /dev/null
@@ -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 = ({ title, showDrawer = true }) => {
- const insets = useSafeAreaInsets();
- const [drawerVisible, setDrawerVisible] = useState(false);
-
- return (
- <>
-
-
-
- {showDrawer && (
- setDrawerVisible(true)}
- style={styles.drawerIcon}
- activeOpacity={0.7}
- >
-
-
- )}
- {title}
-
-
-
-
-
-
- {/* Drawer Modal */}
- 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,
- },
-});
diff --git a/src/Component/Index.tsx b/src/Component/Index.tsx
deleted file mode 100644
index fa0e18e..0000000
--- a/src/Component/Index.tsx
+++ /dev/null
@@ -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}
\ No newline at end of file
diff --git a/src/Component/InternetConnectivity/InternetConnectivity.tsx b/src/Component/InternetConnectivity/InternetConnectivity.tsx
deleted file mode 100644
index 18ff903..0000000
--- a/src/Component/InternetConnectivity/InternetConnectivity.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
-
- No Internet Connection
-
-
- It looks like you're offline. Check your connection and try again to continue.
-
-
-
-
-
- Check your Wi-Fi or mobile data
-
-
-
- Try turning airplane mode on/off
-
-
-
-
-
- {}}
- style={styles.touchable}
- >
-
- Try Again
-
-
-
-
-
- );
-};
-
-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,
- },
-});
\ No newline at end of file
diff --git a/src/Component/InternetConnectivity/ServerDown.tsx b/src/Component/InternetConnectivity/ServerDown.tsx
deleted file mode 100644
index 3d12d4c..0000000
--- a/src/Component/InternetConnectivity/ServerDown.tsx
+++ /dev/null
@@ -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 (
- <>
-
-
-
- {/* Status Indicator */}
-
-
-
-
-
-
-
- {/* Animated Illustration */}
-
-
-
-
- {/* Main Content */}
-
- Server Unavailable
-
-
-
-
- Temporarily down for maintenance
-
-
-
-
- Our servers are currently undergoing maintenance. This usually
- takes 15-30 minutes. We appreciate your patience while we work
- to improve your experience.
-
-
- {/* Action Buttons */}
-
- { }}
- style={styles.touchable}>
-
-
- Refresh Status
-
-
-
-
- {/* Live Status */}
-
-
-
-
-
-
- Monitoring server status...
-
-
-
-
- {/* Background Pattern */}
-
-
-
- >
- );
-};
-
-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,
- },
-});
diff --git a/src/Component/Loader/Loader.tsx b/src/Component/Loader/Loader.tsx
deleted file mode 100644
index 7a4f64d..0000000
--- a/src/Component/Loader/Loader.tsx
+++ /dev/null
@@ -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 (
-
-
-
- );
-};
-
-export default Loader;
diff --git a/src/Component/MyInput.tsx b/src/Component/MyInput.tsx
deleted file mode 100644
index 13166f6..0000000
--- a/src/Component/MyInput.tsx
+++ /dev/null
@@ -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 = ({
- icon,
- placeholder,
- value,
- onChangeText,
- isVarify,
- maxlength,
- onFocus,
- onBlur,
- focused,
- keyboardType,
- editable = true,
- autoCapitalize,
- rightIcon,
- rightIconPress,
- ...textInputProps
-}) => {
- return (
-
- {/* */}
-
- {
- rightIcon ?
-
-
-
- : null}
-
- );
-};
-
-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',
- },
-});
diff --git a/src/Component/PrimaryButton/PrimaryButton.tsx b/src/Component/PrimaryButton/PrimaryButton.tsx
deleted file mode 100644
index a397c83..0000000
--- a/src/Component/PrimaryButton/PrimaryButton.tsx
+++ /dev/null
@@ -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 = ({ title, onPress, style, disabled }) => {
- return (
-
-
- {title}
-
-
- );
-};
-
-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,
- },
-});
diff --git a/src/Component/SideDrawer/SideDrawer.tsx b/src/Component/SideDrawer/SideDrawer.tsx
deleted file mode 100644
index f2f63b2..0000000
--- a/src/Component/SideDrawer/SideDrawer.tsx
+++ /dev/null
@@ -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 = ({ 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 (
-
-
- {/* ================= Header / Profile ================= */}
-
- {/* {
- }}>
-
- */}
-
-
-
-
-
- {user.name}
- {user.role}
-
-
- {/* ================= Menu ================= */}
-
- {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 (
-
- {
- onClose();
- item.onPress();
- }}
- >
-
-
- {item.label}
-
-
-
-
- );
- })}
-
-
- {/* ================= Logout ================= */}
-
- {
- dispatch(removeToken());
- navigation.dispatch(
- CommonActions.reset({
- index: 0,
- routes: [{ name: NavigationString.Login }],
- }),
- );
- }}
- >
-
- Logout
-
-
-
-
- );
-};
-
-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',
- },
-});
diff --git a/src/Constant/AllImports.tsx b/src/Constant/AllImports.tsx
deleted file mode 100644
index 7fbe846..0000000
--- a/src/Constant/AllImports.tsx
+++ /dev/null
@@ -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};
-
diff --git a/src/Constant/AllUrls.tsx b/src/Constant/AllUrls.tsx
deleted file mode 100644
index 2330d22..0000000
--- a/src/Constant/AllUrls.tsx
+++ /dev/null
@@ -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;
\ No newline at end of file
diff --git a/src/Constant/Color.tsx b/src/Constant/Color.tsx
index 4f3aa74..b87a346 100644
--- a/src/Constant/Color.tsx
+++ b/src/Constant/Color.tsx
@@ -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',
diff --git a/src/Constant/NavigationString.tsx b/src/Constant/NavigationString.tsx
index d4589eb..4001171 100644
--- a/src/Constant/NavigationString.tsx
+++ b/src/Constant/NavigationString.tsx
@@ -24,8 +24,8 @@ const NavigationString = {
ResetPassword:'ResetPassword',
KycWebViewScreen:'KycWebViewScreen',
StageThreeScreen:'StageThree',
- DecryptionScreen:'DecryptionScreen',
DeviceBinding: 'DeviceBinding',
- DscBinding: 'DscBinding'
-}
-export default NavigationString
\ No newline at end of file
+ DscBinding: 'DscBinding',
+ EnvelopeCSpreadsheet: 'EnvelopeCSpreadsheet'
+} as const
+export default NavigationString
diff --git a/src/Constant/ShowDailog.tsx b/src/Constant/ShowDailog.tsx
index de60873..9d26934 100644
--- a/src/Constant/ShowDailog.tsx
+++ b/src/Constant/ShowDailog.tsx
@@ -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({
diff --git a/src/Constant/Variable.tsx b/src/Constant/Variable.tsx
index 55ef118..868cbe4 100644
--- a/src/Constant/Variable.tsx
+++ b/src/Constant/Variable.tsx
@@ -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
\ No newline at end of file
+/**
+ * @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;
diff --git a/src/Hooks/deviceDetail.tsx b/src/Hooks/deviceDetail.tsx
deleted file mode 100644
index cc4796f..0000000
--- a/src/Hooks/deviceDetail.tsx
+++ /dev/null
@@ -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};
-};
diff --git a/src/Hooks/useCreate.ts b/src/Hooks/useCreate.ts
deleted file mode 100644
index 5927005..0000000
--- a/src/Hooks/useCreate.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query';
-import apiClient from '../api/client';
-
-export const useCreate = (
- url: string,
- options?: UseMutationOptions
-) => {
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: async (variables: V) => {
- const { data } = await apiClient.post(url, variables);
- return data;
- },
- onSuccess: () => {
- // Invalidate queries if needed, handled by component or passed in options
- },
- ...options,
- });
-};
diff --git a/src/Hooks/useDelete.ts b/src/Hooks/useDelete.ts
deleted file mode 100644
index bc10c9d..0000000
--- a/src/Hooks/useDelete.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query';
-import apiClient from '../api/client';
-
-export const useDelete = (
- url: string,
- options?: UseMutationOptions
-) => {
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: async () => {
- const { data } = await apiClient.delete(url);
- return data;
- },
- ...options,
- });
-};
diff --git a/src/Hooks/useFetch.ts b/src/Hooks/useFetch.ts
deleted file mode 100644
index 45884b6..0000000
--- a/src/Hooks/useFetch.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { useQuery, UseQueryOptions, QueryKey } from '@tanstack/react-query';
-import apiClient from '../api/client';
-
-export const useFetch = (
- key: QueryKey,
- url: string,
- options?: UseQueryOptions
-) => {
- return useQuery({
- queryKey: key,
- queryFn: async () => {
- const { data } = await apiClient.get(url);
- return data;
- },
- ...options,
- });
-};
diff --git a/src/Hooks/useUpdate.ts b/src/Hooks/useUpdate.ts
deleted file mode 100644
index 6a1a6c2..0000000
--- a/src/Hooks/useUpdate.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query';
-import apiClient from '../api/client';
-
-export const useUpdate = (
- url: string,
- options?: UseMutationOptions
-) => {
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: async (variables: V) => {
- const { data } = await apiClient.put(url, variables);
- return data;
- },
- ...options,
- });
-};
diff --git a/src/Navigation/AuthSessionGuard.tsx b/src/Navigation/AuthSessionGuard.tsx
new file mode 100644
index 0000000..98f7664
--- /dev/null
+++ b/src/Navigation/AuthSessionGuard.tsx
@@ -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;
diff --git a/src/Navigation/Bottom.tsx b/src/Navigation/Bottom.tsx
index 20dc7cb..436b154 100644
--- a/src/Navigation/Bottom.tsx
+++ b/src/Navigation/Bottom.tsx
@@ -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`}
>
@@ -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(() => {
diff --git a/src/Navigation/CustomDrawer.tsx b/src/Navigation/CustomDrawer.tsx
deleted file mode 100644
index fad9677..0000000
--- a/src/Navigation/CustomDrawer.tsx
+++ /dev/null
@@ -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 (
- toggleTheme(mode)}
- >
-
-
- {label}
-
-
- );
- };
-
- return (
-
-
- {/* Header */}
-
-
- ProcureLinc
- Smart Procurement
-
-
-
-
-
-
- {/* Theme Switch Section */}
-
- Running Theme
-
- {renderThemeOption('light', 'Light', Sun)}
- {renderThemeOption('dark', 'Dark', Moon)}
- {renderThemeOption('system', 'System', Monitor)}
-
-
-
-
-
-
- Version 1.0.0
-
-
-
- );
-};
-
-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;
diff --git a/src/Navigation/DrawerStack.tsx b/src/Navigation/DrawerStack.tsx
deleted file mode 100644
index dcc8dd7..0000000
--- a/src/Navigation/DrawerStack.tsx
+++ /dev/null
@@ -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 (
- }
- 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)',
- }}
- >
-
- {/* Add other drawer screens here that are NOT in the bottom tab */}
-
- );
-};
-
-export default DrawerStack;
diff --git a/src/Navigation/MainStack.jsx b/src/Navigation/MainStack.tsx
similarity index 93%
rename from src/Navigation/MainStack.jsx
rename to src/Navigation/MainStack.tsx
index e5c1041..f6f01fe 100644
--- a/src/Navigation/MainStack.jsx
+++ b/src/Navigation/MainStack.tsx
@@ -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();
export default function MainStack() {
return (
@@ -41,9 +42,9 @@ export default function MainStack() {
-
+
);
}
diff --git a/src/Navigation/Routes.jsx b/src/Navigation/Routes.jsx
deleted file mode 100644
index 35a9245..0000000
--- a/src/Navigation/Routes.jsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import * as React from 'react';
-import MainStack from './MainStack';
-export default function Routes() {
- return <>{MainStack()} >
-}
diff --git a/src/Navigation/Routes.tsx b/src/Navigation/Routes.tsx
new file mode 100644
index 0000000..7cdc268
--- /dev/null
+++ b/src/Navigation/Routes.tsx
@@ -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 ;
+}
diff --git a/src/Navigation/types.ts b/src/Navigation/types.ts
new file mode 100644
index 0000000..d5e0b55
--- /dev/null
+++ b/src/Navigation/types.ts
@@ -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>()
+ * useRoute>()
+ */
+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 };
+ Registration: Record | undefined;
+ Partnership: { otherId: unknown; orgType: unknown };
+ /** The whole tender object is currently spread into params. */
+ LiveTenderDetail: { tenderId: number } & Record;
+ ResetPassword: undefined;
+ /** Server-rendered KYC HTML delivered via params. */
+ KycWebViewScreen: { res: { data: string } } & Record;
+ StageThree: { otherId: unknown; orgType: unknown };
+ DeviceBinding: { message?: string; showBindButton?: boolean } | undefined;
+ DscBinding: { message?: string } | undefined;
+ EnvelopeCSpreadsheet: { tender: Record; buttonEnable?: boolean };
+};
diff --git a/src/Permission/index.tsx b/src/Permission/index.tsx
index 1752d13..597207a 100644
--- a/src/Permission/index.tsx
+++ b/src/Permission/index.tsx
@@ -1,3 +1,7 @@
+/**
+ * Centralized permission broker — screens must request permissions through
+ * these helpers, never react-native-permissions directly.
+ */
import {
check,
request,
@@ -6,93 +10,72 @@ import {
openSettings,
PERMISSIONS,
Permission,
+ checkNotifications,
+ requestNotifications,
} from 'react-native-permissions';
import { Platform } from 'react-native';
-
+import { logger } from '../core/observability/logger';
+
// Normalize permission for platform
-const normalizePermission = (perm: string): string => {
+const normalizePermission = (perm: string): Permission => {
return Platform.select({
ios: (PERMISSIONS.IOS as Record)[perm] || perm,
android: (PERMISSIONS.ANDROID as Record)[perm] || perm,
default: perm,
- })!;
+ }) as Permission;
};
-
+
// Generic permission requester
export const requestSinglePermission = async (
permission: string
): Promise => {
const normalized = normalizePermission(permission);
-
+
const status: PermissionStatus = await check(normalized);
-
+
if (status === RESULTS.GRANTED) {
- console.log(`✅ ${permission} already granted.`);
return true;
}
-
+
if (status === RESULTS.BLOCKED) {
- console.log(`🚫 ${permission} is blocked. Prompt user to go to settings.`);
+ logger.info('permissions', `${permission} is blocked — opening settings`);
openSettings();
return false;
}
-
+
if (status === RESULTS.DENIED || status === RESULTS.LIMITED) {
const result: PermissionStatus = await request(normalized);
- if (result === RESULTS.GRANTED) {
- console.log(`✅ ${permission} granted after request.`);
- return true;
- } else {
- console.log(`❌ ${permission} denied.`);
- return false;
- }
+ return result === RESULTS.GRANTED;
}
-
+
return false;
};
-
-
-
-// Fallback definition
-const POST_NOTIFICATIONS =
- Platform.OS === 'android' ? 'android.permission.POST_NOTIFICATIONS' : '';
-
+
export const requestNotificationPermission = async (): Promise => {
-
- 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 => {
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;
}
-}
\ No newline at end of file
+}
diff --git a/src/Redux/Slices/SelectedAuction.tsx b/src/Redux/Slices/SelectedAuction.tsx
index 22bd83a..a4e97f0 100644
--- a/src/Redux/Slices/SelectedAuction.tsx
+++ b/src/Redux/Slices/SelectedAuction.tsx
@@ -13,7 +13,7 @@ export const auctionSlice = createSlice({
},
removeAuction: (state) => {
state.auction = '';
- }
+ },
},
});
export const { setAuction , removeAuction} = auctionSlice.actions;
diff --git a/src/Redux/Slices/SelectedPurchaseTender.ts b/src/Redux/Slices/SelectedPurchaseTender.ts
index 7e996b1..4fb7d7d 100644
--- a/src/Redux/Slices/SelectedPurchaseTender.ts
+++ b/src/Redux/Slices/SelectedPurchaseTender.ts
@@ -34,4 +34,4 @@ export const {
clearPurchaseTender,
} = selectPurchaseTenderSlice.actions;
-export default selectPurchaseTenderSlice.reducer;
\ No newline at end of file
+export default selectPurchaseTenderSlice.reducer;
diff --git a/src/Redux/Slices/Token.tsx b/src/Redux/Slices/Token.tsx
index b86451f..9fe349c 100644
--- a/src/Redux/Slices/Token.tsx
+++ b/src/Redux/Slices/Token.tsx
@@ -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 | ''>) => {
+ // 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;
diff --git a/src/Redux/Slices/__tests__/Token.test.ts b/src/Redux/Slices/__tests__/Token.test.ts
new file mode 100644
index 0000000..25ed7f3
--- /dev/null
+++ b/src/Redux/Slices/__tests__/Token.test.ts
@@ -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);
+ });
+});
diff --git a/src/Redux/Store.js b/src/Redux/Store.js
deleted file mode 100644
index a9e470e..0000000
--- a/src/Redux/Store.js
+++ /dev/null
@@ -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);
diff --git a/src/Redux/Store.ts b/src/Redux/Store.ts
new file mode 100644
index 0000000..62fedfd
--- /dev/null
+++ b/src/Redux/Store.ts
@@ -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;
+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());
+ }
+});
diff --git a/src/Redux/hooks.ts b/src/Redux/hooks.ts
new file mode 100644
index 0000000..9ed27a2
--- /dev/null
+++ b/src/Redux/hooks.ts
@@ -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 = useSelector;
diff --git a/src/Screens/Auction/Auction.tsx b/src/Screens/Auction/Auction.tsx
index 41aaaa0..a4110e7 100644
--- a/src/Screens/Auction/Auction.tsx
+++ b/src/Screens/Auction/Auction.tsx
@@ -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
diff --git a/src/Screens/Auction/LiveAuction.tsx b/src/Screens/Auction/LiveAuction.tsx
index d9345d1..cd2bbf1 100644
--- a/src/Screens/Auction/LiveAuction.tsx
+++ b/src/Screens/Auction/LiveAuction.tsx
@@ -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 ;
+ return ;
}
return (
@@ -138,7 +139,7 @@ const LiveAuction = () => {
{/* List */}
{isPending ? (
-
+
) : (
= ({ 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';
diff --git a/src/Screens/Auction/Modal/AuctionItemDetail.tsx b/src/Screens/Auction/Modal/AuctionItemDetail.tsx
index 443d5b5..435c1f4 100644
--- a/src/Screens/Auction/Modal/AuctionItemDetail.tsx
+++ b/src/Screens/Auction/Modal/AuctionItemDetail.tsx
@@ -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 = ({ 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 = ({ 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);
diff --git a/src/Screens/Auction/PurchaseAuction.tsx b/src/Screens/Auction/PurchaseAuction.tsx
index 9e3426d..fbf9d78 100644
--- a/src/Screens/Auction/PurchaseAuction.tsx
+++ b/src/Screens/Auction/PurchaseAuction.tsx
@@ -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({ isShowFilter: false })
const format = 'DD-MM-YYYY HH:mm:ss';
@@ -65,11 +64,11 @@ const PurchaseAuction = () => {
return (
{ setFilter(prev => ({ ...prev, auctionNumber: val })) }} placeholder='Search....' filterPress={() => { setAllBoolean((prev: any) => ({ ...prev, isShowFilter: !prev.isShowFilter })) }} filter={false} />
- {isPending ?
+ {isPending ?
:
(
+ renderItem={({ item }) => (
{
diff --git a/src/Screens/Auction/PurchaseAuctionList.tsx b/src/Screens/Auction/PurchaseAuctionList.tsx
index 62540a7..083a0de 100644
--- a/src/Screens/Auction/PurchaseAuctionList.tsx
+++ b/src/Screens/Auction/PurchaseAuctionList.tsx
@@ -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]),
);
diff --git a/src/Screens/Auction/PurchaseAuctionTile.tsx b/src/Screens/Auction/PurchaseAuctionTile.tsx
index bdc17fe..270faf6 100644
--- a/src/Screens/Auction/PurchaseAuctionTile.tsx
+++ b/src/Screens/Auction/PurchaseAuctionTile.tsx
@@ -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 };
diff --git a/src/Screens/Auction/PurchaseList.tsx b/src/Screens/Auction/PurchaseList.tsx
index 6e7a0b3..9935909 100644
--- a/src/Screens/Auction/PurchaseList.tsx
+++ b/src/Screens/Auction/PurchaseList.tsx
@@ -5,7 +5,7 @@ import {
ScrollView,
TouchableOpacity,
} from 'react-native';
-import Animated, {
+import Animated, {
FadeIn,
FadeOut,
FadeInDown,
@@ -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);
diff --git a/src/Screens/Bidding/StartBidding.tsx b/src/Screens/Bidding/AuctionBiddingScreen.tsx
similarity index 74%
rename from src/Screens/Bidding/StartBidding.tsx
rename to src/Screens/Bidding/AuctionBiddingScreen.tsx
index 80fda10..e312d50 100644
--- a/src/Screens/Bidding/StartBidding.tsx
+++ b/src/Screens/Bidding/AuctionBiddingScreen.tsx
@@ -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([]);
const [bidValues, setBidValues] = useState>({});
@@ -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('get', FORWARD_AUCTION_ITEM, {}, payload);
- if (res?.statusCode === 200) auctionItem = res.data || [];
- }
+ const res = await apiCall('get', config.itemsUrl, {}, payload);
+ if (res?.statusCode === 200) { auctionItem = res.data || []; }
const selectedRes = await apiCall('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();
+ const liveBids = new Map();
(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();
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 = {};
- 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 }) => (
- 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}
/>
)}
@@ -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 },
});
diff --git a/src/Screens/Bidding/Component/BiddingCard.tsx b/src/Screens/Bidding/Component/AuctionBiddingCard.tsx
similarity index 62%
rename from src/Screens/Bidding/Component/BiddingCard.tsx
rename to src/Screens/Bidding/Component/AuctionBiddingCard.tsx
index 6cabba3..9043fcb 100644
--- a/src/Screens/Bidding/Component/BiddingCard.tsx
+++ b/src/Screens/Bidding/Component/AuctionBiddingCard.tsx
@@ -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>;
+ 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({
+ 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) && (
+
+ ₹
+
+
+
+ );
return (
@@ -109,19 +149,18 @@ const BiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid, Selec
{/* HEADER */}
{ setShowDetail(true) }}
+ onPress={() => { setShowDetail(true); }}
style={styles.cardHeader}
>
-
+
{item.productName}
- {/* auctionStartValueFigure */}
- Initial Amt : {item?.auctionStartValueFigure}
+ Initial Amt : ₹{config.startValue(item)}
-
+
{item.me ? 'LEADING' : 'LIVE'}
@@ -143,77 +182,83 @@ const BiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid, Selec
Current Bid
- ₹{item?.bidAmount || item.auctionStartValueFigure}
+ ₹{item?.bidAmount || config.startValue(item)}
-
- Increment
+
+ {config.stepLabel}
- +₹{item.bidVariationValue}
+ {config.stepPrefix}₹{config.step(item)}
{/* COLLAPSIBLE BODY */}
-
+
Set Your Bid
- {!allBoolean.oneTimeBid &&
-
+ {!allBoolean.oneTimeBid &&
−
-
+ }
-
+ {!allBoolean.oneTimeBid &&
₹
{bidValue}
-
+ }
-
+ {config.inlineManualInput && manualInput}
+
+ {!allBoolean.oneTimeBid &&
+
-
+ }
}
- {
- (allBoolean.oneTimeBid || allBoolean.rebid) &&
- ₹
-
-
- }
+ {!config.inlineManualInput && manualInput}
- {(!allBoolean.rebid && item.contractorId == undefined) ?
{
- allState.isLoading ? : Confirm Bid
+ allState.isLoading ? : Confirm Bid
}
: null}
:
- { setShowDetail(false) }} visible={showDetail} />
+ { setShowDetail(false); }} visible={showDetail} />
}
- )
+ );
};
-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,
diff --git a/src/Screens/Bidding/Component/AuctionTimer.tsx b/src/Screens/Bidding/Component/AuctionTimer.tsx
deleted file mode 100644
index 286fdf1..0000000
--- a/src/Screens/Bidding/Component/AuctionTimer.tsx
+++ /dev/null
@@ -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 (
-
-
- {timeLeft}
-
- );
-};
-
-export default AuctionTimer;
diff --git a/src/Screens/Bidding/Component/ConfirmBidModal.tsx b/src/Screens/Bidding/Component/ConfirmBidModal.tsx
index 3f8686f..759e3ac 100644
--- a/src/Screens/Bidding/Component/ConfirmBidModal.tsx
+++ b/src/Screens/Bidding/Component/ConfirmBidModal.tsx
@@ -19,7 +19,7 @@ const ConfirmBidModal = ({ visible, onClose, onConfirm, bidAmount, isLoading }:
return (
-
+
diff --git a/src/Screens/Bidding/Component/EmdComplete.tsx b/src/Screens/Bidding/Component/EmdComplete.tsx
index 617bdff..bf73bbf 100644
--- a/src/Screens/Bidding/Component/EmdComplete.tsx
+++ b/src/Screens/Bidding/Component/EmdComplete.tsx
@@ -19,7 +19,7 @@ const STEPS = [
];
const EmdComplete = ({ onNext }: { onNext?: () => void }) => {
- const { colors, spacing, shadows } = useTheme();
+ const { colors, shadows } = useTheme();
return (
diff --git a/src/Screens/Bidding/Component/GoBackModal.tsx b/src/Screens/Bidding/Component/GoBackModal.tsx
index 7c44668..bc74b7c 100644
--- a/src/Screens/Bidding/Component/GoBackModal.tsx
+++ b/src/Screens/Bidding/Component/GoBackModal.tsx
@@ -17,7 +17,7 @@ const GoBackModal = ({ visible, onClose, onConfirm }: Props) => {
return (
-
+
diff --git a/src/Screens/Bidding/Component/ReverseBiddingCard.tsx b/src/Screens/Bidding/Component/ReverseBiddingCard.tsx
deleted file mode 100644
index b8f7b8d..0000000
--- a/src/Screens/Bidding/Component/ReverseBiddingCard.tsx
+++ /dev/null
@@ -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 (
-
- {!showDetail ? (
-
- {/* HEADER */}
- setShowDetail(true)}
- style={styles.cardHeader}
- >
-
- {item.productName}
-
- Initial Amt : ₹{item.auctionStartValue}
-
-
-
-
-
-
-
- {item.me ? 'LEADING' : 'LIVE'}
-
-
-
-
- ▼
-
-
-
- {SelectedAuction?.auctionNumber}
-
-
-
-
- {/* PRICE INFO */}
-
-
- Current Bid
- ₹{item.bidAmount || item.auctionStartValue}
-
-
-
- Decrement
-
- -₹{item.emdAmount}
-
-
-
-
- {/* COLLAPSIBLE BODY */}
- {/* */}
-
-
-
- Set Your Bid
-
-
- {/* DECREASE */}
- {!allBoolean.oneTimeBid &&
- −
- }
-
- {/* VALUE */}
- {!allBoolean.oneTimeBid &&
- ₹
- {bidValue}
- }
- {
- (allBoolean.oneTimeBid || allBoolean.rebid) &&
- ₹
-
-
-
- }
-
- {/* INCREASE */}
- {!allBoolean.oneTimeBid &&
- +
- }
-
-
-
- {
- allState.isLoading ? : Confirm Bid
- }
-
-
- {/* {(allBoolean.oneTimeBid && allBoolean.rebid) ?
-
- {
- allState.isLoading ? : Confirm Bid
- }
- :
- (allBoolean.oneTimeBid && !item.me) ?
- {
- allState.isLoading ? : Confirm Bid
- }
- : null
- } */}
-
-
- ) : (
- setShowDetail(false)}
- visible={showDetail}
- />
- )}
-
- );
-};
-
-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,
-});
diff --git a/src/Screens/Bidding/Component/TemplateFormModal.tsx b/src/Screens/Bidding/Component/TemplateFormModal.tsx
index 632ae46..d1f0266 100644
--- a/src/Screens/Bidding/Component/TemplateFormModal.tsx
+++ b/src/Screens/Bidding/Component/TemplateFormModal.tsx
@@ -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 = ({ 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 = ({ 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 = ({ 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,
diff --git a/src/Screens/Bidding/Emd.tsx b/src/Screens/Bidding/Emd.tsx
index 5ee0828..1826f2c 100644
--- a/src/Screens/Bidding/Emd.tsx
+++ b/src/Screens/Bidding/Emd.tsx
@@ -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) ? (
-
+
) : allBoolean.isEmdComplete ? (
) : allBoolean.emdType ? (
diff --git a/src/Screens/Bidding/StartReverseBidding.tsx b/src/Screens/Bidding/StartReverseBidding.tsx
deleted file mode 100644
index e14bdc6..0000000
--- a/src/Screens/Bidding/StartReverseBidding.tsx
+++ /dev/null
@@ -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([]);
- const [bidValues, setBidValues] = useState>({});
- 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('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('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('get', REVERSE_AUCTION_ITEM, {}, payload);
- if (res?.statusCode === 200) auctionItem = res.data || [];
- }
-
- const selectedRes = await apiCall('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();
-
- (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();
-
- (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 = {};
- 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();
-
- 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 = {};
- 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 = {};
- 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 (
-
-
-
-
- {SelectedAuction.deptName}
-
-
-
-
-
-
-
- item.id.toString()}
- renderItem={({ item, index }) => (
- 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 && (
-
-
-
- )}
-
- setAllBoolean(prev => ({ ...prev, showDialog: false }))}
- onConfirm={() => placeBidSubmit(allBoolean.bidPayload)}
- bidAmount={allBoolean.bidPayload?.bidAmount}
- />
-
- setAllBoolean(prev => ({ ...prev, showBackDialog: false }))}
- onConfirm={() => {
- setAllBoolean(prev => ({ ...prev, showBackDialog: false }));
- Navigation.dispatch(
- CommonActions.reset({
- index: 0,
- routes: [{ name: NavigationString.Home }],
- })
- );
- }}
- />
-
- );
-};
-
-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 },
-});
\ No newline at end of file
diff --git a/src/Screens/Bidding/__tests__/biddingPattern.test.ts b/src/Screens/Bidding/__tests__/biddingPattern.test.ts
new file mode 100644
index 0000000..871c154
--- /dev/null
+++ b/src/Screens/Bidding/__tests__/biddingPattern.test.ts
@@ -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();
+ });
+});
diff --git a/src/Screens/Bidding/biddingPattern.ts b/src/Screens/Bidding/biddingPattern.ts
new file mode 100644
index 0000000..0d4d940
--- /dev/null
+++ b/src/Screens/Bidding/biddingPattern.ts
@@ -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;
diff --git a/src/Screens/Home/Home.tsx b/src/Screens/Home/Home.tsx
index b568dd0..cb05beb 100644
--- a/src/Screens/Home/Home.tsx
+++ b/src/Screens/Home/Home.tsx
@@ -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 = () => {
+ {isRefreshing && (
+
+ )}
setIsMenuVisible(true)}>
diff --git a/src/Screens/Home/KycWebViewScreen.tsx b/src/Screens/Home/KycWebViewScreen.tsx
index f88e846..b5fcf08 100644
--- a/src/Screens/Home/KycWebViewScreen.tsx
+++ b/src/Screens/Home/KycWebViewScreen.tsx
@@ -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);
};
diff --git a/src/Screens/Login/ForgotPassword.tsx b/src/Screens/Login/ForgotPassword.tsx
index d87d4c0..db20391 100644
--- a/src/Screens/Login/ForgotPassword.tsx
+++ b/src/Screens/Login/ForgotPassword.tsx
@@ -6,7 +6,6 @@ import {
Modal,
TextInput,
TouchableOpacity,
- Alert,
KeyboardAvoidingView,
Platform,
TouchableWithoutFeedback,
diff --git a/src/Screens/Login/Login.tsx b/src/Screens/Login/Login.tsx
index 7d98094..0151bc7 100644
--- a/src/Screens/Login/Login.tsx
+++ b/src/Screens/Login/Login.tsx
@@ -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();
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(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
diff --git a/src/Screens/Login/ResetPassword.tsx b/src/Screens/Login/ResetPassword.tsx
index 2579e10..aedec46 100644
--- a/src/Screens/Login/ResetPassword.tsx
+++ b/src/Screens/Login/ResetPassword.tsx
@@ -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 = ({ route, navigation }) => {
- const [email, setEmail] = useState(route?.params?.email || '');
+ const [email] = useState(route?.params?.email || '');
const [otp, setOtp] = useState(['', '', '', '', '', '']);
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
@@ -45,7 +45,7 @@ const ResetPassword: React.FC = ({ route, navigation }) => {
const otpInputs = useRef>([]);
const newPasswordRef = useRef(null);
const confirmPasswordRef = useRef(null);
- const resendTimerRef = useRef(null);
+ const resendTimerRef = useRef | null>(null);
useEffect(() => {
checkResendCooldown();
@@ -107,6 +107,8 @@ const ResetPassword: React.FC = ({ 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 = ({ 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({
+ 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 = ({ 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 = ({ 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({
+ 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 = ({ 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 = ({ 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({
+ 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 {
diff --git a/src/Screens/Profile/EditProfileModal.tsx b/src/Screens/Profile/EditProfileModal.tsx
new file mode 100644
index 0000000..f106747
--- /dev/null
+++ b/src/Screens/Profile/EditProfileModal.tsx
@@ -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 = ({
+ visible,
+ onClose,
+ data,
+ onSuccess,
+}) => {
+ const { colors } = useTheme();
+ const styles = useMemo(() => createStyles(colors), [colors]);
+ const [formData, setFormData] = useState({
+ firstName: data.firstName || '',
+ middleName: data.middleName || '',
+ lastName: data.lastName || '',
+ mobileNo: data.mobileNo || '',
+ emailId: data.emailId || '',
+ fullname: data.fullName || '',
+ });
+
+ const [errors, setErrors] = useState>({});
+
+ 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 = {};
+
+ 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 (
+
+
+
+
+ Edit Personal Information
+
+
+
+
+
+
+
+ First Name *
+ setFormData({ ...formData, firstName: text })}
+ placeholder="Enter first name"
+ placeholderTextColor={colors.textSecondary}
+ />
+ {errors.firstName && (
+ {errors.firstName}
+ )}
+
+
+
+ Middle Name
+ setFormData({ ...formData, middleName: text })}
+ placeholder="Enter middle name"
+ placeholderTextColor={colors.textSecondary}
+ />
+
+
+
+ Last Name *
+ setFormData({ ...formData, lastName: text })}
+ placeholder="Enter last name"
+ placeholderTextColor={colors.textSecondary}
+ />
+ {errors.lastName && (
+ {errors.lastName}
+ )}
+
+
+
+ Email *
+ setFormData({ ...formData, emailId: text })}
+ placeholder="Enter email"
+ placeholderTextColor={colors.textSecondary}
+ keyboardType="email-address"
+ autoCapitalize="none"
+ />
+ {errors.emailId && (
+ {errors.emailId}
+ )}
+
+
+
+ Mobile Number *
+ setFormData({ ...formData, mobileNo: text })}
+ placeholder="Enter mobile number"
+ placeholderTextColor={colors.textSecondary}
+ keyboardType="phone-pad"
+ maxLength={10}
+ />
+ {errors.mobileNo && (
+ {errors.mobileNo}
+ )}
+
+
+
+
+
+ Cancel
+
+
+
+ {updateProfileMutation.isPending ? (
+
+ ) : (
+ <>
+
+ Save
+ >
+ )}
+
+
+
+
+
+ );
+};
+
+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;
diff --git a/src/Screens/Profile/ProfileTab.tsx b/src/Screens/Profile/ProfileTab.tsx
index c95a67b..dfa4512 100644
--- a/src/Screens/Profile/ProfileTab.tsx
+++ b/src/Screens/Profile/ProfileTab.tsx
@@ -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 = ({
- visible,
- onClose,
- data,
- onSuccess,
-}) => {
- const { colors } = useTheme();
- const styles = useMemo(() => createStyles(colors), [colors]);
- const [formData, setFormData] = useState({
- firstName: data.firstName || '',
- middleName: data.middleName || '',
- lastName: data.lastName || '',
- mobileNo: data.mobileNo || '',
- emailId: data.emailId || '',
- fullname: data.fullName || '',
- });
-
- const [errors, setErrors] = useState>({});
-
- 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 = {};
-
- 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 (
-
-
-
-
- Edit Personal Information
-
-
-
-
-
-
-
- First Name *
- setFormData({ ...formData, firstName: text })}
- placeholder="Enter first name"
- placeholderTextColor={colors.textSecondary}
- />
- {errors.firstName && (
- {errors.firstName}
- )}
-
-
-
- Middle Name
- setFormData({ ...formData, middleName: text })}
- placeholder="Enter middle name"
- placeholderTextColor={colors.textSecondary}
- />
-
-
-
- Last Name *
- setFormData({ ...formData, lastName: text })}
- placeholder="Enter last name"
- placeholderTextColor={colors.textSecondary}
- />
- {errors.lastName && (
- {errors.lastName}
- )}
-
-
-
- Email *
- setFormData({ ...formData, emailId: text })}
- placeholder="Enter email"
- placeholderTextColor={colors.textSecondary}
- keyboardType="email-address"
- autoCapitalize="none"
- />
- {errors.emailId && (
- {errors.emailId}
- )}
-
-
-
- Mobile Number *
- setFormData({ ...formData, mobileNo: text })}
- placeholder="Enter mobile number"
- placeholderTextColor={colors.textSecondary}
- keyboardType="phone-pad"
- maxLength={10}
- />
- {errors.mobileNo && (
- {errors.mobileNo}
- )}
-
-
-
-
-
- Cancel
-
-
-
- {updateProfileMutation.isPending ? (
-
- ) : (
- <>
-
- Save
- >
- )}
-
-
-
-
-
- );
-};
-
// Profile Header Component - OPTIMIZED
const ProfileHeader: React.FC = 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,
diff --git a/src/Screens/Profile/profileTypes.ts b/src/Screens/Profile/profileTypes.ts
new file mode 100644
index 0000000..425589c
--- /dev/null
+++ b/src/Screens/Profile/profileTypes.ts
@@ -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);
diff --git a/src/Screens/Ragistration/PartnerShip.tsx b/src/Screens/Ragistration/PartnerShip.tsx
index 689a4c4..16da803 100644
--- a/src/Screens/Ragistration/PartnerShip.tsx
+++ b/src/Screens/Ragistration/PartnerShip.tsx
@@ -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 = () => {
-
+
);
@@ -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,
diff --git a/src/Screens/Ragistration/Ragistration.tsx b/src/Screens/Ragistration/Ragistration.tsx
index 4fb3759..bdf0dbf 100644
--- a/src/Screens/Ragistration/Ragistration.tsx
+++ b/src/Screens/Ragistration/Ragistration.tsx
@@ -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();
const { params } = useRoute, string>>();
const userData = params as RouteParams;
- const [stage, setStage] = useState(0);
const insets = useSafeAreaInsets();
- const [focusedField, setFocusedField] = useState(null);
- const [selectedOrg, setSelectedOrg] = useState(0);
- const [documents, setDocuments] = useState({
- panCard: null,
- gstnCertificate: null,
- registrationCertificate: null,
- msmefile: null,
- constractorClass: null,
- aadharCard: null,
- });
- const [formData, setFormData] = useState({
- 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 (
-
+
@@ -499,43 +294,25 @@ const Registration: React.FC = () => {
bounces={false}
showsVerticalScrollIndicator={false}
>
-
+
{/* ═══ Back Button ═══ */}
{
- if (stage > 0) {
- setStage(prev => prev - 1);
- }
- }}
+ onPress={() => dispatch({ type: 'PREV_STAGE' })}
style={styles.backBtn}
activeOpacity={0.7}
>
-
+
Registration
-
+
{/* ═══ Progress Bar ═══ */}
- {/* ═══ Stage Title ═══
-
-
- {stageTitles[stage]}
-
-
- Step {stage + 1} of {steps.length}
-
- */}
-
{/* ═══ Stage Content ═══ */}
{
{stage === 0 && (
- handleChange(key as keyof FormDataType, value)
- }
+ handleChange={handleChange}
focusedField={focusedField}
setFocusedField={setFocusedField}
styles={styles}
@@ -557,18 +332,14 @@ const Registration: React.FC = () => {
{stage === 1 && (
- 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 && (
- 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 && (
- 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 ═══ */}
{
- 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}
/>
@@ -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,
diff --git a/src/Screens/Ragistration/StageThree.tsx b/src/Screens/Ragistration/StageThree.tsx
index 56db2a6..3f088c2 100644
--- a/src/Screens/Ragistration/StageThree.tsx
+++ b/src/Screens/Ragistration/StageThree.tsx
@@ -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 = () => {
-
+
);
@@ -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,
diff --git a/src/Screens/Ragistration/__tests__/registrationState.test.ts b/src/Screens/Ragistration/__tests__/registrationState.test.ts
new file mode 100644
index 0000000..3dcf461
--- /dev/null
+++ b/src/Screens/Ragistration/__tests__/registrationState.test.ts
@@ -0,0 +1,58 @@
+import { initialRegistrationState, registrationReducer, RegistrationState } from '../registrationState';
+
+const s = (over: Partial = {}): 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);
+ });
+});
diff --git a/src/Screens/Ragistration/__tests__/registrationValidation.test.ts b/src/Screens/Ragistration/__tests__/registrationValidation.test.ts
new file mode 100644
index 0000000..e959671
--- /dev/null
+++ b/src/Screens/Ragistration/__tests__/registrationValidation.test.ts
@@ -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);
+ });
+});
diff --git a/src/Screens/Ragistration/ragistrationStages/StageFour.tsx b/src/Screens/Ragistration/ragistrationStages/StageFour.tsx
index f1265c4..631cf7b 100644
--- a/src/Screens/Ragistration/ragistrationStages/StageFour.tsx
+++ b/src/Screens/Ragistration/ragistrationStages/StageFour.tsx
@@ -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 = ({
formData,
selectedOrg,
}) => {
- const { colors, spacing, shadows } = useTheme();
+ const { colors, spacing } = useTheme();
const documentItems: DocumentItem[] = [
{ key: 'panCard', label: 'PAN Card', icon: 'pan' },
diff --git a/src/Screens/Ragistration/ragistrationStages/StageOne.tsx b/src/Screens/Ragistration/ragistrationStages/StageOne.tsx
index eb40cf9..4333bb6 100644
--- a/src/Screens/Ragistration/ragistrationStages/StageOne.tsx
+++ b/src/Screens/Ragistration/ragistrationStages/StageOne.tsx
@@ -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 = ({
allOrganizations,
allMsmeOrgs,
allSocialCategories,
- dropdownStyle,
setSelectedOrg,
selectedOrg,
}) => {
diff --git a/src/Screens/Ragistration/ragistrationStages/StageThree.tsx b/src/Screens/Ragistration/ragistrationStages/StageThree.tsx
index 78d9613..48f1674 100644
--- a/src/Screens/Ragistration/ragistrationStages/StageThree.tsx
+++ b/src/Screens/Ragistration/ragistrationStages/StageThree.tsx
@@ -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';
diff --git a/src/Screens/Ragistration/ragistrationStages/StageTwo.tsx b/src/Screens/Ragistration/ragistrationStages/StageTwo.tsx
index 667294c..62a5148 100644
--- a/src/Screens/Ragistration/ragistrationStages/StageTwo.tsx
+++ b/src/Screens/Ragistration/ragistrationStages/StageTwo.tsx
@@ -33,9 +33,6 @@ interface Props {
setadharotpsend: (value: boolean) => void;
verifyAdharOtpHandler: () => void;
aadhaarVerifying: boolean;
- verifyGSTHandler: () => void;
- gstverifying: boolean;
- color: any;
styles: any;
formatDateInput: (text: string) => string;
selectedOrg: string | number;
@@ -57,14 +54,11 @@ const StageTwo: React.FC = ({
setadharotpsend,
verifyAdharOtpHandler,
aadhaarVerifying,
- verifyGSTHandler,
- gstverifying,
- color,
styles,
formatDateInput,
selectedOrg,
}) => {
- const { colors, spacing, shadows } = useTheme();
+ const { colors, spacing } = useTheme();
const [open, setOpen] = useState(false);
const [date, setDate] = useState(new Date());
diff --git a/src/Screens/Ragistration/ragistrationStages/StageZero.tsx b/src/Screens/Ragistration/ragistrationStages/StageZero.tsx
index 25cba07..22ab0ad 100644
--- a/src/Screens/Ragistration/ragistrationStages/StageZero.tsx
+++ b/src/Screens/Ragistration/ragistrationStages/StageZero.tsx
@@ -1,5 +1,4 @@
import React from 'react';
-import { View } from 'react-native';
import MyInput from '../../../components/MyInput';
import { useTheme } from '../../../Hooks/useTheme';
import AppText from '../../../components/AppText';
diff --git a/src/Screens/Ragistration/registrationState.ts b/src/Screens/Ragistration/registrationState.ts
new file mode 100644
index 0000000..9c44899
--- /dev/null
+++ b/src/Screens/Ragistration/registrationState.ts
@@ -0,0 +1,196 @@
+/**
+ * Registration wizard state — one reducer instead of the historical ~20
+ * scattered useStates. Every action's semantics mirror the exact setState
+ * sequences it replaced; async flows dispatch a *_START action, do the work,
+ * then dispatch a *_DONE action.
+ */
+import { DocumentsType, FormDataType, OrgType } from './types/types';
+
+export type DropdownItem = { label: string; value: number | string };
+
+export type MasterLists = {
+ contractorClasses: DropdownItem[];
+ organizations: DropdownItem[];
+ socialCategories: DropdownItem[];
+ msmeOrgs: DropdownItem[];
+ states: DropdownItem[];
+};
+
+export type RegistrationState = {
+ stage: number;
+ focusedField: string | null;
+ selectedOrg: OrgType;
+ formData: FormDataType;
+ documents: DocumentsType;
+
+ panVerifying: boolean;
+ panVerified: boolean;
+ aadhaarVerifying: boolean;
+ aadhaarVerified: boolean;
+ aadhaarOtpSent: boolean;
+ aadhaarRefNumber: string;
+ gstVerifying: boolean;
+ gstVerified: boolean;
+
+ masterLists: MasterLists;
+ cities: DropdownItem[];
+ submitting: boolean;
+};
+
+export const initialRegistrationState: RegistrationState = {
+ stage: 0,
+ focusedField: null,
+ selectedOrg: 0,
+ formData: {
+ firstName: '',
+ middleName: '',
+ lastName: '',
+ email: '',
+ mobileNumber: '',
+ organizationName: '',
+ officialWebsite: '',
+ registrationNumber: '',
+ contractorClass: '',
+ orgType: '',
+ registrationDetail: '',
+ socialCategory: '',
+ orgPanNumber: '',
+ dateOfIncorp: '',
+ orgPanName: '',
+ gstnNo: '',
+ aadhaarNumber: '',
+ aadhaarOtp: '',
+ address: '',
+ state: '',
+ city: '',
+ pinCode: '',
+ agentContact2: '',
+ expiryDate: '',
+ },
+ documents: {
+ panCard: null,
+ gstnCertificate: null,
+ registrationCertificate: null,
+ msmefile: null,
+ constractorClass: null,
+ aadharCard: null,
+ },
+
+ panVerifying: false,
+ panVerified: false,
+ aadhaarVerifying: false,
+ aadhaarVerified: false,
+ aadhaarOtpSent: false,
+ aadhaarRefNumber: '',
+ gstVerifying: false,
+ gstVerified: false,
+
+ masterLists: {
+ contractorClasses: [],
+ organizations: [],
+ socialCategories: [],
+ msmeOrgs: [],
+ states: [],
+ },
+ cities: [],
+ submitting: false,
+};
+
+export type RegistrationAction =
+ | { type: 'NEXT_STAGE' }
+ | { type: 'PREV_STAGE' }
+ | { type: 'SET_FOCUSED_FIELD'; field: string | null }
+ | { type: 'SET_SELECTED_ORG'; org: OrgType }
+ | { type: 'SET_FIELD'; field: keyof FormDataType; value: string }
+ | { type: 'PREFILL_CONTACT'; values: Partial }
+ | { type: 'SET_DOCUMENT'; field: string; file: any }
+ | { type: 'MASTER_DATA_LOADED'; lists: MasterLists }
+ | { type: 'CITIES_LOADED'; cities: DropdownItem[] }
+ | { type: 'PAN_VERIFY_START' }
+ | { type: 'PAN_VERIFY_DONE'; verified: boolean }
+ | { type: 'SET_PAN_VERIFIED'; verified: boolean }
+ | { type: 'AADHAAR_OTP_REQUEST' }
+ | { type: 'AADHAAR_OTP_DONE'; sent: boolean; refNumber: string }
+ | { type: 'SET_AADHAAR_OTP_SENT'; sent: boolean }
+ | { type: 'AADHAAR_VERIFY_START' }
+ /** `verified` omitted → leave the current verified flag untouched (failure path). */
+ | { type: 'AADHAAR_VERIFY_DONE'; verified?: boolean }
+ | { type: 'SET_AADHAAR_VERIFIED'; verified: boolean }
+ | { type: 'GST_VERIFY_START' }
+ /** `verified` omitted → leave the current verified flag untouched (failure path). */
+ | { type: 'GST_VERIFY_DONE'; verified?: boolean }
+ | { type: 'SUBMIT_START' }
+ | { type: 'SUBMIT_END' };
+
+export function registrationReducer(
+ state: RegistrationState,
+ action: RegistrationAction,
+): RegistrationState {
+ switch (action.type) {
+ case 'NEXT_STAGE':
+ return { ...state, stage: state.stage + 1 };
+ case 'PREV_STAGE':
+ return state.stage > 0 ? { ...state, stage: state.stage - 1 } : state;
+ case 'SET_FOCUSED_FIELD':
+ return { ...state, focusedField: action.field };
+ case 'SET_SELECTED_ORG':
+ return { ...state, selectedOrg: action.org };
+ case 'SET_FIELD':
+ return { ...state, formData: { ...state.formData, [action.field]: action.value } };
+ case 'PREFILL_CONTACT':
+ return { ...state, formData: { ...state.formData, ...action.values } };
+ case 'SET_DOCUMENT':
+ return { ...state, documents: { ...state.documents, [action.field]: action.file } };
+ case 'MASTER_DATA_LOADED':
+ return { ...state, masterLists: action.lists };
+ case 'CITIES_LOADED':
+ return { ...state, cities: action.cities };
+
+ case 'PAN_VERIFY_START':
+ return { ...state, panVerifying: true };
+ case 'PAN_VERIFY_DONE':
+ return { ...state, panVerifying: false, panVerified: action.verified };
+ case 'SET_PAN_VERIFIED':
+ return { ...state, panVerified: action.verified };
+
+ case 'AADHAAR_OTP_REQUEST':
+ return { ...state, aadhaarVerifying: true, aadhaarRefNumber: '' };
+ case 'AADHAAR_OTP_DONE':
+ return {
+ ...state,
+ aadhaarVerifying: false,
+ aadhaarOtpSent: action.sent,
+ aadhaarRefNumber: action.refNumber,
+ };
+ case 'SET_AADHAAR_OTP_SENT':
+ return { ...state, aadhaarOtpSent: action.sent };
+
+ case 'AADHAAR_VERIFY_START':
+ return { ...state, aadhaarVerifying: true };
+ case 'AADHAAR_VERIFY_DONE':
+ return {
+ ...state,
+ aadhaarVerifying: false,
+ aadhaarVerified: action.verified ?? state.aadhaarVerified,
+ };
+ case 'SET_AADHAAR_VERIFIED':
+ return { ...state, aadhaarVerified: action.verified };
+
+ case 'GST_VERIFY_START':
+ return { ...state, gstVerifying: true };
+ case 'GST_VERIFY_DONE':
+ return {
+ ...state,
+ gstVerifying: false,
+ gstVerified: action.verified ?? state.gstVerified,
+ };
+
+ case 'SUBMIT_START':
+ return { ...state, submitting: true };
+ case 'SUBMIT_END':
+ return { ...state, submitting: false };
+
+ default:
+ return state;
+ }
+}
diff --git a/src/Screens/Ragistration/registrationValidation.ts b/src/Screens/Ragistration/registrationValidation.ts
new file mode 100644
index 0000000..69b6336
--- /dev/null
+++ b/src/Screens/Ragistration/registrationValidation.ts
@@ -0,0 +1,108 @@
+/**
+ * Pure per-stage validation for the registration wizard. No UI, no toasts —
+ * the screen decides how to present the returned message.
+ *
+ * The comparisons intentionally mirror the historical logic exactly
+ * (including loose equality where dropdowns may yield string or number).
+ */
+import { DocumentsType, FormDataType, OrgType } from './types/types';
+
+export type StageValidationInput = {
+ formData: FormDataType;
+ documents: DocumentsType;
+ selectedOrg: OrgType;
+};
+
+export type StageValidationResult = { valid: boolean; message?: string };
+
+const ok: StageValidationResult = { valid: true };
+const fail = (message: string): StageValidationResult => ({ valid: false, message });
+
+export const validateStage = (
+ stage: number,
+ { formData, documents, selectedOrg }: StageValidationInput,
+): StageValidationResult => {
+ switch (stage) {
+ case 0:
+ if (!formData.organizationName?.trim()) {
+ return fail('Please fill all required details.');
+ }
+ return ok;
+
+ case 1:
+ // eslint-disable-next-line eqeqeq
+ if (selectedOrg == 8) {
+ if (!formData.orgType || !formData.socialCategory) {
+ return fail('Please enter all valid details.');
+ }
+ } else if (
+ !formData.registrationNumber ||
+ !formData.registrationDetail ||
+ !formData.socialCategory ||
+ !formData.orgType
+ ) {
+ return fail('Please enter all valid details.');
+ }
+ return ok;
+
+ case 2:
+ if (
+ !formData.orgPanNumber ||
+ !formData.dateOfIncorp ||
+ !formData.orgPanName ||
+ // eslint-disable-next-line eqeqeq
+ (selectedOrg == 8 && !formData.gstnNo) ||
+ !formData.aadhaarNumber ||
+ !formData.aadhaarOtp
+ ) {
+ return fail('Please fill all required KYC and Aadhaar details.');
+ }
+ if (formData.aadhaarNumber.length !== 12) {
+ return fail('Aadhaar number must be 12 digits.');
+ }
+ if (formData.aadhaarOtp.length !== 6) {
+ return fail('OTP must be 6 digits.');
+ }
+ return ok;
+
+ case 3:
+ if (!formData.address || !formData.state || !formData.city || !formData.pinCode) {
+ return fail('Please fill all required address details.');
+ }
+ if (formData.pinCode.length !== 6) {
+ return fail('Please enter a valid 6-digit pin code.');
+ }
+ return ok;
+
+ case 4:
+ // eslint-disable-next-line eqeqeq
+ if (selectedOrg == 8) {
+ if (!documents.panCard || !documents.aadharCard) {
+ return fail('Please upload all required documents.');
+ }
+ return ok;
+ }
+ if ((formData.contractorClass as unknown) !== 1 && !documents.constractorClass) {
+ return fail('Please upload CONTRACTOR CLASS documents.');
+ }
+ if ((formData.registrationDetail as unknown) !== 3 && !documents.msmefile) {
+ return fail('Please upload MSME FILE.');
+ }
+ if (!documents.gstnCertificate || !documents.registrationCertificate) {
+ return fail('Please upload all required documents.');
+ }
+ return ok;
+
+ default:
+ return ok;
+ }
+};
+
+/** PAN format gate applied before leaving the KYC stage. */
+export const validatePanFormat = (pan: string): StageValidationResult => {
+ const panRegex = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
+ if (!panRegex.test(pan.toUpperCase())) {
+ return fail("Invalid PAN format. It should be like 'ABCDE1234F'.");
+ }
+ return ok;
+};
diff --git a/src/Screens/SignUp/SignUp.tsx b/src/Screens/SignUp/SignUp.tsx
index 7afac7a..052bad6 100644
--- a/src/Screens/SignUp/SignUp.tsx
+++ b/src/Screens/SignUp/SignUp.tsx
@@ -11,13 +11,13 @@ import {
ScrollView,
ToastAndroid,
} from 'react-native';
-import { User, Phone, Mail, Lock, CheckSquare, Square, Shield, UserPlus, Contact, Eye, EyeOff } from 'lucide-react-native';
+import { User, Phone, Mail, CheckSquare, Square, Shield, UserPlus, Contact, Eye, EyeOff } from 'lucide-react-native';
import { useGetDesignation, useRegisterContractor } from '../../Services/ApiCall/Hooks';
-import MyDropdown from '../../Component/Dropdown/MyDropdown';
+import MyDropdown from '../../components/MyDropdown';
import PrimaryButton from '../../components/PrimaryButton';
import MyInput from '../../components/MyInput';
-import InsideHeader from '../../Component/Header/InsideHeader';
-import TermsAndConditionsModal from '../../Component/TermsAndConditionsModal/TermsAndConditionsModal';
+import InsideHeader from '../../components/InsideHeader';
+import TermsAndConditionsModal from '../../components/TermsAndConditionsModal';
import AppText from '../../components/AppText';
import { Formik } from 'formik';
import * as Yup from 'yup';
@@ -111,7 +111,6 @@ const Signup = () => {
registerContractor(formData, {
onSuccess: (res: any) => {
- console.log('Register Res : ', res)
if (res?.status === '201') {
ToastAndroid.show(`${res.message}`, ToastAndroid.SHORT);
navigation.pop();
@@ -120,8 +119,7 @@ const Signup = () => {
}
},
onError: (error: any) => {
- console.log('Register Res : ', error)
- ToastAndroid.show(error.message, ToastAndroid.SHORT);
+ ToastAndroid.show(error?.message || 'Registration failed', ToastAndroid.SHORT);
},
});
};
diff --git a/src/Screens/Splash/Splash.tsx b/src/Screens/Splash/Splash.tsx
index 4253f86..3fd5b28 100644
--- a/src/Screens/Splash/Splash.tsx
+++ b/src/Screens/Splash/Splash.tsx
@@ -1,9 +1,11 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { Animated, StyleSheet, View } from 'react-native';
import { useNavigation, useIsFocused } from '@react-navigation/native';
-import AsyncStorage from '@react-native-async-storage/async-storage';
import { useSelector } from 'react-redux';
+import { performLogout } from '../../Services/Auth/session';
+import { applyRouteDecision, decideRegistrationRoute } from '../../Services/Auth/registrationRouting';
+import { logger } from '../../core/observability/logger';
import { showErrorAlert, showToast } from '../../Constant/ShowDailog';
import { useRagistrationState } from '../../Services/ApiCall/Hooks';
import NavigationString from '../../Constant/NavigationString';
@@ -27,84 +29,37 @@ const Splash: React.FC = () => {
const { mutateAsync: registrationStateMutate } = useRagistrationState();
const hasSessionToken = Boolean(reduxToken && typeof reduxToken === 'object' && reduxToken.token);
- 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 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', 'unclassifiable registration-state response on splash', {
+ statusCode: res?.statusCode,
+ });
showErrorAlert(res?.error || 'Something went wrong. Please try re-logging in.');
navigation.reset({
index: 0,
routes: [{ name: NavigationString.Login }],
});
} catch (error: any) {
- console.error('Registration status error:', error);
+ logger.error('auth', 'registration status resolution failed on splash', {
+ 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]);
useEffect(() => {
if (!isFocused || !isPersistRehydrated) {
diff --git a/src/Screens/Tender/Component/EnvelopeCExcelPreview.tsx b/src/Screens/Tender/Component/EnvelopeCExcelPreview.tsx
index 46c21e4..0f2fb32 100644
--- a/src/Screens/Tender/Component/EnvelopeCExcelPreview.tsx
+++ b/src/Screens/Tender/Component/EnvelopeCExcelPreview.tsx
@@ -1,532 +1,78 @@
-import React, { useEffect, useState } from 'react';
-import {
- View,
- Text,
- TextInput,
- ScrollView,
- TouchableOpacity,
- StyleSheet,
- ActivityIndicator,
- Alert,
-} from 'react-native';
-import RNFS from 'react-native-fs';
-import * as XLSX from 'xlsx';
-import axios, { AxiosError } from 'axios';
-import { useSelector } from 'react-redux';
-import colors from '../../../Constant/Color';
-import { GET_ENVELOPE_C, SUBMIT_ENVELOPE_C_UPLOAD } from '../../../Services/ApiCall/ApiUrls';
+/**
+ * EnvelopeCExcelPreview — launcher for the native BOQ (Envelope-C) editor.
+ *
+ * The Excel-like editor is a full-screen, virtualized surface, so instead of
+ * embedding a spreadsheet inside the Tender-Bidding scroll view this presents a
+ * compact card that opens the dedicated `EnvelopeCSpreadsheet` screen. Props are
+ * unchanged from the previous inline preview so the call site keeps working:
+ *
+ *
+ */
+import React from 'react';
+import { StyleSheet, TouchableOpacity, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
-import { encrypt, encryptWithRSA } from '../../../Services/Encryption/encrypt';
+import { FileSpreadsheet, ChevronRight } from 'lucide-react-native';
+import AppText from '../../../components/AppText';
+import { useTheme } from '../../../Hooks/useTheme';
+import NavigationString from '../../../Constant/NavigationString';
-/* -------------------- UTILS -------------------- */
-interface OfficerKey {
- officersId: number;
- officerPublicKey: string;
- officerType: string;
+interface EnvelopeCExcelPreviewProps {
+ /** The full proceed-to-bid tender data object. */
+ tenderId: any;
+ buttonEnable?: boolean;
}
+const EnvelopeCExcelPreview: React.FC = ({ tenderId, buttonEnable }) => {
+ const navigation = useNavigation();
+ const { colors, spacing } = useTheme();
-interface SaveEnvelopeOfficerDetail {
- officerId: number;
- officerEncryptedKey: string;
- officerType: string;
-}
-
-// Convert ArrayBuffer → Base64
-const arrayBufferToBase64 = (buffer: ArrayBuffer) => {
- let binary = '';
- const bytes = new Uint8Array(buffer);
- for (let i = 0; i < bytes.length; i++) {
- binary += String.fromCharCode(bytes[i]);
- }
- return global.btoa(binary);
-};
-
-
-// Find column dynamically by keywords
-const findColumnKey = (columns: string[], keywords: string[]) =>
- columns.find(col =>
- keywords.some(k => col.toLowerCase().includes(k.toLowerCase()))
- );
-
-
-// Detect TOTAL row (any column contains TOTAL)
-const isTotalRow = (row: any) =>
- Object.values(row).some(v => String(v).toUpperCase() === 'TOTAL');
-
-
-/* -------------------- COMPONENT -------------------- */
-
-
-const EnvelopeCExcelPreview = (props: any) => {
- const tenderData = props;
- console.log("Tender Data", tenderData)
- const token = useSelector((state: any) => state.token.token);
- const navigation = useNavigation() as any;
-
-
- const [data, setData] = useState([]);
- const [columns, setColumns] = useState([]);
- const [loading, setLoading] = useState(false);
-
-
- useEffect(() => {
- loadExcelFromApi();
- }, []);
-
-
- /* -------------------- LOAD EXCEL -------------------- */
-
-
- const loadExcelFromApi = async () => {
- try {
- setLoading(true);
-
-
- console.log('\n📊 LOADING EXCEL FROM API');
- console.log('═══════════════════════════════════════════════════════');
- console.log('🔗 API URL:', `${GET_ENVELOPE_C}${tenderData?.tenderId?.tenderId}`);
- console.log('🆔 Tender ID:', tenderData?.tenderId?.tenderId);
- console.log('🔑 Token:', token.token ? 'Present' : 'Missing');
-
-
- const response = await axios.get(
- `${GET_ENVELOPE_C}${tenderData?.tenderId?.tenderId}`,
- {
- responseType: 'arraybuffer',
- headers: {
- Authentication: `Bearer ${token.token}`,
- },
- }
- );
-
-
- console.log('\n✅ API RESPONSE RECEIVED');
- console.log('───────────────────────────────────────────────────────');
- console.log('📡 Status:', response.status);
- console.log('📡 Status Text:', response.statusText);
- console.log('📦 Response Data Size (KB):', (response.data.byteLength / 1024).toFixed(2));
-
-
- const base64 = arrayBufferToBase64(response.data);
- const workbook = XLSX.read(base64, { type: 'base64' });
- const sheet = workbook.Sheets[workbook.SheetNames[0]];
- const rows: any[] = XLSX.utils.sheet_to_json(sheet, { defval: '' });
-
-
- console.log('📊 PARSED DATA - Total Rows:', rows.length);
-
-
- setColumns(Object.keys(rows[0] || {}));
- setData(rows);
-
-
- console.log('✅ EXCEL LOADED SUCCESSFULLY\n');
-
-
- } catch (e: any) {
- console.log('\n❌ ERROR LOADING EXCEL');
- console.error('Error:', e.message);
- Alert.alert('Error', 'Failed to load Excel: ' + e.message);
- } finally {
- setLoading(false);
- }
+ const openEditor = () => {
+ navigation.navigate(NavigationString.EnvelopeCSpreadsheet, {
+ tender: tenderId,
+ buttonEnable: buttonEnable ?? true,
+ });
};
-
- /* -------------------- UPDATE CELL -------------------- */
-
-
- const updateCell = (rowIndex: number, key: string, value: string) => {
- const updated = [...data];
- const row = updated[rowIndex];
-
-
- if (isTotalRow(row)) return;
-
-
- const qtyKey: any = findColumnKey(columns, ['qty', 'quantity']);
- const rateKey: any = findColumnKey(columns, ['rate', 'price']);
- const valueKey = findColumnKey(columns, ['value', 'amount', 'total']);
-
-
- if (key === qtyKey || key === rateKey) {
- row[key] = value;
-
-
- const qty = Number(row[qtyKey]);
- const rate = Number(row[rateKey]);
-
-
- if (valueKey) {
- row[valueKey] =
- !isNaN(qty) && !isNaN(rate) ? qty * rate : 0;
- }
- }
-
-
- // Recalculate TOTAL
- const totalRowIndex = updated.findIndex(isTotalRow);
- if (totalRowIndex !== -1 && valueKey) {
- updated[totalRowIndex][valueKey] = updated.reduce((sum, r) => {
- if (isTotalRow(r)) return sum;
- const val = Number(r[valueKey]);
- return sum + (isNaN(val) ? 0 : val);
- }, 0);
- }
-
-
- setData(updated);
- };
-
-
- /* -------------------- SAVE & UPLOAD EXCEL -------------------- */
-
-
- const buildOfficerEncryptedKeys = (envelopeKey: string): SaveEnvelopeOfficerDetail[] => {
- const officerKeys: OfficerKey[] = tenderData?.tenderId?.envelopeKeyDetailsResDTO?.departmentOfficerPublicKeys || [];
-
-
- console.log('\n🔐 ENCRYPTING ENVELOPE KEY FOR OFFICERS');
- console.log('═══════════════════════════════════════════════════════');
- console.log('📌 Envelope Key to encrypt:', envelopeKey);
- console.log('👥 Number of officers:', officerKeys.length);
-
-
- const encryptedKeys: SaveEnvelopeOfficerDetail[] = [];
-
-
- for (let i = 0; i < officerKeys.length; i++) {
- const officer = officerKeys[i];
- console.log(`\n🔄 Officer ${i + 1}/${officerKeys.length}`);
- console.log(' ID:', officer.officersId);
- console.log(' Type:', officer.officerType);
-
-
- try {
- const encryptedKey = encryptWithRSA(envelopeKey, officer.officerPublicKey);
-
-
- if (!encryptedKey) {
- console.error(` ❌ FAILED to encrypt key for officer ${officer.officersId}`);
- } else {
- console.log(` ✅ SUCCESS - Encrypted key length: ${encryptedKey.length}`);
- }
-
-
- encryptedKeys.push({
- officerId: officer.officersId,
- officerEncryptedKey: encryptedKey || '',
- officerType: officer.officerType,
- });
- } catch (error) {
- console.error(` ❌ ERROR encrypting for officer ${officer.officersId}:`, error);
- encryptedKeys.push({
- officerId: officer.officersId,
- officerEncryptedKey: '',
- officerType: officer.officerType,
- });
- }
- }
-
-
- console.log('\n✅ ALL OFFICER KEYS ENCRYPTED');
- console.log('═══════════════════════════════════════════════════════\n');
- return encryptedKeys;
- };
-
-
- // ✅ NEW: Encrypt Excel file content (binary) after creation
- const encryptExcelFile = async (filePath: string, encryptionKey: string): Promise => {
- console.log('\n🔐 ENCRYPTING EXCEL FILE CONTENT');
- console.log('═══════════════════════════════════════════════════════');
- console.log('📁 File Path:', filePath);
- console.log('🔑 Encryption Key:', encryptionKey);
-
-
- try {
- // Read the Excel file as Base64
- const fileContent = await RNFS.readFile(filePath, 'base64');
- console.log('📖 File read successfully, Base64 length:', fileContent.length);
-
-
- // Encrypt the entire file content
- console.log('🔒 Encrypting file content...');
- const encryptedContent: any = encrypt(fileContent, encryptionKey);
- console.log('✅ File content encrypted, Encrypted length:', encryptedContent?.length);
-
-
- // Save encrypted content to a new file
- const encryptedFilePath = `${RNFS.DocumentDirectoryPath}/envelopeC_encrypted.xlsx`;
- await RNFS.writeFile(encryptedFilePath, encryptedContent, 'utf8');
- console.log('💾 Encrypted file saved at:', encryptedFilePath);
-
-
- console.log('✅ EXCEL FILE ENCRYPTION COMPLETED');
- console.log('═══════════════════════════════════════════════════════\n');
-
-
- return encryptedFilePath;
-
-
- } catch (error) {
- console.error('❌ ERROR ENCRYPTING EXCEL FILE:', error);
- throw error;
- }
- };
-
-
- const saveUpdatedExcel = async () => {
- try {
- setLoading(true);
-
-
- console.log('\n💾 SAVING UPDATED EXCEL');
- console.log('═══════════════════════════════════════════════════════');
-
-
- const ENCRYPTION_KEY = tenderData?.tenderId?.envelopeKeyDetailsResDTO?.envelopeCGenerateKey || '';
-
- if (!ENCRYPTION_KEY) {
- console.error('❌ ENCRYPTION KEY NOT FOUND');
- Alert.alert('Error', 'Encryption key not available');
- setLoading(false);
- return;
- }
-
-
- console.log('🔑 Encryption Key:', ENCRYPTION_KEY);
-
-
- // Step 1: Create normal Excel workbook with user data (NOT ENCRYPTED YET)
- console.log('\n📝 STEP 1: Creating Excel workbook with user data');
- console.log('───────────────────────────────────────────────────────');
- const worksheet = XLSX.utils.json_to_sheet(data);
- const workbook = XLSX.utils.book_new();
- XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
-
-
- const binary = XLSX.write(workbook, {
- bookType: 'xlsx',
- type: 'binary',
- });
-
-
- const buffer = new Uint8Array(binary.length);
- for (let i = 0; i < binary.length; i++) {
- buffer[i] = binary.charCodeAt(i) & 0xff;
- }
-
-
- // Step 2: Save the plain Excel file
- console.log('\n📝 STEP 2: Saving plain Excel file');
- console.log('───────────────────────────────────────────────────────');
- const plainFilePath = `${RNFS.DocumentDirectoryPath}/envelopeC_plain.xlsx`;
- await RNFS.writeFile(plainFilePath, buffer.toString(), 'ascii');
- console.log('✅ Plain Excel saved at:', plainFilePath);
-
-
- // Step 3: Encrypt the entire Excel file
- console.log('\n📝 STEP 3: Encrypting the Excel file');
- console.log('───────────────────────────────────────────────────────');
- const encryptedFilePath = await encryptExcelFile(plainFilePath, ENCRYPTION_KEY);
- console.log('✅ Encrypted Excel file ready');
-
-
- // Step 4: Build officer encrypted keys
- console.log('\n📝 STEP 4: Building officer encrypted keys');
- console.log('───────────────────────────────────────────────────────');
- const officerEncryptedKeys = buildOfficerEncryptedKeys(ENCRYPTION_KEY);
-
-
- // Step 5: Prepare FormData for upload
- console.log('\n📝 STEP 5: Building FormData');
- console.log('───────────────────────────────────────────────────────');
- const formData = new FormData();
- const requestDto = {
- tenderId: tenderData?.tenderId?.tenderId,
- deptId: tenderData?.tenderId?.deptId,
- typesOfTender: tenderData?.tenderId?.typesOfTender,
- fieldValue: '',
- fieldValueInWord: 'zero',
- deviceType: 'MOBILE',
- fileName: 'envelopeC_encrypted.xlsx',
- saveEnvelopesOfficersDetailsResDTO: officerEncryptedKeys,
- randomKeyGenerated: ENCRYPTION_KEY,
- };
-
-
- formData.append('requestDto', JSON.stringify(requestDto));
-
-
- // Use the ENCRYPTED file for upload
- formData.append('file', {
- uri: `file://${encryptedFilePath}`,
- name: 'envelopeC_encrypted.xlsx',
- type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
- } as any);
-
-
- console.log('📋 Request DTO:', JSON.stringify(requestDto, null, 2));
- console.log('📎 Encrypted File Path:', encryptedFilePath);
-
-
- // Step 6: Upload to server
- console.log('\n📝 STEP 6: Uploading encrypted file to server');
- console.log('───────────────────────────────────────────────────────');
- console.log('FormData prepared:', formData);
-
-
- const response = await axios.post(SUBMIT_ENVELOPE_C_UPLOAD, formData, {
- headers: {
- 'Content-Type': 'multipart/form-data',
- Authentication: `Bearer ${token.token}`,
- },
- });
-
-
- console.log('✅ UPLOAD SUCCESSFUL');
- console.log('Response:', response.data);
- console.log('═══════════════════════════════════════════════════════\n');
-
-
- // Clean up temporary files
- await RNFS.unlink(plainFilePath).catch(e => console.log('Plain file cleanup:', e.message));
- await RNFS.unlink(encryptedFilePath).catch(e => console.log('Encrypted file cleanup:', e.message));
-
-
- Alert.alert('Success', 'Excel uploaded successfully');
- navigation.goBack();
-
-
- } catch (e: any) {
- console.error('❌ Error saving/uploading Excel:', {
- message: e?.message,
- response: e?.response?.data,
- status: e?.response?.status,
- statusText: e?.response?.statusText,
- });
-
-
- Alert.alert('Error', e?.response?.data?.message || 'Save failed');
- } finally {
- setLoading(false);
- }
- };
-
-
- /* -------------------- UI -------------------- */
-
-
- if (loading) {
- return (
-
-
-
- );
- }
-
-
- const qtyKey = findColumnKey(columns, ['qty', 'quantity']);
- const rateKey = findColumnKey(columns, ['rate', 'price']);
- const valueKey = findColumnKey(columns, ['value', 'amount', 'total']);
-
-
return (
-
-
-
- {/* HEADER */}
-
- {columns.map(col => (
- {col}
- ))}
-
-
-
- {/* DATA */}
-
- {data.map((row, rowIndex) => (
-
- {columns.map(key => {
- const editable =
- !isTotalRow(row) && (key === qtyKey || key === rateKey);
-
-
- return (
-
- updateCell(rowIndex, key, text)
- }
- />
- );
- })}
-
- ))}
-
-
-
-
-
- {tenderData?.buttonEnable && (
-
- Save Updated Excel
-
- )}
-
+
+
+
+
+
+
+ Open Editor
+
+
+ Fill the editable cells, totals recalculate automatically. Formulas and locked cells are protected.
+
+
+
+
);
};
+const styles = StyleSheet.create({
+ card: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ borderRadius: 12,
+ borderWidth: 1.5,
+ padding: 14,
+ },
+ iconWrap: {
+ width: 44,
+ height: 44,
+ borderRadius: 12,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+});
export default EnvelopeCExcelPreview;
-/* -------------------- STYLES -------------------- */
-
-
-const styles = StyleSheet.create({
- container: { flex: 1, backgroundColor: '#fff' },
- loader: { flex: 1, justifyContent: 'center', alignItems: 'center' },
- row: { flexDirection: 'row' },
- headerCell: {
- minWidth: 140,
- padding: 10,
- backgroundColor: '#f1f5f9',
- borderWidth: 1,
- fontWeight: '700',
- },
- cell: {
- minWidth: 140,
- padding: 10,
- borderWidth: 1,
- borderColor: '#cbd5e1',
- },
- valueCell: {
- backgroundColor: '#f8fafc',
- },
- totalCell: {
- backgroundColor: '#e5e7eb',
- fontWeight: '700',
- },
- button: {
- margin: 15,
- backgroundColor: colors.primary,
- padding: 14,
- borderRadius: 8,
- },
- buttonText: {
- color: '#fff',
- textAlign: 'center',
- fontWeight: '600',
- },
-});
\ No newline at end of file
diff --git a/src/Screens/Tender/DeviceBinding.tsx b/src/Screens/Tender/DeviceBinding.tsx
index 5fccc33..ec87045 100644
--- a/src/Screens/Tender/DeviceBinding.tsx
+++ b/src/Screens/Tender/DeviceBinding.tsx
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { View, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
-import { InsideHeader } from '../../Component/Index';
+import { InsideHeader } from '../../components';
import AppText from '../../components/AppText';
import { useTheme } from '../../Hooks/useTheme';
import { useNavigation, useRoute } from '@react-navigation/native';
@@ -11,10 +11,9 @@ import { BASE_URL } from '../../Services/ApiCall/ApiUrls';
import Geolocation from 'react-native-geolocation-service';
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
import { Platform } from 'react-native';
-import { clampRGBA } from 'react-native-reanimated/lib/typescript/Colors';
const DeviceBinding = () => {
- const { colors, spacing, typography } = useTheme();
+ const { colors, spacing } = useTheme();
const navigation = useNavigation();
const route = useRoute();
const [loading, setLoading] = useState(false);
diff --git a/src/Screens/Tender/DscBinding.tsx b/src/Screens/Tender/DscBinding.tsx
index 859b7cb..bc886e4 100644
--- a/src/Screens/Tender/DscBinding.tsx
+++ b/src/Screens/Tender/DscBinding.tsx
@@ -1,13 +1,13 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity } from 'react-native';
-import { InsideHeader } from '../../Component/Index';
+import { InsideHeader } from '../../components';
import AppText from '../../components/AppText';
import { useTheme } from '../../Hooks/useTheme';
import { useNavigation, useRoute } from '@react-navigation/native';
import { AlertCircle } from 'lucide-react-native';
const DscBinding = () => {
- const { colors, spacing, typography } = useTheme();
+ const { colors, spacing } = useTheme();
const navigation = useNavigation();
const route = useRoute();
diff --git a/src/Screens/Tender/EnvelopeC/EnvelopeCSpreadsheetScreen.tsx b/src/Screens/Tender/EnvelopeC/EnvelopeCSpreadsheetScreen.tsx
new file mode 100644
index 0000000..8541b8f
--- /dev/null
+++ b/src/Screens/Tender/EnvelopeC/EnvelopeCSpreadsheetScreen.tsx
@@ -0,0 +1,109 @@
+/**
+ * EnvelopeCSpreadsheetScreen — full-screen native BOQ editor for Envelope-C.
+ *
+ * Opens the tender's BOQ template, renders the Excel-like editor (edit only the
+ * originally-editable cells, live recalculation, validation), and submits the
+ * metadata-preserving workbook through the existing Envelope-C API.
+ *
+ * Route params: { tender: , buttonEnable?: boolean }.
+ */
+import React from 'react';
+import { ActivityIndicator, KeyboardAvoidingView, Platform, StyleSheet, TouchableOpacity, View } from 'react-native';
+import { useNavigation, useRoute } from '@react-navigation/native';
+import { FileUp, Send } from 'lucide-react-native';
+
+import InsideHeader from '../../../components/InsideHeader';
+import AppText from '../../../components/AppText';
+import { useTheme } from '../../../Hooks/useTheme';
+import SpreadsheetView from '../../../Spreadsheet/components/SpreadsheetView';
+import useEnvelopeCWorkbook from './useEnvelopeCWorkbook';
+
+const EnvelopeCSpreadsheetScreen: React.FC = () => {
+ const route = useRoute();
+ const navigation = useNavigation();
+ const { colors, spacing } = useTheme();
+
+ const tender = route.params?.tender;
+ const buttonEnable = route.params?.buttonEnable ?? true;
+
+ const { ss, submitting, submit, importFromDevice } = useEnvelopeCWorkbook(tender, () =>
+ navigation.goBack(),
+ );
+
+ const canSubmit = ss.status === 'ready' && !submitting && ss.errorCount === 0;
+
+ return (
+
+
+
+ {/* Keep the grid + footer above the keyboard. Android already resizes the
+ window (adjustResize); iOS needs padding behaviour. */}
+
+
+
+
+
+
+
+
+ {buttonEnable && (
+
+ {submitting ? (
+
+ ) : (
+ <>
+
+
+ {ss.errorCount > 0 ? `Fix ${ss.errorCount} error(s)` : 'Submit'}
+
+ >
+ )}
+
+ )}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ footer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ padding: 12,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ },
+ secondaryBtn: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ borderRadius: 12,
+ borderWidth: 1.5,
+ },
+ primaryBtn: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 8,
+ paddingVertical: 14,
+ borderRadius: 12,
+ },
+});
+
+export default EnvelopeCSpreadsheetScreen;
+
diff --git a/src/Screens/Tender/EnvelopeC/useEnvelopeCWorkbook.ts b/src/Screens/Tender/EnvelopeC/useEnvelopeCWorkbook.ts
new file mode 100644
index 0000000..91551a0
--- /dev/null
+++ b/src/Screens/Tender/EnvelopeC/useEnvelopeCWorkbook.ts
@@ -0,0 +1,192 @@
+/**
+ * useEnvelopeCWorkbook — Envelope-C BOQ integration.
+ *
+ * Implements the mobile-bid encryption contract in MOBILE_BID_DSC_API_SPEC.md:
+ * downloads the (plaintext) BOQ template into the editor, then on submit
+ * 1. exports the metadata-preserving workbook bytes,
+ * 2. AES/ECB encrypts the RAW file bytes with a SHA-1(K)-derived key,
+ * 3. RSA-wraps the envelope key K with each officer's public key,
+ * 4. uploads the raw ciphertext file + `randomKeyGenerated=K` + wrapped keys.
+ *
+ * Steps 2–3 are exactly what makes web rebid/opening able to RSA-unwrap the
+ * key; without them the stored `contractor_enc_key` is a raw key and the DSC
+ * unwrap times out.
+ */
+import { useCallback, useEffect, useState } from 'react';
+import { Alert } from 'react-native';
+import axios from 'axios';
+import { useSelector } from 'react-redux';
+
+import useSpreadsheet from '../../../Spreadsheet/hooks/useSpreadsheet';
+import FileManager from '../../../Spreadsheet/engine/FileManager';
+import {
+ encrypt,
+ encryptFileBytesToBase64,
+ encryptWithRSA,
+ sha256HexOfBytes,
+} from '../../../Services/Encryption/encrypt';
+import { GET_ENVELOPE_C, SUBMIT_ENVELOPE_C_UPLOAD } from '../../../Services/ApiCall/ApiUrls';
+
+interface OfficerKey {
+ officersId: number;
+ officerPublicKey: string;
+ officerType: string;
+}
+
+interface SaveEnvelopeOfficerDetail {
+ officerId: number;
+ officerEncryptedKey: string;
+ officerType: string;
+}
+
+const ENCRYPTED_FILE_NAME = 'envelopeC_encrypted.xlsx';
+
+export const useEnvelopeCWorkbook = (tender: any, onSubmitted?: () => void) => {
+ const ss = useSpreadsheet();
+ const tokenObj = useSelector((state: any) => state.token?.token);
+ const bearer = tokenObj?.token;
+
+ const [submitting, setSubmitting] = useState(false);
+
+ const tenderId = tender?.tenderId;
+ const encryptionKey: string = tender?.envelopeKeyDetailsResDTO?.envelopeCGenerateKey || '';
+
+ /* ------------------------------- load ------------------------------- */
+
+ const loadFromApi = useCallback(async () => {
+ if (!tenderId) {
+ Alert.alert('Error', 'Missing tender id.');
+ return;
+ }
+ try {
+ const response = await axios.get(`${GET_ENVELOPE_C}${tenderId}`, {
+ responseType: 'arraybuffer',
+ headers: { Authentication: `Bearer ${bearer}` },
+ });
+ await ss.loadFromBytes(response.data as ArrayBuffer, `envelopeC_${tenderId}.xlsx`);
+ } catch (e: any) {
+ Alert.alert('Import failed', e?.response?.data?.message || e?.message || 'Could not load the BOQ template.');
+ }
+ // ss identity changes across renders; loadFromBytes is stable.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [tenderId, bearer]);
+
+ useEffect(() => {
+ loadFromApi();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [loadFromApi]);
+
+ /* --------------------------- import from device --------------------- */
+
+ const importFromDevice = useCallback(async () => {
+ try {
+ const picked = await FileManager.pickWorkbook();
+ if (!picked) return;
+ await ss.loadFromBytes(picked.bytes, picked.name);
+ } catch (e: any) {
+ Alert.alert('Import failed', e?.message || 'Could not open the selected file.');
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ /* ------------------------------- submit ----------------------------- */
+
+ const buildOfficerEncryptedKeys = useCallback((): SaveEnvelopeOfficerDetail[] | null => {
+ const officerKeys: OfficerKey[] = tender?.envelopeKeyDetailsResDTO?.departmentOfficerPublicKeys || [];
+ // RSA/ECB/PKCS1-wrap the plaintext envelope key K with each officer's public
+ // key (Base64 SPKI DER → ~344-char Base64 ciphertext), per
+ // MOBILE_BID_DSC_API_SPEC.md. The web DSC applet unwraps it with the
+ // officer's PRIVATE key during rebid/opening — a raw or empty value makes
+ // the bid unrecoverable, so any wrap failure aborts the submission.
+ // (`officersId` -> `officerId` per the API contract.)
+ const wrapped: SaveEnvelopeOfficerDetail[] = [];
+ for (const o of officerKeys) {
+ const officerEncryptedKey = encryptWithRSA(encryptionKey, o.officerPublicKey);
+ if (!officerEncryptedKey) return null;
+ wrapped.push({
+ officerId: o.officersId,
+ officerEncryptedKey,
+ officerType: o.officerType,
+ });
+ }
+ return wrapped;
+ }, [tender, encryptionKey]);
+
+ const submit = useCallback(async () => {
+ // 1) Validation gate — block submission while any editable cell is invalid.
+ const errors = ss.validateAll();
+ if (errors.length > 0) {
+ Alert.alert('Validation failed', `Please fix ${errors.length} highlighted cell(s) before submitting.`);
+ return;
+ }
+ if (!encryptionKey) {
+ // Matches the server's HTTP 400 guard: a mobile bid must be client-
+ // encrypted and echo randomKeyGenerated.
+ Alert.alert('Error', 'Encryption key not available for this tender.');
+ return;
+ }
+
+ setSubmitting(true);
+ let encryptedPath: string | null = null;
+ try {
+ // 2) RSA-wrap the envelope key K for every opening officer + contractor.
+ const officerKeys = buildOfficerEncryptedKeys();
+ if (!officerKeys) {
+ throw new Error('Could not encrypt the envelope key for the opening officers.');
+ }
+
+ // 3) Export the metadata-preserving workbook and AES-encrypt the RAW
+ // bytes with the SHA-1(K) file key; upload as raw ciphertext bytes.
+ const bytes = await ss.exportBytes();
+ const cipherBase64 = encryptFileBytesToBase64(bytes, encryptionKey);
+ if (!cipherBase64) throw new Error('Failed to encrypt the workbook.');
+
+ encryptedPath = await FileManager.writeBase64(ENCRYPTED_FILE_NAME, cipherBase64);
+ const hashValue = sha256HexOfBytes(bytes);
+
+ // 4) Build the request exactly as MOBILE_BID_DSC_API_SPEC.md specifies.
+ const requestDto = {
+ tenderId,
+ deptId: tender?.deptId,
+ typesOfTender: tender?.typesOfTender,
+ deviceType: 'Mobile',
+ randomKeyGenerated: encryptionKey, // plaintext K, echoed back
+ // The financial value lives inside the encrypted BOQ file; the text
+ // fields still travel as AES(text) ciphertext per the contract.
+ fieldValue: '',
+ fieldValueInWord: encrypt('zero', encryptionKey) || '',
+ hashValue,
+ fileName: 'boq.xlsx',
+ saveEnvelopesOfficersDetailsResDTO: officerKeys,
+ };
+
+ const formData = new FormData();
+ formData.append('requestDto', JSON.stringify(requestDto));
+ formData.append('file', {
+ uri: `file://${encryptedPath}`,
+ name: 'boq.xlsx',
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ } as any);
+
+ // 5) Upload.
+ await axios.post(SUBMIT_ENVELOPE_C_UPLOAD, formData, {
+ headers: { 'Content-Type': 'multipart/form-data', Authentication: `Bearer ${bearer}` },
+ });
+
+ Alert.alert('Success', 'Envelope-C BOQ submitted successfully.');
+ onSubmitted?.();
+ } catch (e: any) {
+ Alert.alert('Save failed', e?.response?.data?.message || e?.message || 'Could not submit the workbook.');
+ } finally {
+ if (encryptedPath) await FileManager.remove(encryptedPath);
+ setSubmitting(false);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [ss, encryptionKey, tenderId, tender, bearer, buildOfficerEncryptedKeys, onSubmitted]);
+
+ return { ss, submitting, submit, importFromDevice, reload: loadFromApi };
+};
+
+export default useEnvelopeCWorkbook;
+
+
diff --git a/src/Screens/Tender/LiveTender.tsx b/src/Screens/Tender/LiveTender.tsx
index c111659..120fdf6 100644
--- a/src/Screens/Tender/LiveTender.tsx
+++ b/src/Screens/Tender/LiveTender.tsx
@@ -2,9 +2,9 @@ import React, { useCallback, useState, useEffect } from 'react';
import { View, FlatList, ActivityIndicator, RefreshControl, Text, StyleSheet } from 'react-native';
import Animated, { FadeInDown, FadeOutUp } from 'react-native-reanimated';
import { useNavigation } from '@react-navigation/native';
-import { useFetchLiveAuction, useFetchLiveTender } from '../../Services/ApiCall/Hooks';
-import { SearchInput } from '../../Component/Serach/SeacrhInput';
-import { Skelton } from '../../Component/Index';
+import { useFetchLiveTender } from '../../Services/ApiCall/Hooks';
+import { SearchInput } from '../../components/SearchInput';
+import { Skeleton } from '../../components';
import { useDispatch, useSelector } from 'react-redux';
import { togglePurchaseTender } from '../../Redux/Slices/SelectedPurchaseTender';
import LiveTenderTile from './LiveTenderTile';
@@ -35,6 +35,8 @@ const LiveTender = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
+ // fetchNextPage identity changes each render; gating flags are the real deps.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasNextPage, isFetchingNextPage]);
const renderFooter = useCallback(() => {
@@ -44,11 +46,13 @@ const LiveTender = () => {
);
+ // Theme colors object is recreated per render; footer only depends on the fetching flag.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFetchingNextPage]);
const renderItem = useCallback(
({ item, index }: any) => {
- if (item.tenderStatus === "Cancelled") return null;
+ const isCancelled = item.tenderStatus === "Cancelled";
const isPurchase = SelectedPurchaseList.findIndex((prev: any) => prev.tenderId === item.tenderId)
return {
tenderId={item.tenderId}
tenderDispatchDate={item.purchaseStart}
isPurchased={isPurchase !== -1 ? true : false}
+ isCancelled={isCancelled}
purchaseEnd={item.purchaseEnd}
tenderOfWork={item.departmentName}
tenderFees={item.documentFees}
@@ -65,12 +70,14 @@ const LiveTender = () => {
onDetailsPress={() => navigation.navigate('LiveTenderDetail', { item })}
/>
},
+ // Dispatch/navigation are stable; user?.id changes only with re-login.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
[SelectedPurchaseList]
);
if (isLoading) {
return
-
+
;
}
return (
@@ -126,7 +133,7 @@ const LiveTender = () => {
keyExtractor={(item) => String(item.tenderId)}
renderItem={renderItem}
showsVerticalScrollIndicator={false}
- contentContainerStyle={{ paddingBottom: 50, paddingTop: 10 }}
+ contentContainerStyle={{ paddingBottom: 120, paddingTop: 10 }}
onEndReached={loadMore}
onEndReachedThreshold={0.2}
ListFooterComponent={renderFooter}
diff --git a/src/Screens/Tender/LiveTenderDetail.tsx b/src/Screens/Tender/LiveTenderDetail.tsx
index 89cfbcc..544f5ee 100644
--- a/src/Screens/Tender/LiveTenderDetail.tsx
+++ b/src/Screens/Tender/LiveTenderDetail.tsx
@@ -46,7 +46,7 @@ const Section = ({ title, icon: Icon, children, styles, colors }: any) => (
const LiveTenderDetail = () => {
const route = useRoute();
- const { colors, spacing, shadows, isDarkMode } = useTheme();
+ const { colors, shadows } = useTheme();
const navigation = useNavigation();
const tender = route?.params;
const { data } = useFetchLiveTenderById(tender?.tenderId);
@@ -206,7 +206,7 @@ const LiveTenderDetail = () => {
const { dirs } = ReactNativeBlobUtil.fs;
const path = `${dirs.DownloadDir}/${doc?.fileName}.pdf`;
const downloadUrl = `${GET_DOCID}${doc?.docId}`;
- const response = await ReactNativeBlobUtil.config({
+ await ReactNativeBlobUtil.config({
fileCache: false,
addAndroidDownloads: {
useDownloadManager: true,
diff --git a/src/Screens/Tender/LiveTenderTile.tsx b/src/Screens/Tender/LiveTenderTile.tsx
index c08a848..b155921 100644
--- a/src/Screens/Tender/LiveTenderTile.tsx
+++ b/src/Screens/Tender/LiveTenderTile.tsx
@@ -1,21 +1,19 @@
import React, { memo } from 'react';
-import { View, StyleSheet, TouchableOpacity, Dimensions } from 'react-native';
+import { View, StyleSheet, TouchableOpacity } from 'react-native';
import moment from 'moment';
import { LiveTenderTileProps } from './types/types';
-import { Calendar, Tag, Info, ArrowRight, Clock, MapPin } from 'lucide-react-native';
+import { Calendar, Tag, ArrowRight, Clock } from 'lucide-react-native';
import { useNavigation } from '@react-navigation/native';
import { format } from '../../Helper/DateFormat';
import { useTheme } from '../../Hooks/useTheme';
-import LinearGradient from 'react-native-linear-gradient';
import AppText from '../../components/AppText';
import NavigationString from '../../Constant/NavigationString';
-const { width } = Dimensions.get('window');
-
const LiveTenderTile: React.FC = ({
contractorId,
data,
isPurchased = false,
+ isCancelled = false,
onPurchasePress,
tenderDispatchDate,
tenderOfWork,
@@ -24,7 +22,7 @@ const LiveTenderTile: React.FC = ({
tenderNumber,
}) => {
const navigation: any = useNavigation();
- const { colors, shadows, spacing } = useTheme();
+ const { colors } = useTheme();
const purchaseEnded = moment().isAfter(moment(purchaseEnd, 'YYYY-MM-DD HH:mm:ss'));
@@ -36,23 +34,16 @@ const LiveTenderTile: React.FC = ({
const purchased = contractorIds.includes(String(contractorId));
- const getStatusColor = () => {
- if (isPurchased) return colors.error;
- if (purchaseEnded) return colors.textSecondary;
- if (purchased) return colors.textSecondary;
- return colors.success;
- };
-
- const statusColor = getStatusColor();
-
return (
navigation.navigate('LiveTenderDetail', data)}
style={[styles.container, {
backgroundColor: colors.surface,
shadowColor: colors.shadow,
- borderColor: colors.border
+ borderColor: isCancelled ? colors.error : colors.border,
+ opacity: isCancelled ? 0.6 : 1,
}]}
>
@@ -66,9 +57,17 @@ const LiveTenderTile: React.FC = ({
-
- ₹{tenderFees}
-
+ {isCancelled ? (
+
+
+ Cancelled
+
+
+ ) : (
+
+ ₹{tenderFees}
+
+ )}
{/* Title Section */}
@@ -113,17 +112,19 @@ const LiveTenderTile: React.FC = ({
{/* Actions Section */}
- navigation.navigate('LiveTenderDetail', data)}
- >
-
- View Details
-
-
+ {!isCancelled && (
+ navigation.navigate('LiveTenderDetail', data)}
+ >
+
+ View Details
+
+
+ )}
navigation.navigate(NavigationString.PurchaseTenderList) : onPurchasePress}
style={styles.actionBtnWrapper}
>
@@ -131,7 +132,7 @@ const LiveTenderTile: React.FC = ({
style={[
styles.actionBtn,
{
- backgroundColor: (purchaseEnded || purchased)
+ backgroundColor: (isCancelled || purchaseEnded || purchased)
? colors.textSecondary
: isPurchased
? colors.success // Green for Go to Cart
@@ -140,9 +141,9 @@ const LiveTenderTile: React.FC = ({
]}
>
- {purchaseEnded ? (purchased ? 'Purchased' : 'Closed') : (purchased ? 'Purchased' : isPurchased ? 'GO TO CART' : 'Add to Cart')}
+ {isCancelled ? 'Cancelled' : purchaseEnded ? (purchased ? 'Purchased' : 'Closed') : (purchased ? 'Purchased' : isPurchased ? 'GO TO CART' : 'Add to Cart')}
- {(!purchaseEnded && !purchased) && }
+ {(!isCancelled && !purchaseEnded && !purchased) && }
@@ -187,6 +188,11 @@ const styles = StyleSheet.create({
borderRadius: 20,
gap: 6,
},
+ statusBadge: {
+ paddingVertical: 4,
+ paddingHorizontal: 12,
+ borderRadius: 20,
+ },
title: {
marginBottom: 16,
fontWeight: '700',
diff --git a/src/Screens/Tender/Modal/TenderFullDetail.tsx b/src/Screens/Tender/Modal/TenderFullDetail.tsx
index 2a62fdb..1792a04 100644
--- a/src/Screens/Tender/Modal/TenderFullDetail.tsx
+++ b/src/Screens/Tender/Modal/TenderFullDetail.tsx
@@ -1,4 +1,4 @@
-import React, { memo, useState } from 'react';
+import React, { memo } from 'react';
import {
View,
Text,
@@ -18,7 +18,6 @@ import {
Eye,
Layers,
ArrowLeft,
- Clock,
} from 'lucide-react-native';
import colors from '../../../Constant/Color';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
diff --git a/src/Screens/Tender/ProceedToBidDetail.tsx b/src/Screens/Tender/ProceedToBidDetail.tsx
index 2bf82bf..8b812fa 100644
--- a/src/Screens/Tender/ProceedToBidDetail.tsx
+++ b/src/Screens/Tender/ProceedToBidDetail.tsx
@@ -1,11 +1,11 @@
import { StyleSheet, View, ScrollView, TouchableOpacity, RefreshControl, ActivityIndicator } from 'react-native'
import React, { useCallback } from 'react'
-import { InsideHeader } from '../../Component/Index'
+import { InsideHeader } from '../../components'
import { useFocusEffect, useNavigation, useRoute } from '@react-navigation/native'
import { useFetchTenderById, useProceedToBid } from '../../Services/ApiCall/Hooks'
import { useTheme } from '../../Hooks/useTheme'
import AppText from '../../components/AppText'
-import { CircleDot, Clock, CheckCircle, XCircle, Calendar, FileText, Shield, AlertCircle } from 'lucide-react-native'
+import { CircleDot, CheckCircle, XCircle, Calendar, Shield, AlertCircle } from 'lucide-react-native'
import Animated, { FadeInUp, FadeInDown, FadeIn, SlideInRight, Layout } from 'react-native-reanimated'
import { apiCall } from '../../Axios/Axios'
import NavigationString from '../../Constant/NavigationString'
@@ -21,7 +21,7 @@ const ProceedToBidDetail = () => {
const { colors, spacing, shadows, typography } = useTheme();
const route = useRoute();
const tenderDataId = route.params?.tender;
- const { data, refetch, isRefetching } = useProceedToBid(tenderDataId?.tenderId);
+ const { refetch, isRefetching } = useProceedToBid(tenderDataId?.tenderId);
const { data: tenderResponse, refetch: tenderRefetch }: any = useFetchTenderById(tenderDataId?.tenderId);
const tenderData = tenderResponse?.data;
const [refreshing, setRefreshing] = React.useState(false);
@@ -50,7 +50,7 @@ const ProceedToBidDetail = () => {
navigation.navigate(NavigationString.DscBinding, {
message: "Your DSC has been expired. Please map new DSC from the web screen"
});
- } else if (deviceBinding && deviceVerification === 'PENDING') {
+ } else if (deviceBinding && deviceVerification !== 'PENDING') {
navigation.navigate(NavigationString.DeviceBinding, {
message: "Please approve this device to enable bidding access from the web.",
showBindButton: false
@@ -128,6 +128,8 @@ const ProceedToBidDetail = () => {
useFocusEffect(
useCallback(() => {
tenderRefetch();
+ // Refetch keyed by tender id only; tenderRefetch identity changes each render.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [tenderData?.tenderId])
);
@@ -374,7 +376,7 @@ const ProceedToBidDetail = () => {
entering={FadeInDown.duration(300)}
style={[styles.footer, { borderTopColor: colors.border }]}
>
- {tenderData?.proceedtoBid && !tenderData?.viewBid && tenderData?.biddingStatus && tenderData?.bidWithdrawStatus === false ? (
+ {tenderData?.proceedtoBid && tenderData?.biddingStatus && tenderData?.bidWithdrawStatus === false ? (
{loading ? (
@@ -406,7 +408,7 @@ const ProceedToBidDetail = () => {
export default ProceedToBidDetail
-const makeStyles = (colors: any, spacing: any, shadows: any) => StyleSheet.create({
+const makeStyles = (colors: any, spacing: any, _shadows: any) => StyleSheet.create({
container: {
flex: 1,
padding: spacing.m,
diff --git a/src/Screens/Tender/ProceedToPurTender.tsx b/src/Screens/Tender/ProceedToPurTender.tsx
index 90f5f8b..8afd7a9 100644
--- a/src/Screens/Tender/ProceedToPurTender.tsx
+++ b/src/Screens/Tender/ProceedToPurTender.tsx
@@ -8,18 +8,14 @@ import {
} from 'react-native';
import Animated, { FadeInDown, FadeIn } from 'react-native-reanimated';
import {
- CreditCard,
Calendar,
Hash,
- IndianRupee,
- Percent,
- Server,
Wifi,
WifiOff,
Receipt,
CheckCircle2,
} from 'lucide-react-native';
-import { InsideHeader, Loader } from '../../Component/Index';
+import { InsideHeader } from '../../components';
import { useDispatch, useSelector } from 'react-redux';
import { useOfflineTenderPurchase } from '../../Services/ApiCall/Hooks';
import { StackActions, useNavigation } from '@react-navigation/native';
diff --git a/src/Screens/Tender/PurchaseTenderAllList.tsx b/src/Screens/Tender/PurchaseTenderAllList.tsx
index 3e45860..717c975 100644
--- a/src/Screens/Tender/PurchaseTenderAllList.tsx
+++ b/src/Screens/Tender/PurchaseTenderAllList.tsx
@@ -1,13 +1,13 @@
-import React, { useState, useCallback, memo, useEffect } from 'react';
-import { View, Text, StyleSheet, FlatList, RefreshControl, ActivityIndicator } from 'react-native';
+import React, { useState, useCallback, useEffect } from 'react';
+import { View, Text, FlatList, ActivityIndicator } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import moment from 'moment';
import { useFetchPurchaseTender } from '../../Services/ApiCall/Hooks';
import PurchaseTenderTile from './PurchaseTenderTile';
import { useDispatch } from 'react-redux';
import { setTender } from '../../Redux/Slices/SelectedTender';
-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 colors from '../../Constant/Color';
@@ -59,6 +59,8 @@ const PurchaseTenderAllList = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
+ // fetchNextPage identity changes each render; gating flags are the real deps.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasNextPage, isFetchingNextPage]);
@@ -111,13 +113,13 @@ const PurchaseTenderAllList = () => {
)}
- {isLoading ?
+ {isLoading ?
: String(item.tenderId || item.id)}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 200, paddingHorizontal: 10 }}
- renderItem={({ item, index }) => (
+ renderItem={({ item }) => (
{
diff --git a/src/Screens/Tender/PurchaseTenderList.tsx b/src/Screens/Tender/PurchaseTenderList.tsx
index 01bc305..ae7fa2e 100644
--- a/src/Screens/Tender/PurchaseTenderList.tsx
+++ b/src/Screens/Tender/PurchaseTenderList.tsx
@@ -4,7 +4,6 @@ import {
StyleSheet,
ScrollView,
TouchableOpacity,
- useWindowDimensions,
} from 'react-native';
import Animated, {
FadeIn,
@@ -16,7 +15,7 @@ import Animated, {
withTiming,
} from 'react-native-reanimated';
import { ChevronDown, ChevronRight, Trash2, ShoppingBag, FileText, 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';
@@ -27,11 +26,9 @@ import { useEffect } from 'react';
const PurchaseTenderList = () => {
const Navigation: any = useNavigation();
- const Dispatch = useDispatch();
- const { colors, spacing, shadows, typography } = useTheme();
+ const { colors, spacing, shadows } = useTheme();
const [expandedIndex, setExpandedIndex] = useState(null);
const SelectedPurchaseList = useSelector((state: any) => state.selectPurchaseTender);
- const { width } = useWindowDimensions();
const toggleExpand = (index: number) => {
setExpandedIndex(prev => (prev === index ? null : index));
@@ -41,6 +38,8 @@ const PurchaseTenderList = () => {
if (SelectedPurchaseList.length === 0) {
Navigation.goBack();
}
+ // Navigation identity is stable; the list is the real dependency.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [SelectedPurchaseList]);
const styles = makeStyles(colors, spacing, shadows);
diff --git a/src/Screens/Tender/PurchaseTenderTile.tsx b/src/Screens/Tender/PurchaseTenderTile.tsx
index 6c56755..35fc8a1 100644
--- a/src/Screens/Tender/PurchaseTenderTile.tsx
+++ b/src/Screens/Tender/PurchaseTenderTile.tsx
@@ -1,14 +1,14 @@
import React, { useMemo } from 'react';
-import { View, StyleSheet, TouchableOpacity, Platform } from 'react-native';
+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 { useFetchTenderById } from '../../Services/ApiCall/Hooks';
import { useNavigation } from '@react-navigation/native';
import TenderFullDetail from './Modal/TenderFullDetail';
import AppText from '../../components/AppText';
import { useTheme } from '../../Hooks/useTheme';
-import { Calendar, Wallet, Banknote, ArrowRight, CircleDot, Clock } from 'lucide-react-native';
+import { Calendar, Banknote, ArrowRight, CircleDot, Clock } from 'lucide-react-native';
const PurchaseTenderTile = ({ item, onPressDetails }: any) => {
const { colors, spacing, shadows } = useTheme();
diff --git a/src/Screens/Tender/Tender.tsx b/src/Screens/Tender/Tender.tsx
index e747fd4..12a9cd0 100644
--- a/src/Screens/Tender/Tender.tsx
+++ b/src/Screens/Tender/Tender.tsx
@@ -6,7 +6,7 @@ import {
Animated,
Dimensions,
} from 'react-native';
-import { InsideHeader } from '../../Component/Index';
+import { InsideHeader } from '../../components';
import {
useFetchLiveTender,
useFetchPurchaseTender,
diff --git a/src/Screens/Tender/tenderBidding/TenderBidding.tsx b/src/Screens/Tender/tenderBidding/TenderBidding.tsx
index e61dbcc..1cbf435 100644
--- a/src/Screens/Tender/tenderBidding/TenderBidding.tsx
+++ b/src/Screens/Tender/tenderBidding/TenderBidding.tsx
@@ -1,7 +1,6 @@
import {
ActivityIndicator,
Alert,
- FlatList,
KeyboardAvoidingView,
Platform,
ScrollView,
@@ -11,87 +10,44 @@ import {
View,
} from 'react-native';
import React, { useState } from 'react';
-import Animated, { FadeInUp, FadeInDown, SlideInRight } from 'react-native-reanimated';
+import Animated, { FadeInUp, FadeInDown } from 'react-native-reanimated';
import { useNavigation, useRoute } from '@react-navigation/native';
-import MyInput from '../../../../src/Component/MyInput';
import { pick, types } from '@react-native-documents/picker';
-import { InsideHeader } from '../../../Component/Index';
+import { InsideHeader } from '../../../components';
import { useTheme } from '../../../Hooks/useTheme';
import AppText from '../../../components/AppText';
-import { SUBMIT_ENVELOPE_A, SUBMIT_ENVELOPE_B, SUBMIT_ENVELOPE_FILE_A, SUBMIT_ENVELOPE_FILE_B, SUBMIT_QCBS_AND_LCS } from '../../../Services/ApiCall/ApiUrls';
-import { CheckSquare, Square, FileUp, ChevronRight, AlertCircle } from 'lucide-react-native';
-import { apiCall } from '../../../Axios/Axios';
import EnvelopeCExcelPreview from '../Component/EnvelopeCExcelPreview';
import { useFetchTenderById, useProceedToBid } from '../../../Services/ApiCall/Hooks';
-import { encrypt, encryptWithRSA } from '../../../Services/Encryption/encrypt';
-
-
-type EnvelopeType = 'A' | 'B' | 'C' | 'LCS' | 'QCBS';
-type EnvelopeFormState = {
- A: Record;
- B: Record;
- C: Record;
- LCS: Record;
- QCBS: Record;
-};
-
-
-interface EnvelopeButtonProps {
- title: string;
- onPress: () => void;
-}
-
-
-interface InfoRowProps {
- title: string;
- value: string;
- index?: number;
-}
-
-
-interface OfficerKey {
- officersId: number;
- officerPublicKey: string;
- officerType: string;
-}
-
-
-interface SaveEnvelopeOfficerDetail {
- officerId: number;
- officerEncryptedKey: string;
- officerType: string;
-}
-
+import { logger } from '../../../core/observability/logger';
+import type { EnvelopeFormState, EnvelopeType } from './envelopeTypes';
+import {
+ buildEnvelopePayload,
+ buildQcbsPayload,
+ findEnvelopeFile,
+ validateEnvelope,
+ validateQcbs,
+} from './envelopePayload';
+import { submitEnvelopeData, submitQcbsData, uploadEnvelopeDocument } from './envelopeSubmission';
+import EnvelopeButton from './components/EnvelopeButton';
+import EnvelopeFieldItem from './components/EnvelopeFieldItem';
+import QcbsQuestionCard from './components/QcbsQuestionCard';
+import TermsRow from './components/TermsRow';
+/**
+ * Tender envelope bidding screen. This component owns UI state and
+ * presentation ONLY — crypto lives in envelopeCrypto.ts, payload building and
+ * validation in envelopePayload.ts, and API calls in envelopeSubmission.ts.
+ */
const TenderBidding = () => {
const route = useRoute();
const navigation = useNavigation();
- const { colors, spacing, shadows, typography } = useTheme();
+ const { colors, spacing, shadows } = useTheme();
- const tenderId = useState(route.params?.tenderId || null);
-
- const { data: tenderResponse, refetch: tenderRefetch }: any = useFetchTenderById(route.params?.tenderId);
+ const { data: tenderResponse }: any = useFetchTenderById(route.params?.tenderId);
const bidData = tenderResponse?.data;
- const { data: tender, refetch } = useProceedToBid(route.params?.tenderId);
+ const { data: tender }: any = useProceedToBid(route.params?.tenderId);
const tenderData = tender?.data;
-
- console.log('═══════════════════════════════════════════════════════');
- console.log('📦 TENDER DATA RECEIVED');
- console.log('═══════════════════════════════════════════════════════');
- console.log('Tender ID:', tenderData?.tenderId);
- console.log('Tender Number:', tenderData?.tenderNumber);
- console.log('Encryption Keys Available:', !!tenderData?.envelopeKeyDetailsResDTO);
- if (tenderData?.envelopeKeyDetailsResDTO) {
- console.log('🔑 Envelope A Key:', tenderData.envelopeKeyDetailsResDTO.envelopeAGenerateKey);
- console.log('🔑 Envelope B Key:', tenderData.envelopeKeyDetailsResDTO.envelopeBGenerateKey);
- console.log('🔑 Envelope C Key:', tenderData.envelopeKeyDetailsResDTO.envelopeCGenerateKey);
- console.log('🔑 Envelope LCS /QCBS Key:', tenderData?.envelopeKeyDetailsResDTO?.envelopeQcbsAndLcs);
- console.log('👥 Officers Count:', tenderData.envelopeKeyDetailsResDTO.departmentOfficerPublicKeys?.length || 0);
- }
- console.log('═══════════════════════════════════════════════════════\n');
-
-
const [activeEnvelope, setActiveEnvelope] = useState('A');
const [envelopForm, setEnvelopForm] = useState({
A: {},
@@ -103,53 +59,18 @@ const TenderBidding = () => {
const [loading, setLoading] = useState(false);
const [check, setCheck] = useState(false);
- // State for LCS/QCBS selected options - stores {questionId: selectedOption}
+ // LCS/QCBS selected options — {questionId: selectedOption}
const [selectedOptions, setSelectedOptions] = useState>({});
- const styles = makeStyles(colors, spacing, shadows);
-
- // ── Sub-components ──────────────────────────────────────────────
-
- const InfoRow = ({ title, value, index = 0 }: InfoRowProps) => (
-
- {title}
- {value || '-'}
-
- );
-
-
- const EnvelopeButton = ({ title, envelopeType, onPress }: EnvelopeButtonProps & { envelopeType: EnvelopeType }) => {
- const isActive = activeEnvelope === envelopeType;
- return (
-
-
- {title}
-
-
- );
- };
+ const styles = makeStyles(spacing);
+ // ── Form state handlers ─────────────────────────────────────────
const handleEnvelopeChange = (
envelopeType: EnvelopeType,
sequenceId: number,
- value: any
+ value: any,
) => {
- console.log(`📝 Form Update - Envelope ${envelopeType}, Field ${sequenceId}:`, typeof value === 'string' ? value : 'File');
setEnvelopForm(prev => ({
...prev,
[envelopeType]: {
@@ -159,208 +80,37 @@ const TenderBidding = () => {
}));
};
- // Handle option selection for LCS/QCBS
const handleOptionSelect = (questionId: number, option: any) => {
- console.log(`✅ Option selected for Question ${questionId}:`, option);
setSelectedOptions(prev => ({
...prev,
- [questionId]: option
+ [questionId]: option,
}));
};
-
- const pickPdf = async (
- envelopeType: EnvelopeType,
- sequenceId: number
- ) => {
+ const pickPdf = async (envelopeType: EnvelopeType, sequenceId: number) => {
+ logger.info('envelope', `[pick] opening PDF picker for envelope ${envelopeType}`, { sequenceId });
try {
- console.log(`📄 Opening document picker for Envelope ${envelopeType}, Field ${sequenceId}`);
const res = await pick({
type: types.pdf,
allowMultiSelection: false,
});
-
-
- const file = res[0];
- console.log('✅ File selected:', file.name, `(${(file.size || 0) / 1024} KB)`);
- handleEnvelopeChange(envelopeType, sequenceId, file);
+ logger.info('envelope', `[pick] document selected for envelope ${envelopeType}`, {
+ sequenceId,
+ fileName: res[0]?.name,
+ fileType: res[0]?.type,
+ fileSize: res[0]?.size,
+ });
+ handleEnvelopeChange(envelopeType, sequenceId, res[0]);
} catch (err: any) {
if (err?.code === 'DOCUMENT_PICKER_CANCELED') {
- console.log('❌ Document picker cancelled');
+ logger.debug('envelope', `[pick] cancelled by user for envelope ${envelopeType}`, { sequenceId });
return;
}
- console.error('❌ Document picker error:', err);
+ logger.error('envelope', 'document picker failed', { error: String(err) });
}
};
-
- const renderEnvelopeItem = ({
- item,
- envelopeType,
- }: {
- item: any;
- envelopeType: EnvelopeType;
- }) => {
- const sequenceId = item?.[`tenderEnvelope${envelopeType}SequnceId`];
-
-
- if (!sequenceId) {
- return null;
- }
-
-
- const labelName = item?.[`labelName${envelopeType}`] || '';
- const labelType = item?.[`labelType${envelopeType}`] || '';
-
-
- const showInput =
- activeEnvelope === envelopeType &&
- !bidData?.[`envelope${envelopeType}SubmittStatus`];
-
-
- return (
-
-
- {/* Label */}
-
-
- {labelName || '-'}
-
- {item?.[`labelMandatory${activeEnvelope}`] && (
-
- *
-
- )}
-
-
- {/* Input directly below the label, inside the same card */}
- {showInput ? (
-
- {labelType === '1' && (
-
- handleEnvelopeChange(envelopeType, sequenceId, text)
- }
- keyboardType="numeric"
- />
- )}
-
- {labelType === '2' && (
-
- handleEnvelopeChange(envelopeType, sequenceId, text)
- }
- keyboardType="default"
- />
- )}
-
- {labelType === '3' && (
- <>
- pickPdf(activeEnvelope, sequenceId)}
- activeOpacity={0.7}
- >
-
-
- {envelopForm?.[activeEnvelope]?.[sequenceId]
- ? 'Change Document'
- : 'Upload Document'}
-
-
-
- {envelopForm?.[activeEnvelope]?.[sequenceId] && (
-
- 📄 {(envelopForm[activeEnvelope][sequenceId] as any)?.name}
-
- )}
- >
- )}
-
- {labelType === '4' && (
-
- handleEnvelopeChange(envelopeType, sequenceId, text)
- }
- keyboardType="numeric"
- />
- )}
-
- ) : (
-
- Envelope {activeEnvelope} unavailable
-
- )}
-
-
- );
- };
-
- // Render LCS/QCBS Question with selectable options
- const renderQcbsQuestion = ({ item }: { item: any }) => {
- const questionId = item.questionId;
- const selectedOption = selectedOptions[questionId];
-
- return (
-
-
- {item.question}
-
-
- {item.parameters?.map((param: any, index: number) => {
- const isSelected = selectedOption?.option === param.option;
-
- return (
-
- handleOptionSelect(questionId, param)}
- activeOpacity={0.7}
- >
-
-
- {isSelected && }
-
-
-
- {param.option}
-
-
-
-
-
- );
- })}
-
- );
- };
-
-
- const hasC = activeEnvelope === 'C' && !tenderData?.envelopeCSubmittStatus;
-
+ // ── Derived state ───────────────────────────────────────────────
const hasEnvelopes = () => {
const hasA = tenderData?.tenderEnvelopeAList?.length > 0;
@@ -368,560 +118,167 @@ const TenderBidding = () => {
return hasA || hasB;
};
+ const envelopeValid = validateEnvelope(tenderData, activeEnvelope, envelopForm[activeEnvelope]);
- const digitToWordMap: Record = {
- '0': 'Zero',
- '1': 'One',
- '2': 'Two',
- '3': 'Three',
- '4': 'Four',
- '5': 'Five',
- '6': 'Six',
- '7': 'Seven',
- '8': 'Eight',
- '9': 'Nine',
- };
+ // ── Submission (A and B share one flow; only endpoints differ) ──
+ const handleSubmitEnvelope = async (envelopeType: 'A' | 'B') => {
+ logger.info('envelope', `[submit] ===== envelope ${envelopeType} submission started =====`, {
+ tenderId: tenderData?.tenderId,
+ });
- const convertDigitsToWords = (value: string): string => {
- const words = value
- .split('')
- .map(digit => digitToWordMap[digit] || '')
- .join(' ') || value;
- console.log(`🔤 Converted "${value}" to words: "${words}"`);
- return words;
- };
+ logger.debug('envelope', `[submit] step 1/5: validating envelope ${envelopeType}`);
+ if (!validateEnvelope(tenderData, envelopeType, envelopForm[envelopeType])) {
+ logger.warn('envelope', `[submit] envelope ${envelopeType} validation failed — aborting`);
+ Alert.alert('Validation Error', 'Please fill all mandatory fields');
+ return;
+ }
-
- const buildOfficerEncryptedKeys = (envelopeKey: string): SaveEnvelopeOfficerDetail[] => {
- const officerKeys: OfficerKey[] = tenderData?.envelopeKeyDetailsResDTO?.departmentOfficerPublicKeys || [];
-
-
- console.log('\n🔐 ENCRYPTING ENVELOPE KEY FOR OFFICERS');
- console.log('═══════════════════════════════════════════════════════');
- console.log('📌 Envelope Key to encrypt:', envelopeKey);
- console.log('👥 Number of officers:', officerKeys.length);
-
-
- const encryptedKeys: SaveEnvelopeOfficerDetail[] = [];
-
-
- for (let i = 0; i < officerKeys.length; i++) {
- const officer = officerKeys[i];
- console.log(`\n🔄 Officer ${i + 1}/${officerKeys.length}`);
- console.log(' ID:', officer.officersId);
- console.log(' Type:', officer.officerType);
- console.log(' Public Key Length:', officer.officerPublicKey?.length || 0);
-
-
- try {
- const encryptedKey = encryptWithRSA(envelopeKey, officer.officerPublicKey);
-
-
- if (!encryptedKey) {
- console.error(` ❌ FAILED to encrypt key for officer ${officer.officersId}`);
- } else {
- console.log(` ✅ SUCCESS - Encrypted key length: ${encryptedKey.length}`);
- console.log(` Encrypted Key Preview: ${encryptedKey.substring(0, 50)}...`);
- }
-
-
- encryptedKeys.push({
- officerId: officer.officersId,
- officerEncryptedKey: encryptedKey || '',
- officerType: officer.officerType,
- });
- } catch (error) {
- console.error(` ❌ ERROR encrypting for officer ${officer.officersId}:`, error);
- encryptedKeys.push({
- officerId: officer.officersId,
- officerEncryptedKey: '',
- officerType: officer.officerType,
- });
+ setLoading(true);
+ try {
+ logger.debug('envelope', `[submit] step 2/5: building payload for envelope ${envelopeType}`);
+ const payload = buildEnvelopePayload(tenderData, envelopeType, envelopForm[envelopeType]);
+ if (!payload) {
+ logger.error('envelope', `[submit] envelope ${envelopeType} payload build failed (no key) — aborting`);
+ Alert.alert('Error', 'Encryption key not available');
+ return;
}
- }
-
- console.log('\n✅ ALL OFFICER KEYS ENCRYPTED');
- console.log('═══════════════════════════════════════════════════════\n');
- return encryptedKeys;
- };
-
-
- const buildEnvelopePayload = (needCData: Boolean = false) => {
- console.log('\n🏗️ BUILDING ENVELOPE PAYLOAD');
- console.log('═══════════════════════════════════════════════════════');
- console.log('📋 Active Envelope:', activeEnvelope);
-
-
- const list = tenderData?.[`tenderEnvelope${activeEnvelope}List`] || [];
- const formValues = envelopForm?.[activeEnvelope] || {};
-
-
- console.log('📄 Form Fields:', Object.keys(formValues).length);
-
-
- const ENCRYPTION_KEY = tenderData?.envelopeKeyDetailsResDTO?.[`envelope${activeEnvelope}GenerateKey`] || '';
-
-
- if (!ENCRYPTION_KEY) {
- console.error('❌ ENCRYPTION KEY NOT FOUND FOR ENVELOPE', activeEnvelope);
- Alert.alert('Error', 'Encryption key not available');
- return null;
- }
-
-
- console.log(`🔑 Using encryption key for Envelope ${activeEnvelope}:`, ENCRYPTION_KEY);
-
-
- console.log('\n🔐 ENCRYPTING FIELD DATA');
- console.log(`─────────────────────────────────────────────────────── ${list}`);
-
- const combinedData = list
- .filter((item: any) => {
- const labelType = item[`labelType${activeEnvelope}`];
- return needCData ? labelType === '3' : labelType !== '3';
- })
- .map((item: any, index: number) => {
- const seqId = item[`tenderEnvelope${activeEnvelope}SequnceId`];
- const labelType = item[`labelType${activeEnvelope}`];
- const labelName = item[`labelName${activeEnvelope}`];
- const isMandatory = item[`labelMandatory${activeEnvelope}`];
- const rawValue = formValues[seqId];
-
- console.log(`\n Field ${index + 1}: ${labelName} (ID: ${seqId}, Type: ${labelType})`);
-
- if (labelType === '3') {
- console.log(` 📎 File Field detected`);
- return {
- [`tenderEnvelope${activeEnvelope}SequnceId`]: seqId,
- [`labelName${activeEnvelope}`]: labelName,
- [`labelType${activeEnvelope}`]: labelType,
- [`labelMandatory${activeEnvelope}`]: isMandatory,
- };
- } else {
- console.log(` Raw Value: "${rawValue}"`);
- const encryptedValue = rawValue ? encrypt(String(rawValue), ENCRYPTION_KEY) : '';
- const valueInWords = rawValue ? convertDigitsToWords(String(rawValue)) : '';
- const encryptedValueInWord = valueInWords ? encrypt(valueInWords, ENCRYPTION_KEY) : '';
-
- console.log(` Encrypted Value: ${encryptedValue ? encryptedValue.substring(0, 30) + '...' : 'N/A'}`);
-
- return {
- [`tenderEnvelope${activeEnvelope}SequnceId`]: seqId,
- [`labelName${activeEnvelope}`]: labelName,
- [`labelType${activeEnvelope}`]: labelType,
- [`labelMandatory${activeEnvelope}`]: isMandatory,
- fieldValue: encryptedValue,
- fieldValueInWord: encryptedValueInWord,
- };
+ logger.debug('envelope', `[submit] step 3/5: checking for document to upload (envelope ${envelopeType})`);
+ const file = findEnvelopeFile(tenderData, envelopeType, envelopForm[envelopeType]);
+ if (file) {
+ logger.info('envelope', `[submit] envelope ${envelopeType} has a document — uploading before data submit`);
+ const filePayload = buildEnvelopePayload(tenderData, envelopeType, envelopForm[envelopeType], true);
+ // Final file-upload payload (requestDto). The redactor masks the
+ // symmetric key + wrapped officer keys; the rest is shown verbatim.
+ logger.debug('envelope', `[submit] envelope ${envelopeType} final file-upload requestDto`, filePayload);
+ const uploadRes = await uploadEnvelopeDocument(envelopeType, filePayload, file);
+ if (uploadRes?.statusCode !== 200) {
+ logger.warn('envelope', `[submit] envelope ${envelopeType} document upload rejected — aborting`, {
+ statusCode: uploadRes?.statusCode,
+ });
+ Alert.alert('Error', 'File upload failed. Please try again.');
+ return;
}
+ logger.info('envelope', `[submit] envelope ${envelopeType} document upload succeeded`);
+ } else {
+ logger.debug('envelope', `[submit] envelope ${envelopeType} has no document — skipping upload`);
+ }
+
+ logger.debug('envelope', `[submit] step 4/5: submitting envelope ${envelopeType} data`);
+ // Final data payload sent to the server (key + wrapped keys masked).
+ logger.debug('envelope', `[submit] envelope ${envelopeType} final data payload`, payload);
+ const res: any = await submitEnvelopeData(envelopeType, payload);
+ logger.debug('envelope', `[submit] step 5/5: handling envelope ${envelopeType} response`, {
+ statusCode: res?.statusCode,
});
-
- console.log('\n✅ Field processing completed:', combinedData.length, 'fields');
-
-
- const officerEncryptedKeys = buildOfficerEncryptedKeys(ENCRYPTION_KEY);
-
-
- const payload = {
- deptId: tenderData?.deptId,
- tenderId: tenderData?.tenderId,
-
-
- [`tenderEnvelope${activeEnvelope}NameId`]:
- tenderData?.[`tenderEnvelope${activeEnvelope}List`]?.[0]?.[
- `tenderEnvelope${activeEnvelope}NameId`
- ],
- [`envelope${activeEnvelope}TemplateName`]:
- tenderData?.[`tenderEnvelope${activeEnvelope}List`]?.[0]?.[
- `envelope${activeEnvelope}TemplateName`
- ],
-
-
- [`save${activeEnvelope}Data`]: combinedData,
-
- deviceType: 'Mobile',
-
- randomKeyGenerated: ENCRYPTION_KEY,
- saveEnvelopesOfficersDetailsResDTO: officerEncryptedKeys,
- };
-
-
- console.log('\n📦 FINAL PAYLOAD STRUCTURE');
- console.log('═══════════════════════════════════════════════════════');
- console.log('Dept ID:', payload.deptId);
- console.log('Tender ID:', payload.tenderId);
- console.log('Envelope Name ID:', payload[`tenderEnvelope${activeEnvelope}NameId`]);
- console.log('Template Name:', payload[`envelope${activeEnvelope}TemplateName`]);
- console.log('Save Data Count:', payload[`save${activeEnvelope}Data`]?.length || 0);
- console.log('Device Type:', payload.deviceType);
- console.log('Officers with Encrypted Keys:', officerEncryptedKeys.length);
- console.log('\n📋 COMPLETE PAYLOAD:');
- console.log(JSON.stringify(payload, null, 2));
- console.log('═══════════════════════════════════════════════════════\n');
-
-
- return payload;
- };
-
- // Build payload for LCS/QCBS submission
- const buildQcbsPayload = () => {
- console.log('\n🏗️ BUILDING QCBS/LCS PAYLOAD');
- console.log('═══════════════════════════════════════════════════════');
-
- const questions = tenderData?.tenderEnvelopeQcbsList?.questions || [];
-
- // Get the encryption key for LCS/QCBS
- const ENCRYPTION_KEY = tenderData?.envelopeKeyDetailsResDTO?.envelopeQcbsAndLcs || '';
-
- if (!ENCRYPTION_KEY) {
- console.error('❌ ENCRYPTION KEY NOT FOUND FOR', activeEnvelope);
- Alert.alert('Error', 'Encryption key not available');
- return null;
- }
-
- console.log(`🔑 Using encryption key for ${activeEnvelope}:`, ENCRYPTION_KEY);
-
- // Build officer encrypted keys
- const officerEncryptedKeys = buildOfficerEncryptedKeys(ENCRYPTION_KEY);
-
- // Map questions with ONLY the selected parameter
- const questionPayload = questions.map((question: any) => {
- const selected = selectedOptions[question.questionId];
-
- // Only include the selected parameter in the array
- const parameters = selected ? [{
- option: encrypt(String(selected.option), ENCRYPTION_KEY),
- mark: selected.mark,
- strUser: encrypt(String(selected.strUser || "1"), ENCRYPTION_KEY)
- }] : [];
-
- return {
- questionId: question.questionId,
- question: question.question,
- parameters: parameters // Only contains the selected option
- };
- });
-
- const payload = {
- tenderId: tenderData?.tenderId,
- templateId: tenderData?.tenderEnvelopeQcbsList?.templateId,
- qcbsTemplateName: tenderData?.tenderEnvelopeQcbsList?.qcbsTemplateName || "string",
- questions: questionPayload,
- deviceType: 'Mobile',
- saveEnvelopesOfficersDetailsResDTO: officerEncryptedKeys,
- };
-
- console.log('📋 QCBS/LCS PAYLOAD:', JSON.stringify(payload, null, 2));
- console.log('═══════════════════════════════════════════════════════\n');
-
- return payload;
- };
-
-
-
- const validateEnvelope = () => {
- const list = tenderData?.[`tenderEnvelope${activeEnvelope}List`] || [];
- const formValues = envelopForm[activeEnvelope];
-
-
- const missing = list.some((item: any) => {
- const seqId = item[`tenderEnvelope${activeEnvelope}SequnceId`];
- const isMandatory = item[`labelMandatory${activeEnvelope}`];
- const value = formValues?.[seqId];
-
-
- if (!isMandatory) return false;
- if (item[`labelType${activeEnvelope}`] === '3') {
- return !value;
- }
- return !value || value.toString().trim() === '';
- });
-
-
- const isValid = !missing;
- console.log(`✔️ Validation Result for Envelope ${activeEnvelope}:`, isValid ? 'PASSED' : 'FAILED');
- return isValid;
- };
-
- // Validate QCBS/LCS - at least one question must be answered
- const validateQcbs = () => {
- const questions = tenderData?.tenderEnvelopeQcbsList?.questions || [];
- const hasSelection = questions.some((q: any) => selectedOptions[q.questionId]);
- console.log(`✔️ Validation Result for ${activeEnvelope}:`, hasSelection ? 'PASSED' : 'FAILED');
- return hasSelection;
- };
-
-
- const handleSubmitEnvelopeA = async () => {
- console.log('\n🚀 SUBMIT ENVELOPE A INITIATED');
- console.log('═══════════════════════════════════════════════════════');
-
-
- if (!validateEnvelope()) {
- console.log('❌ Validation failed - Missing mandatory fields');
- Alert.alert('Validation Error', 'Please fill all mandatory fields');
- return;
- }
-
-
- setLoading(true);
- console.log('⏳ Loading state: TRUE');
-
-
- const payload = buildEnvelopePayload();
- console.log('📦 Payload generated: ', payload);
-
- const envelopeList = tenderData?.[`tenderEnvelope${activeEnvelope}List`] || [];
- const fileField = envelopeList.find((item: any) => item[`labelType${activeEnvelope}`] === '3');
- const fileSeqId = fileField ? fileField[`tenderEnvelope${activeEnvelope}SequnceId`] : null;
- const hasFile = fileSeqId ? !!envelopForm?.[activeEnvelope]?.[fileSeqId] : false;
-
- if (hasFile) {
- console.log('📎 File upload required - Uploading file first...');
- const result = await handleSubmitEnvelopeFile(SUBMIT_ENVELOPE_FILE_A);
-
-
- if (result && result?.statusCode === 200) {
- console.log('✅ File upload successful');
- try {
- console.log('📤 Submitting envelope A data...');
- const res = await apiCall('post', SUBMIT_ENVELOPE_A, payload) as any;
-
-
- if (res?.statusCode === 200) {
- console.log('✅ Envelope A submitted successfully');
- navigation.goBack();
- ToastAndroid.show(res.message, ToastAndroid.SHORT);
- } else {
- console.log('⚠️ Unexpected response:', res?.statusCode);
- }
- } catch (error) {
- console.error('❌ Error submitting envelope A:', error);
- } finally {
- setLoading(false);
- console.log('⏳ Loading state: FALSE');
- }
+ if (res?.statusCode === 200) {
+ logger.info('envelope', `[submit] ===== envelope ${envelopeType} submitted successfully =====`);
+ navigation.goBack();
+ ToastAndroid.show(res.message, ToastAndroid.SHORT);
} else {
- console.log('❌ File upload failed');
- setLoading(false);
- Alert.alert('Error', 'File upload failed. Please try again.');
- }
- } else {
- try {
- console.log('📤 Submitting envelope A data (no file)...');
- const res = await apiCall('post', SUBMIT_ENVELOPE_A, payload) as any;
- console.log('✅ Envelope A submission response:', res);
-
- if (res?.statusCode === 200) {
- console.log('✅ Envelope A submitted successfully');
- navigation.goBack();
- ToastAndroid.show(res.message, ToastAndroid.SHORT);
- } else {
- console.log('⚠️ Unexpected response:', res?.statusCode);
- }
- } catch (error) {
- console.error('❌ Error submitting envelope A:', error);
- } finally {
- setLoading(false);
- console.log('⏳ Loading state: FALSE');
+ logger.warn('envelope', `[submit] envelope ${envelopeType} submission rejected by server`, {
+ statusCode: res?.statusCode,
+ });
+ Alert.alert('Error', res?.message || 'Submission failed');
}
+ } catch (error) {
+ logger.error('envelope', `submitting envelope ${envelopeType} failed`, { error: String(error) });
+ Alert.alert('Error', 'An error occurred during submission');
+ } finally {
+ setLoading(false);
+ logger.debug('envelope', `[submit] envelope ${envelopeType} flow finished (loading cleared)`);
}
- console.log('═══════════════════════════════════════════════════════\n');
};
-
- const handleSubmitEnvelopeB = async () => {
- console.log('\n🚀 SUBMIT ENVELOPE B INITIATED');
- console.log('═══════════════════════════════════════════════════════');
-
-
- if (!validateEnvelope()) {
- console.log('❌ Validation failed - Missing mandatory fields');
- Alert.alert('Validation Error', 'Please fill all mandatory fields');
- return;
- }
-
-
- setLoading(true);
- console.log('⏳ Loading state: TRUE');
-
-
- const payload = buildEnvelopePayload();
- console.log(`📦 Payload generated: ${JSON.stringify(payload)}`);
-
- const envelopeListB = tenderData?.[`tenderEnvelope${activeEnvelope}List`] || [];
- const fileFieldB = envelopeListB.find((item: any) => item[`labelType${activeEnvelope}`] === '3');
- const fileSeqIdB = fileFieldB ? fileFieldB[`tenderEnvelope${activeEnvelope}SequnceId`] : null;
- const hasFileB = fileSeqIdB ? !!envelopForm?.[activeEnvelope]?.[fileSeqIdB] : false;
-
-
- if (hasFileB) {
- console.log('📎 File upload required - Uploading file first...');
- const result = await handleSubmitEnvelopeFile(SUBMIT_ENVELOPE_FILE_B);
-
-
- if (result && result?.statusCode === 200) {
- console.log('✅ File upload successful');
- try {
- console.log('📤 Submitting envelope B data...');
- const res = await apiCall('post', SUBMIT_ENVELOPE_B, payload) as any;
-
-
- if (res?.statusCode === 200) {
- console.log('✅ Envelope B submitted successfully');
- navigation.goBack();
- ToastAndroid.show(res.message, ToastAndroid.SHORT);
- } else {
- console.log('⚠️ Unexpected response:', res?.statusCode);
- }
- } catch (error) {
- console.error('❌ Error submitting envelope B:', error);
- } finally {
- setLoading(false);
- console.log('⏳ Loading state: FALSE');
- }
- } else {
- console.log('❌ File upload failed');
- setLoading(false);
- Alert.alert('Error', 'File upload failed. Please try again.');
- }
- } else {
- try {
- console.log('📤 Submitting envelope B data (no file)...');
- const res = await apiCall('post', SUBMIT_ENVELOPE_B, payload) as any;
-
-
- if (res?.statusCode === 200) {
- console.log('✅ Envelope B submitted successfully');
- navigation.goBack();
- ToastAndroid.show(res.message, ToastAndroid.SHORT);
- } else {
- console.log('⚠️ Unexpected response:', res?.statusCode);
- }
- } catch (error) {
- console.error('❌ Error submitting envelope B:', error);
- } finally {
- setLoading(false);
- console.log('⏳ Loading state: FALSE');
- }
- }
- console.log('═══════════════════════════════════════════════════════\n');
- };
-
- // Handle LCS/QCBS submission
const handleSubmitQcbs = async () => {
- console.log(`\n🚀 SUBMIT ${activeEnvelope} INITIATED`);
- console.log('═══════════════════════════════════════════════════════');
+ logger.info('envelope', `[submit] ===== ${activeEnvelope} submission started =====`, {
+ tenderId: tenderData?.tenderId,
+ });
- if (!validateQcbs()) {
- console.log('❌ Validation failed - No option selected');
+ logger.debug('envelope', `[submit] step 1/4: validating ${activeEnvelope} answers`);
+ if (!validateQcbs(tenderData, selectedOptions)) {
+ logger.warn('envelope', `[submit] ${activeEnvelope} validation failed (no option selected) — aborting`);
ToastAndroid.show('Validation Error: Please select at least one option', ToastAndroid.SHORT);
return;
}
-
+ logger.debug('envelope', `[submit] step 2/4: checking terms & conditions for ${activeEnvelope}`);
if (!check) {
- console.log('⚠️ Terms & Conditions not checked');
+ logger.warn('envelope', `[submit] ${activeEnvelope} terms & conditions not accepted — aborting`);
ToastAndroid.show('Please check terms & conditions', ToastAndroid.SHORT);
return;
}
setLoading(true);
- console.log('⏳ Loading state: TRUE');
-
- const payload = buildQcbsPayload();
-
try {
+ logger.debug('envelope', `[submit] step 3/4: building ${activeEnvelope} payload`);
+ const payload = buildQcbsPayload(tenderData, selectedOptions);
+ if (!payload) {
+ logger.error('envelope', `[submit] ${activeEnvelope} payload build failed (no key) — aborting`);
+ Alert.alert('Error', 'Encryption key not available');
+ return;
+ }
- console.log('📤 Submitting to:', SUBMIT_QCBS_AND_LCS);
-
- const res = await apiCall('post', SUBMIT_QCBS_AND_LCS, payload) as any;
-
+ logger.debug('envelope', `[submit] step 4/4: submitting ${activeEnvelope} data`);
+ // Final LCS/QCBS payload sent to the server (wrapped officer keys masked).
+ logger.debug('envelope', `[submit] ${activeEnvelope} final payload`, payload);
+ const res: any = await submitQcbsData(payload);
+ logger.debug('envelope', `[submit] handling ${activeEnvelope} response`, {
+ statusCode: res?.statusCode,
+ });
if (res?.statusCode === 200) {
- console.log(`✅ ${activeEnvelope} submitted successfully`);
+ logger.info('envelope', `[submit] ===== ${activeEnvelope} submitted successfully =====`);
navigation.goBack();
ToastAndroid.show(res.message || `${activeEnvelope} submitted successfully`, ToastAndroid.SHORT);
} else {
- console.log('⚠️ Unexpected response:', res?.statusCode);
+ logger.warn('envelope', `[submit] ${activeEnvelope} submission rejected by server`, {
+ statusCode: res?.statusCode,
+ });
Alert.alert('Error', res?.message || 'Submission failed');
}
} catch (error) {
- console.error(`❌ Error submitting ${activeEnvelope}:`, error);
+ logger.error('envelope', `submitting ${activeEnvelope} failed`, { error: String(error) });
Alert.alert('Error', 'An error occurred during submission');
} finally {
setLoading(false);
- console.log('⏳ Loading state: FALSE');
- }
- console.log('═══════════════════════════════════════════════════════\n');
- };
-
-
- const handleSubmitEnvelopeFile = async (url: string) => {
- console.log('\n📎 FILE UPLOAD INITIATED');
- console.log('═══════════════════════════════════════════════════════');
- console.log('URL:', url);
-
-
- const payload = buildEnvelopePayload(true);
-
-
- if (!payload) {
- console.log('❌ Payload build failed for file upload');
- return null;
- }
-
-
- const list = tenderData?.[`tenderEnvelope${activeEnvelope}List`] || [];
- const fileField = list.find((item: any) => item[`labelType${activeEnvelope}`] === '3');
- const seqId = fileField ? fileField[`tenderEnvelope${activeEnvelope}SequnceId`] : null;
-
- if (!seqId || !envelopForm?.[activeEnvelope]?.[seqId]) {
- console.error('❌ No file found to upload for envelope', activeEnvelope);
- return null;
- }
-
- const fileDetail: any = envelopForm?.[activeEnvelope]?.[seqId];
- console.log('📄 File Details:');
- console.log(' Name:', fileDetail?.name);
- console.log(' Type:', fileDetail?.type);
- console.log(' URI:', fileDetail?.uri);
-
-
- const formData = new FormData();
- formData.append('files', {
- uri: fileDetail.uri,
- name: fileDetail.name,
- type: fileDetail.type,
- } as any);
- formData.append('requestDto', JSON.stringify(payload));
-
-
- console.log('📤 Uploading file...');
-
-
- try {
- const res: any = await apiCall('post', url, formData, {}, 'multipart/form-data');
- console.log('✅ File upload response:', res?.statusCode);
- console.log('═══════════════════════════════════════════════════════\n');
- return res;
- } catch (error) {
- console.error('❌ File upload error:', error);
- console.log('═══════════════════════════════════════════════════════\n');
- return null;
+ logger.debug('envelope', `[submit] ${activeEnvelope} flow finished (loading cleared)`);
}
};
+ const handleFooterSubmit = () => {
+ logger.info('envelope', '[submit] footer "Submit Bid" pressed', { activeEnvelope });
+ if (!check) {
+ logger.warn('envelope', '[submit] terms & conditions not accepted — aborting footer submit');
+ Alert.alert('Info', 'Check first terms & condition');
+ return;
+ }
+ if (activeEnvelope === 'A' || activeEnvelope === 'B') {
+ handleSubmitEnvelope(activeEnvelope);
+ } else {
+ logger.debug('envelope', '[submit] footer submit ignored for non-A/B envelope', { activeEnvelope });
+ }
+ };
// ── Render ───────────────────────────────────────────────────────
+ const envelopeTabs: Array<{ type: EnvelopeType; title: string; visible: boolean }> = [
+ { type: 'A', title: 'Envelope A', visible: !!tenderData?.showEA },
+ { type: 'B', title: 'Envelope B', visible: !!tenderData?.showEB },
+ { type: 'LCS', title: 'Envelope LCS', visible: !!tenderData?.showLCS },
+ { type: 'QCBS', title: 'Envelope QCBS', visible: !!tenderData?.showQCBS },
+ { type: 'C', title: 'Envelope C', visible: !!tenderData?.showEC },
+ ];
+
+ const activeEnvelopeList = tenderData?.[`tenderEnvelope${activeEnvelope}List`] || [];
+ const qcbsQuestions = tenderData?.tenderEnvelopeQcbsList?.questions || [];
+
return (
-
+
{
{/* ═══════════ TENDER INFO HEADER CARD ═══════════ */}
-
+
{bidData?.tenderNumber || 'Tender'}
@@ -947,14 +304,14 @@ const TenderBidding = () => {
NIT No.
-
+
{bidData?.nitNo || '-'}
EMD Amount
-
+
₹{bidData?.emdInFig || '-'}
@@ -963,185 +320,114 @@ const TenderBidding = () => {
Re-Bid Allowed
-
+
{bidData?.tenderRebid ? 'Yes' : 'No'}
Bid Withdraw
-
+
{bidData?.bidWithdraw || '-'}
-
{/* ═══════════ ENVELOPE TABS ═══════════ */}
-
-
+
+
Bid Envelopes
-
+
- {tenderData?.showEA && (
- {
- console.log('🔄 Switched to Envelope A');
- setActiveEnvelope('A');
- }}
- />
- )}
- {tenderData?.showEB && (
- {
- console.log('🔄 Switched to Envelope B');
- setActiveEnvelope('B');
- }}
- />
- )}
- {tenderData?.showLCS && (
- {
- console.log('🔄 Switched to Envelope LCS');
- setActiveEnvelope('LCS');
- }}
- />
- )}
- {tenderData?.showQCBS && (
- {
- console.log('🔄 Switched to Envelope QCBS');
- setActiveEnvelope('QCBS');
- }}
- />
- )}
- {tenderData?.showEC && (
- {
- console.log('🔄 Switched to Envelope C');
- setActiveEnvelope('C');
- }}
- />
- )}
+ {envelopeTabs
+ .filter(tab => tab.visible)
+ .map(tab => (
+ setActiveEnvelope(tab.type)}
+ />
+ ))}
-
{/* ═══════════ ENVELOPE A/B DETAILS ═══════════ */}
- {tenderData?.[`tenderEnvelope${activeEnvelope}List`]?.length > 0 && (
-
-
+ {activeEnvelopeList.length > 0 && (
+
+
Envelope {activeEnvelope} Details
-
- item?.[`tenderEnvelope${activeEnvelope}SequnceId`]?.toString()}
- renderItem={({ item }) => renderEnvelopeItem({ item, envelopeType: activeEnvelope })}
- scrollEnabled={false}
- />
+
+ {activeEnvelopeList.map((item: any) => {
+ const sequenceId = item?.[`tenderEnvelope${activeEnvelope}SequnceId`];
+ return (
+
+ handleEnvelopeChange(activeEnvelope, sequenceId, text)
+ }
+ onPickFile={() => pickPdf(activeEnvelope, sequenceId)}
+ />
+ );
+ })}
-
- {
- setCheck(!check);
- console.log('☑️ Terms & Conditions checked:', !check);
- }}>
- {check ? (
-
- ) : (
-
- )}
-
-
- Accept Terms & Conditions
-
-
+ setCheck(!check)} />
)}
-
{/* ═══════════ LCS/QCBS QUESTIONS ═══════════ */}
{(tenderData?.showLCS || tenderData?.showQCBS) &&
- (tenderData?.tenderEnvelopeQcbsList?.questions?.length > 0) &&
+ qcbsQuestions.length > 0 &&
(activeEnvelope === 'LCS' || activeEnvelope === 'QCBS') && (
-
-
+
+
{activeEnvelope} Questions
-
-
+
+
Select the most appropriate option for each question
- item.questionId.toString()}
- renderItem={renderQcbsQuestion}
- scrollEnabled={false}
- />
+ {qcbsQuestions.map((question: any) => (
+ handleOptionSelect(question.questionId, option)}
+ />
+ ))}
-
- {
- setCheck(!check);
- console.log('☑️ Terms & Conditions checked:', !check);
- }}>
- {check ? (
-
- ) : (
-
- )}
-
-
- Accept Terms & Conditions
-
-
+ setCheck(!check)} />
)}
-
{/* ═══════════ ENVELOPE C ═══════════ */}
{activeEnvelope === 'C' && (
-
-
+
+
Envelope C Status
-
+
{tenderData?.envelopeCSubmittStatus ? (
<>
-
+
✅ Submitted Successfully
{tenderData?.envelopeC_Logs && (
-
+
Date & Time:
{tenderData.envelopeC_Logs.dateTime}
@@ -1160,21 +446,18 @@ const TenderBidding = () => {
)}
-
-
{/* ═══════════ FOOTER CTA ═══════════ */}
- {(activeEnvelope === 'LCS' || activeEnvelope === 'QCBS') &&
- tenderData?.tenderEnvelopeQcbsList?.questions?.length > 0 ? (
+ {(activeEnvelope === 'LCS' || activeEnvelope === 'QCBS') && qcbsQuestions.length > 0 ? (
{
) : hasEnvelopes() && activeEnvelope !== 'C' ? (
{
- if (activeEnvelope === 'A') {
- if (!check) {
- console.log('⚠️ Terms & Conditions not checked');
- Alert.alert('Info', 'Check first terms & condition');
- return;
- }
- handleSubmitEnvelopeA();
- } else if (activeEnvelope === 'B') {
- if (!check) {
- console.log('⚠️ Terms & Conditions not checked');
- Alert.alert('Info', 'Check first terms & condition');
- return;
- }
- handleSubmitEnvelopeB();
- }
- }}
+ style={[styles.proceedButton, { backgroundColor: colors.primary }]}
+ onPress={handleFooterSubmit}
disabled={loading}
activeOpacity={0.85}
>
@@ -1218,32 +485,25 @@ const TenderBidding = () => {
)}
- ) : (
- //
- //
- //
- // No submission available for this envelope
- //
- //
- null
- )}
+ ) : null}
);
};
-
export default TenderBidding;
-
// ── Styles ────────────────────────────────────────────────────────────
-const makeStyles = (colors: any, spacing: any, shadows: any) => StyleSheet.create({
+const makeStyles = (spacing: any) => StyleSheet.create({
+ root: { flex: 1 },
+ flex1: { flex: 1 },
+ bold600: { fontWeight: '600' } as any,
+ bold700: { fontWeight: '700' } as any,
container: {
flex: 1,
padding: spacing.m,
},
headerCard: {
- backgroundColor: colors.surface,
borderRadius: 16,
padding: spacing.m,
marginBottom: spacing.m,
@@ -1271,8 +531,8 @@ const makeStyles = (colors: any, spacing: any, shadows: any) => StyleSheet.creat
section: {
marginBottom: spacing.m,
},
+ sectionTitle: { fontWeight: '600', marginBottom: spacing.s } as any,
envelopeTabsCard: {
- backgroundColor: colors.surface,
borderRadius: 12,
padding: spacing.m,
},
@@ -1281,107 +541,11 @@ const makeStyles = (colors: any, spacing: any, shadows: any) => StyleSheet.creat
gap: spacing.s,
flexWrap: 'wrap',
},
- envelopeButton: {
- paddingVertical: 10,
- paddingHorizontal: 16,
- borderRadius: 20,
- borderWidth: 1.5,
- borderColor: colors.border,
- backgroundColor: colors.surface,
- minWidth: 100,
- alignItems: 'center',
- },
- envelopeButtonActive: {
- backgroundColor: colors.primary,
- borderColor: colors.primary,
- },
detailsCard: {
- backgroundColor: colors.surface,
borderRadius: 12,
padding: spacing.m,
},
- envelopeItem: {
- backgroundColor: colors.surface,
- borderRadius: 10,
- padding: spacing.m,
- marginBottom: spacing.s,
- borderWidth: 1,
- borderColor: colors.border,
- borderLeftWidth: 3,
- borderLeftColor: colors.primary,
- },
- uploadButton: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 8,
- paddingVertical: 10,
- paddingHorizontal: 16,
- borderRadius: 10,
- borderWidth: 1.5,
- borderColor: colors.primary,
- backgroundColor: colors.surface,
- marginTop: spacing.s,
- alignSelf: 'flex-start',
- },
- questionCard: {
- backgroundColor: colors.surface,
- borderRadius: 12,
- padding: spacing.m,
- marginBottom: spacing.m,
- borderWidth: 1,
- borderColor: colors.border,
- borderLeftWidth: 3,
- borderLeftColor: colors.primary,
- },
- optionContainer: {
- backgroundColor: colors.surface,
- borderRadius: 10,
- padding: spacing.m,
- marginBottom: spacing.s,
- borderWidth: 1.5,
- borderColor: colors.border,
- },
- optionContainerSelected: {
- borderColor: colors.primary,
- borderWidth: 2,
- backgroundColor: `${colors.primary}08`,
- },
- optionRow: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 12,
- },
- radioButton: {
- width: 22,
- height: 22,
- borderRadius: 11,
- borderWidth: 2,
- borderColor: colors.border,
- justifyContent: 'center',
- alignItems: 'center',
- },
- radioButtonInner: {
- width: 11,
- height: 11,
- borderRadius: 6,
- backgroundColor: colors.primary,
- },
- infoRow: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- paddingVertical: spacing.s,
- borderBottomWidth: StyleSheet.hairlineWidth,
- },
- termsRow: {
- flexDirection: 'row',
- gap: 12,
- alignItems: 'center',
- marginTop: spacing.m,
- paddingTop: spacing.m,
- borderTopWidth: StyleSheet.hairlineWidth,
- borderTopColor: colors.border,
- },
+ qcbsHint: { fontStyle: 'italic', marginBottom: spacing.m } as any,
statusBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
@@ -1389,6 +553,7 @@ const makeStyles = (colors: any, spacing: any, shadows: any) => StyleSheet.creat
alignSelf: 'flex-start',
marginBottom: spacing.m,
},
+ logDetails: { gap: 6 },
logDetailRow: {
flexDirection: 'row',
justifyContent: 'space-between',
@@ -1403,14 +568,12 @@ const makeStyles = (colors: any, spacing: any, shadows: any) => StyleSheet.creat
bottom: 0,
left: 0,
right: 0,
- backgroundColor: colors.surface,
padding: spacing.m,
borderTopWidth: 1,
},
proceedButton: {
- backgroundColor: colors.primary,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
-});
\ No newline at end of file
+});
diff --git a/src/Screens/Tender/tenderBidding/__tests__/envelopePayload.test.ts b/src/Screens/Tender/tenderBidding/__tests__/envelopePayload.test.ts
new file mode 100644
index 0000000..6794eb3
--- /dev/null
+++ b/src/Screens/Tender/tenderBidding/__tests__/envelopePayload.test.ts
@@ -0,0 +1,120 @@
+import { buildEnvelopePayload, buildQcbsPayload, findEnvelopeFile, validateEnvelope, validateQcbs } from '../envelopePayload';
+import { convertDigitsToWords } from '../envelopeCrypto';
+
+jest.mock('../../../../Services/Encryption/encrypt', () => ({
+ encrypt: jest.fn((value: string) => `enc(${value})`),
+ encryptWithRSA: jest.fn(() => 'rsa-wrapped-key'),
+}));
+
+const tenderData = {
+ deptId: 7,
+ tenderId: 42,
+ tenderEnvelopeAList: [
+ {
+ tenderEnvelopeASequnceId: 1,
+ labelNameA: 'Bid Amount',
+ labelTypeA: '1',
+ labelMandatoryA: true,
+ tenderEnvelopeANameId: 11,
+ envelopeATemplateName: 'TemplateA',
+ },
+ {
+ tenderEnvelopeASequnceId: 2,
+ labelNameA: 'Supporting Doc',
+ labelTypeA: '3',
+ labelMandatoryA: false,
+ },
+ ],
+ envelopeKeyDetailsResDTO: {
+ envelopeAGenerateKey: 'aes-key-A',
+ envelopeQcbsAndLcs: 'aes-key-qcbs',
+ departmentOfficerPublicKeys: [
+ { officersId: 5, officerPublicKey: 'pub-key', officerType: 'AO' },
+ ],
+ },
+ tenderEnvelopeQcbsList: {
+ templateId: 3,
+ qcbsTemplateName: 'QCBS-T',
+ questions: [
+ { questionId: 100, question: 'Q1?', parameters: [{ option: 'Yes', mark: 5, strUser: '1' }] },
+ ],
+ },
+};
+
+describe('convertDigitsToWords', () => {
+ it('spells out each digit', () => {
+ expect(convertDigitsToWords('120')).toBe('One Two Zero');
+ });
+});
+
+describe('validateEnvelope', () => {
+ it('fails when a mandatory field is empty', () => {
+ expect(validateEnvelope(tenderData, 'A', {})).toBe(false);
+ expect(validateEnvelope(tenderData, 'A', { 1: ' ' })).toBe(false);
+ });
+
+ it('passes when mandatory fields are filled (optional file may be empty)', () => {
+ expect(validateEnvelope(tenderData, 'A', { 1: '5000' })).toBe(true);
+ });
+});
+
+describe('findEnvelopeFile', () => {
+ it('returns the picked file for the file field', () => {
+ const file = { uri: 'file://x.pdf', name: 'x.pdf', type: 'application/pdf' };
+ expect(findEnvelopeFile(tenderData, 'A', { 2: file })).toBe(file);
+ expect(findEnvelopeFile(tenderData, 'A', {})).toBeNull();
+ });
+});
+
+describe('buildEnvelopePayload', () => {
+ it('returns null when the tender has no encryption key', () => {
+ expect(buildEnvelopePayload({ envelopeKeyDetailsResDTO: {} }, 'A', {})).toBeNull();
+ });
+
+ it('encrypts non-file fields in figures and words, echoing the key per backend contract', () => {
+ const payload = buildEnvelopePayload(tenderData, 'A', { 1: '50' });
+
+ expect(payload.deptId).toBe(7);
+ expect(payload.tenderId).toBe(42);
+ expect(payload.tenderEnvelopeANameId).toBe(11);
+ expect(payload.envelopeATemplateName).toBe('TemplateA');
+ expect(payload.deviceType).toBe('Mobile');
+ expect(payload.randomKeyGenerated).toBe('aes-key-A');
+
+ expect(payload.saveAData).toHaveLength(1); // file fields excluded
+ expect(payload.saveAData[0].fieldValue).toBe('enc(50)');
+ expect(payload.saveAData[0].fieldValueInWord).toBe('enc(Five Zero)');
+
+ expect(payload.saveEnvelopesOfficersDetailsResDTO).toEqual([
+ { officerId: 5, officerEncryptedKey: 'rsa-wrapped-key', officerType: 'AO' },
+ ]);
+ });
+
+ it('filesOnly variant carries only file fields, without values', () => {
+ const payload = buildEnvelopePayload(tenderData, 'A', { 1: '50' }, true);
+ expect(payload.saveAData).toHaveLength(1);
+ expect(payload.saveAData[0].labelTypeA).toBe('3');
+ expect(payload.saveAData[0].fieldValue).toBeUndefined();
+ });
+});
+
+describe('QCBS', () => {
+ it('validates that at least one option is selected', () => {
+ expect(validateQcbs(tenderData, {})).toBe(false);
+ expect(validateQcbs(tenderData, { 100: { option: 'Yes' } })).toBe(true);
+ });
+
+ it('builds a payload containing only the selected, encrypted option', () => {
+ const payload = buildQcbsPayload(tenderData, { 100: { option: 'Yes', mark: 5, strUser: '1' } });
+ expect(payload.templateId).toBe(3);
+ expect(payload.questions).toHaveLength(1);
+ expect(payload.questions[0].parameters).toEqual([
+ { option: 'enc(Yes)', mark: 5, strUser: 'enc(1)' },
+ ]);
+ });
+
+ it('emits an empty parameters array for unanswered questions', () => {
+ const payload = buildQcbsPayload(tenderData, {});
+ expect(payload.questions[0].parameters).toEqual([]);
+ });
+});
diff --git a/src/Screens/Tender/tenderBidding/components/EnvelopeButton.tsx b/src/Screens/Tender/tenderBidding/components/EnvelopeButton.tsx
new file mode 100644
index 0000000..4457d80
--- /dev/null
+++ b/src/Screens/Tender/tenderBidding/components/EnvelopeButton.tsx
@@ -0,0 +1,47 @@
+import React, { memo } from 'react';
+import { StyleSheet, TouchableOpacity } from 'react-native';
+import { useTheme } from '../../../../Hooks/useTheme';
+import AppText from '../../../../components/AppText';
+
+type Props = {
+ title: string;
+ isActive: boolean;
+ onPress: () => void;
+};
+
+const EnvelopeButton = ({ title, isActive, onPress }: Props) => {
+ const { colors } = useTheme();
+ return (
+
+
+ {title}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ button: {
+ paddingVertical: 10,
+ paddingHorizontal: 16,
+ borderRadius: 20,
+ borderWidth: 1.5,
+ minWidth: 100,
+ alignItems: 'center',
+ },
+ title: { fontWeight: '700' } as any,
+});
+
+export default memo(EnvelopeButton);
diff --git a/src/Screens/Tender/tenderBidding/components/EnvelopeFieldItem.tsx b/src/Screens/Tender/tenderBidding/components/EnvelopeFieldItem.tsx
new file mode 100644
index 0000000..51d76f2
--- /dev/null
+++ b/src/Screens/Tender/tenderBidding/components/EnvelopeFieldItem.tsx
@@ -0,0 +1,144 @@
+import React, { memo } from 'react';
+import { StyleSheet, TouchableOpacity, View } from 'react-native';
+import Animated, { FadeInUp } from 'react-native-reanimated';
+import { FileUp } from 'lucide-react-native';
+import MyInput from '../../../../components/MyInput';
+import AppText from '../../../../components/AppText';
+import { useTheme } from '../../../../Hooks/useTheme';
+import type { EnvelopeType } from '../envelopeTypes';
+
+type Props = {
+ item: any;
+ envelopeType: EnvelopeType;
+ value: any;
+ /** False once the envelope has already been submitted. */
+ showInput: boolean;
+ /** Whole-envelope validation state — drives the mandatory-asterisk color. */
+ envelopeValid: boolean;
+ onChangeText: (text: string) => void;
+ onPickFile: () => void;
+};
+
+const keyboardTypeFor = (labelType: string) =>
+ labelType === '1' || labelType === '4' ? 'numeric' : 'default';
+
+const placeholderFor = (labelType: string, labelName: string) => {
+ if (labelType === '1') { return `Enter ${labelName} numeric value`; }
+ if (labelType === '4') { return `Enter ${labelName} currency value`; }
+ return `Enter ${labelName} Alph-numeric value`;
+};
+
+/** One envelope form field: numeric/text/currency input or a PDF picker. */
+const EnvelopeFieldItem = ({
+ item,
+ envelopeType,
+ value,
+ showInput,
+ envelopeValid,
+ onChangeText,
+ onPickFile,
+}: Props) => {
+ const { colors, spacing } = useTheme();
+
+ const sequenceId = item?.[`tenderEnvelope${envelopeType}SequnceId`];
+ if (!sequenceId) {
+ return null;
+ }
+
+ const labelName = item?.[`labelName${envelopeType}`] || '';
+ const labelType = item?.[`labelType${envelopeType}`] || '';
+ const isMandatory = item?.[`labelMandatory${envelopeType}`];
+
+ return (
+
+
+ {/* Label */}
+
+
+ {labelName || '-'}
+
+ {isMandatory && (
+
+ *
+
+ )}
+
+
+ {/* Input directly below the label, inside the same card */}
+ {showInput ? (
+
+ {labelType === '3' ? (
+ <>
+
+
+
+ {value ? 'Change Document' : 'Upload Document'}
+
+
+
+ {value && (
+
+ 📄 {(value as any)?.name}
+
+ )}
+ >
+ ) : (
+
+ )}
+
+ ) : (
+
+ Envelope {envelopeType} unavailable
+
+ )}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ envelopeItem: {
+ borderRadius: 10,
+ borderWidth: 1,
+ borderLeftWidth: 3,
+ },
+ labelRow: { flexDirection: 'row', alignItems: 'center', gap: 4 },
+ labelText: { fontWeight: '600', textTransform: 'uppercase', letterSpacing: 0.3 } as any,
+ uploadButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ paddingVertical: 10,
+ paddingHorizontal: 16,
+ borderRadius: 10,
+ borderWidth: 1.5,
+ alignSelf: 'flex-start',
+ },
+ uploadText: { fontWeight: '600' } as any,
+ fileName: { marginTop: 4 } as any,
+});
+
+export default memo(EnvelopeFieldItem);
diff --git a/src/Screens/Tender/tenderBidding/components/QcbsQuestionCard.tsx b/src/Screens/Tender/tenderBidding/components/QcbsQuestionCard.tsx
new file mode 100644
index 0000000..e47d676
--- /dev/null
+++ b/src/Screens/Tender/tenderBidding/components/QcbsQuestionCard.tsx
@@ -0,0 +1,116 @@
+import React, { memo } from 'react';
+import { StyleSheet, TouchableOpacity, View } from 'react-native';
+import Animated, { FadeInUp, SlideInRight } from 'react-native-reanimated';
+import AppText from '../../../../components/AppText';
+import { useTheme } from '../../../../Hooks/useTheme';
+
+type Props = {
+ question: any;
+ selectedOption: any;
+ onSelect: (option: any) => void;
+};
+
+/** One LCS/QCBS question with its single-select option list. */
+const QcbsQuestionCard = ({ question, selectedOption, onSelect }: Props) => {
+ const { colors, spacing } = useTheme();
+
+ return (
+
+
+ {question.question}
+
+
+ {question.parameters?.map((param: any, index: number) => {
+ const isSelected = selectedOption?.option === param.option;
+
+ return (
+
+ onSelect(param)}
+ activeOpacity={0.7}
+ >
+
+
+ {isSelected && }
+
+
+
+ {param.option}
+
+
+
+
+
+ );
+ })}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ flex1: { flex: 1 },
+ questionCard: {
+ borderRadius: 12,
+ borderWidth: 1,
+ borderLeftWidth: 3,
+ },
+ questionText: { fontWeight: '700', lineHeight: 22 } as any,
+ optionContainer: {
+ borderRadius: 10,
+ borderWidth: 1.5,
+ },
+ optionRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ },
+ radioButton: {
+ width: 22,
+ height: 22,
+ borderRadius: 11,
+ borderWidth: 2,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ radioButtonInner: {
+ width: 11,
+ height: 11,
+ borderRadius: 6,
+ },
+});
+
+export default memo(QcbsQuestionCard);
diff --git a/src/Screens/Tender/tenderBidding/components/TermsRow.tsx b/src/Screens/Tender/tenderBidding/components/TermsRow.tsx
new file mode 100644
index 0000000..0978c39
--- /dev/null
+++ b/src/Screens/Tender/tenderBidding/components/TermsRow.tsx
@@ -0,0 +1,50 @@
+import React, { memo } from 'react';
+import { StyleSheet, TouchableOpacity, View } from 'react-native';
+import { CheckSquare, Square } from 'lucide-react-native';
+import AppText from '../../../../components/AppText';
+import { useTheme } from '../../../../Hooks/useTheme';
+
+type Props = {
+ checked: boolean;
+ onToggle: () => void;
+};
+
+const TermsRow = ({ checked, onToggle }: Props) => {
+ const { colors, spacing } = useTheme();
+ return (
+
+
+ {checked ? (
+
+ ) : (
+
+ )}
+
+
+ Accept Terms & Conditions
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ termsRow: {
+ flexDirection: 'row',
+ gap: 12,
+ alignItems: 'center',
+ borderTopWidth: StyleSheet.hairlineWidth,
+ },
+ termsText: { fontWeight: '600', flex: 1 } as any,
+});
+
+export default memo(TermsRow);
diff --git a/src/Screens/Tender/tenderBidding/envelopeCrypto.ts b/src/Screens/Tender/tenderBidding/envelopeCrypto.ts
new file mode 100644
index 0000000..ab3ac65
--- /dev/null
+++ b/src/Screens/Tender/tenderBidding/envelopeCrypto.ts
@@ -0,0 +1,75 @@
+/**
+ * Sealed-bid crypto helpers for tender envelopes.
+ *
+ * SECURITY: nothing in this module may ever log key material, plaintext bid
+ * values, or ciphertext — the previous implementation dumped all three to
+ * logcat and that was a reportable incident, not a debugging aid.
+ */
+import { encryptWithRSA } from '../../../Services/Encryption/encrypt';
+import { logger } from '../../../core/observability/logger';
+import type { OfficerKey, SaveEnvelopeOfficerDetail } from './envelopeTypes';
+
+const DIGIT_WORDS: Record = {
+ '0': 'Zero',
+ '1': 'One',
+ '2': 'Two',
+ '3': 'Three',
+ '4': 'Four',
+ '5': 'Five',
+ '6': 'Six',
+ '7': 'Seven',
+ '8': 'Eight',
+ '9': 'Nine',
+};
+
+/** "120" → "One Two Zero" — the backend stores bid figures in words too. */
+export const convertDigitsToWords = (value: string): string =>
+ value
+ .split('')
+ .map(digit => DIGIT_WORDS[digit] || '')
+ .join(' ') || value;
+
+/**
+ * Wraps the symmetric envelope key with each department officer's RSA public
+ * key. A failed wrap is recorded as an empty key (matching the backend
+ * contract) and logged WITHOUT any key material.
+ */
+export const buildOfficerEncryptedKeys = (
+ envelopeKey: string,
+ officerKeys: OfficerKey[],
+): SaveEnvelopeOfficerDetail[] => {
+ // Counts only — never log the key being wrapped or the resulting ciphertext.
+ logger.debug('envelope', `[crypto] wrapping envelope key for ${officerKeys.length} officer(s)`);
+
+ let wrapped = 0;
+ const result = officerKeys.map(officer => {
+ try {
+ const officerEncryptedKey = encryptWithRSA(envelopeKey, officer.officerPublicKey) || '';
+ if (!officerEncryptedKey) {
+ logger.warn('envelope', `RSA wrap produced empty key for officer ${officer.officersId}`);
+ } else {
+ wrapped += 1;
+ }
+ return {
+ officerId: officer.officersId,
+ officerEncryptedKey,
+ officerType: officer.officerType,
+ };
+ } catch (error) {
+ logger.error('envelope', `RSA wrap failed for officer ${officer.officersId}`, {
+ error: String(error),
+ });
+ return {
+ officerId: officer.officersId,
+ officerEncryptedKey: '',
+ officerType: officer.officerType,
+ };
+ }
+ });
+
+ logger.debug(
+ 'envelope',
+ `[crypto] officer key wrap complete: ${wrapped}/${officerKeys.length} succeeded`,
+ );
+ return result;
+};
diff --git a/src/Screens/Tender/tenderBidding/envelopePayload.ts b/src/Screens/Tender/tenderBidding/envelopePayload.ts
new file mode 100644
index 0000000..6e43fe9
--- /dev/null
+++ b/src/Screens/Tender/tenderBidding/envelopePayload.ts
@@ -0,0 +1,233 @@
+/**
+ * Envelope payload construction and validation — pure functions over the
+ * tender data and form state. No React, no HTTP, no UI, no key logging.
+ */
+import { encrypt } from '../../../Services/Encryption/encrypt';
+import { logger } from '../../../core/observability/logger';
+import { buildOfficerEncryptedKeys, convertDigitsToWords } from './envelopeCrypto';
+import type { EnvelopeType, PickedFile } from './envelopeTypes';
+
+/** Every mandatory field of the active envelope must be filled. */
+export const validateEnvelope = (
+ tenderData: any,
+ envelopeType: EnvelopeType,
+ formValues: Record,
+): boolean => {
+ const list = tenderData?.[`tenderEnvelope${envelopeType}List`] || [];
+
+ let missingSeqId: number | null = null;
+ const missing = list.some((item: any) => {
+ const seqId = item[`tenderEnvelope${envelopeType}SequnceId`];
+ const isMandatory = item[`labelMandatory${envelopeType}`];
+ const value = formValues?.[seqId];
+
+ if (!isMandatory) { return false; }
+ const empty = item[`labelType${envelopeType}`] === '3'
+ ? !value
+ : !value || value.toString().trim() === '';
+ if (empty) { missingSeqId = seqId; }
+ return empty;
+ });
+
+ logger.debug('envelope', `[validate] envelope ${envelopeType}`, {
+ fields: list.length,
+ valid: !missing,
+ ...(missing ? { firstMissingSeqId: missingSeqId } : {}),
+ });
+
+ return !missing;
+};
+
+/** LCS/QCBS: at least one question must have a selected option. */
+export const validateQcbs = (
+ tenderData: any,
+ selectedOptions: Record,
+): boolean => {
+ const questions = tenderData?.tenderEnvelopeQcbsList?.questions || [];
+ const answered = questions.filter((q: any) => selectedOptions[q.questionId]).length;
+ logger.debug('envelope', '[validate] LCS/QCBS', {
+ questions: questions.length,
+ answered,
+ valid: answered > 0,
+ });
+ return answered > 0;
+};
+
+/** Returns the picked file for the envelope's file field, if any. */
+export const findEnvelopeFile = (
+ tenderData: any,
+ envelopeType: EnvelopeType,
+ formValues: Record,
+): PickedFile | null => {
+ const list = tenderData?.[`tenderEnvelope${envelopeType}List`] || [];
+ const fileField = list.find((item: any) => item[`labelType${envelopeType}`] === '3');
+ const seqId = fileField ? fileField[`tenderEnvelope${envelopeType}SequnceId`] : null;
+ const file = (seqId && formValues?.[seqId]) || null;
+ logger.debug('envelope', `[file] locate document field for envelope ${envelopeType}`, {
+ hasFileField: !!fileField,
+ seqId,
+ fileSelected: !!file,
+ fileName: file?.name,
+ fileType: file?.type,
+ });
+ return file;
+};
+
+/**
+ * Builds the save-envelope payload with every field value AES-encrypted under
+ * the envelope key and the key RSA-wrapped per officer.
+ *
+ * @param filesOnly when true, builds the variant used as the multipart
+ * `requestDto` for the file upload (file fields only); otherwise the
+ * data-submission variant (non-file fields only).
+ * @returns the payload, or null when the tender carries no encryption key.
+ */
+export const buildEnvelopePayload = (
+ tenderData: any,
+ envelopeType: EnvelopeType,
+ formValues: Record,
+ filesOnly: boolean = false,
+): any | null => {
+ const list = tenderData?.[`tenderEnvelope${envelopeType}List`] || [];
+ const encryptionKey =
+ tenderData?.envelopeKeyDetailsResDTO?.[`envelope${envelopeType}GenerateKey`] || '';
+
+ logger.info('envelope', `[payload] building envelope ${envelopeType} payload`, {
+ filesOnly,
+ totalFields: list.length,
+ hasEncryptionKey: !!encryptionKey,
+ });
+
+ if (!encryptionKey) {
+ logger.error('envelope', `no encryption key available for envelope ${envelopeType}`);
+ return null;
+ }
+
+ const combinedData = list
+ .filter((item: any) => {
+ const labelType = item[`labelType${envelopeType}`];
+ return filesOnly ? labelType === '3' : labelType !== '3';
+ })
+ .map((item: any) => {
+ const seqId = item[`tenderEnvelope${envelopeType}SequnceId`];
+ const labelType = item[`labelType${envelopeType}`];
+ const labelName = item[`labelName${envelopeType}`];
+ const isMandatory = item[`labelMandatory${envelopeType}`];
+ const rawValue = formValues[seqId];
+
+ if (labelType === '3') {
+ return {
+ [`tenderEnvelope${envelopeType}SequnceId`]: seqId,
+ [`labelName${envelopeType}`]: labelName,
+ [`labelType${envelopeType}`]: labelType,
+ [`labelMandatory${envelopeType}`]: isMandatory,
+ };
+ }
+
+ const encryptedValue = rawValue ? encrypt(String(rawValue), encryptionKey) : '';
+ const valueInWords = rawValue ? convertDigitsToWords(String(rawValue)) : '';
+ const encryptedValueInWord = valueInWords ? encrypt(valueInWords, encryptionKey) : '';
+
+ return {
+ [`tenderEnvelope${envelopeType}SequnceId`]: seqId,
+ [`labelName${envelopeType}`]: labelName,
+ [`labelType${envelopeType}`]: labelType,
+ [`labelMandatory${envelopeType}`]: isMandatory,
+ fieldValue: encryptedValue,
+ fieldValueInWord: encryptedValueInWord,
+ };
+ });
+
+ logger.debug('envelope', `[payload] envelope ${envelopeType} fields encrypted`, {
+ filesOnly,
+ encryptedFields: combinedData.length,
+ });
+
+ const officerEncryptedKeys = buildOfficerEncryptedKeys(
+ encryptionKey,
+ tenderData?.envelopeKeyDetailsResDTO?.departmentOfficerPublicKeys || [],
+ );
+
+ logger.info('envelope', `[payload] envelope ${envelopeType} payload ready`, {
+ filesOnly,
+ savedFields: combinedData.length,
+ officers: officerEncryptedKeys.length,
+ });
+
+ return {
+ deptId: tenderData?.deptId,
+ tenderId: tenderData?.tenderId,
+ [`tenderEnvelope${envelopeType}NameId`]:
+ tenderData?.[`tenderEnvelope${envelopeType}List`]?.[0]?.[
+ `tenderEnvelope${envelopeType}NameId`
+ ],
+ [`envelope${envelopeType}TemplateName`]:
+ tenderData?.[`tenderEnvelope${envelopeType}List`]?.[0]?.[
+ `envelope${envelopeType}TemplateName`
+ ],
+ [`save${envelopeType}Data`]: combinedData,
+ deviceType: 'Mobile',
+ // NOTE: echoing the symmetric key back to the server is a weakness of the
+ // current backend contract (see the architecture audit §11) — it cannot be
+ // removed client-side without a coordinated backend change.
+ randomKeyGenerated: encryptionKey,
+ saveEnvelopesOfficersDetailsResDTO: officerEncryptedKeys,
+ };
+};
+
+/** Builds the LCS/QCBS payload — only the selected option per question, encrypted. */
+export const buildQcbsPayload = (
+ tenderData: any,
+ selectedOptions: Record,
+): any | null => {
+ const questions = tenderData?.tenderEnvelopeQcbsList?.questions || [];
+ const encryptionKey = tenderData?.envelopeKeyDetailsResDTO?.envelopeQcbsAndLcs || '';
+
+ const answered = questions.filter((q: any) => selectedOptions[q.questionId]).length;
+ logger.info('envelope', '[payload] building LCS/QCBS payload', {
+ questions: questions.length,
+ answered,
+ hasEncryptionKey: !!encryptionKey,
+ });
+
+ if (!encryptionKey) {
+ logger.error('envelope', 'no encryption key available for LCS/QCBS');
+ return null;
+ }
+
+ const officerEncryptedKeys = buildOfficerEncryptedKeys(
+ encryptionKey,
+ tenderData?.envelopeKeyDetailsResDTO?.departmentOfficerPublicKeys || [],
+ );
+
+ const questionPayload = questions.map((question: any) => {
+ const selected = selectedOptions[question.questionId];
+ const parameters = selected
+ ? [{
+ option: encrypt(String(selected.option), encryptionKey),
+ mark: selected.mark,
+ strUser: encrypt(String(selected.strUser || '1'), encryptionKey),
+ }]
+ : [];
+
+ return {
+ questionId: question.questionId,
+ question: question.question,
+ parameters,
+ };
+ });
+
+ logger.info('envelope', '[payload] LCS/QCBS payload ready', {
+ questions: questionPayload.length,
+ officers: officerEncryptedKeys.length,
+ });
+
+ return {
+ tenderId: tenderData?.tenderId,
+ templateId: tenderData?.tenderEnvelopeQcbsList?.templateId,
+ qcbsTemplateName: tenderData?.tenderEnvelopeQcbsList?.qcbsTemplateName || 'string',
+ questions: questionPayload,
+ deviceType: 'Mobile',
+ saveEnvelopesOfficersDetailsResDTO: officerEncryptedKeys,
+ };
+};
diff --git a/src/Screens/Tender/tenderBidding/envelopeSubmission.ts b/src/Screens/Tender/tenderBidding/envelopeSubmission.ts
new file mode 100644
index 0000000..d456d31
--- /dev/null
+++ b/src/Screens/Tender/tenderBidding/envelopeSubmission.ts
@@ -0,0 +1,75 @@
+/**
+ * Envelope submission API calls. Uses the legacy `apiCall` envelope contract
+ * (screens branch on `res?.statusCode === 200`) until this feature migrates
+ * to the throwing core/http client end to end.
+ */
+import { apiCall } from '../../../Axios/Axios';
+import {
+ SUBMIT_ENVELOPE_A,
+ SUBMIT_ENVELOPE_B,
+ SUBMIT_ENVELOPE_FILE_A,
+ SUBMIT_ENVELOPE_FILE_B,
+ SUBMIT_QCBS_AND_LCS,
+} from '../../../Services/ApiCall/ApiUrls';
+import { logger } from '../../../core/observability/logger';
+import type { PickedFile } from './envelopeTypes';
+
+/** A and B share identical flows; only the endpoints differ. */
+const ENVELOPE_URLS: Record<'A' | 'B', { data: string; file: string }> = {
+ A: { data: SUBMIT_ENVELOPE_A, file: SUBMIT_ENVELOPE_FILE_A },
+ B: { data: SUBMIT_ENVELOPE_B, file: SUBMIT_ENVELOPE_FILE_B },
+};
+
+export const uploadEnvelopeDocument = async (
+ envelopeType: 'A' | 'B',
+ requestDto: any,
+ file: PickedFile,
+): Promise => {
+ const url = ENVELOPE_URLS[envelopeType].file;
+ const formData = new FormData();
+ formData.append('files', {
+ uri: file.uri,
+ name: file.name,
+ type: file.type,
+ } as any);
+ formData.append('requestDto', JSON.stringify(requestDto));
+
+ logger.info('envelope', `[api] uploading envelope ${envelopeType} document`, {
+ url,
+ fileName: file.name,
+ fileType: file.type,
+ fileSize: file.size,
+ });
+
+ try {
+ const res: any = await apiCall('post', url, formData, {}, 'multipart/form-data');
+ logger.info('envelope', `[api] envelope ${envelopeType} document upload responded`, {
+ statusCode: res?.statusCode,
+ });
+ return res;
+ } catch (error) {
+ logger.error('envelope', `file upload failed for envelope ${envelopeType}`, {
+ error: String(error),
+ });
+ return null;
+ }
+};
+
+export const submitEnvelopeData = async (envelopeType: 'A' | 'B', payload: any): Promise => {
+ const url = ENVELOPE_URLS[envelopeType].data;
+ logger.info('envelope', `[api] submitting envelope ${envelopeType} data`, { url });
+ const res: any = await apiCall('post', url, payload);
+ logger.info('envelope', `[api] envelope ${envelopeType} data submit responded`, {
+ statusCode: res?.statusCode,
+ });
+ return res;
+};
+
+export const submitQcbsData = async (payload: any): Promise => {
+ logger.info('envelope', '[api] submitting LCS/QCBS data', { url: SUBMIT_QCBS_AND_LCS });
+ const res: any = await apiCall('post', SUBMIT_QCBS_AND_LCS, payload);
+ logger.info('envelope', '[api] LCS/QCBS data submit responded', {
+ statusCode: res?.statusCode,
+ });
+ return res;
+};
diff --git a/src/Screens/Tender/tenderBidding/envelopeTypes.ts b/src/Screens/Tender/tenderBidding/envelopeTypes.ts
new file mode 100644
index 0000000..11ca4a5
--- /dev/null
+++ b/src/Screens/Tender/tenderBidding/envelopeTypes.ts
@@ -0,0 +1,32 @@
+/** Shared types for the tender envelope bidding feature. */
+
+export type EnvelopeType = 'A' | 'B' | 'C' | 'LCS' | 'QCBS';
+
+/** Per-envelope form values keyed by the backend sequence id. */
+export type EnvelopeFormState = {
+ A: Record;
+ B: Record;
+ C: Record;
+ LCS: Record;
+ QCBS: Record;
+};
+
+export interface OfficerKey {
+ officersId: number;
+ officerPublicKey: string;
+ officerType: string;
+}
+
+export interface SaveEnvelopeOfficerDetail {
+ officerId: number;
+ officerEncryptedKey: string;
+ officerType: string;
+}
+
+/** A picked document ready for multipart upload. */
+export type PickedFile = {
+ uri: string;
+ name: string;
+ type: string;
+ size?: number | null;
+};
diff --git a/src/Screens/Tender/types/types.tsx b/src/Screens/Tender/types/types.tsx
index 2531c5e..758a115 100644
--- a/src/Screens/Tender/types/types.tsx
+++ b/src/Screens/Tender/types/types.tsx
@@ -9,6 +9,7 @@ export interface LiveTenderTileProps {
purchaseEnd?: string;
tenderFees?: string | number;
isPurchased?: boolean;
+ isCancelled?: boolean;
tenderId?: any;
tenderNumber?: any;
}
diff --git a/src/Screens/TestScreen/DecryptionScreen.tsx b/src/Screens/TestScreen/DecryptionScreen.tsx
deleted file mode 100644
index 570e318..0000000
--- a/src/Screens/TestScreen/DecryptionScreen.tsx
+++ /dev/null
@@ -1,449 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import {
- View,
- Text,
- StyleSheet,
- ScrollView,
- TouchableOpacity,
- Alert,
-} from 'react-native';
-import { useNavigation } from '@react-navigation/native';
-import { decrypt } from '../../Services/Encryption/encrypt';
-import { Clipboard, Copy, Eye, EyeOff, RefreshCw } from 'lucide-react-native';
-import colors from '../../Constant/Color';
-import { InsideHeader } from '../../Component/Index';
-
-
-const DecryptionScreen = () => {
- const navigation = useNavigation();
-
- // ==========================================
- // 🔹 HARDCODED INPUT - CHANGE VALUES HERE
- // ==========================================
- const CIPHER_TEXT = 'xoqT29mkmo0m4O5dmYIc+akKJO8oXD7tTUCWyLKLbF3dXS/PFReGRMgOXDsENnwtrjspBSNL7eoGYUrxi/gUbQ==';
- const ENCRYPTION_KEY = "Lv7GctxTJZWFxM7"
- // ==========================================
-
- const [decryptedText, setDecryptedText] = useState('');
- const [showKey, setShowKey] = useState(false);
- const [showCipher, setShowCipher] = useState(true);
- const [showDecrypted, setShowDecrypted] = useState(true);
-
- useEffect(() => {
- // Auto-decrypt on component mount
- handleDecrypt();
- }, []);
-
- const handleDecrypt = () => {
- console.log('\n🔓 DECRYPTION INITIATED');
- console.log('═══════════════════════════════════════════════════════');
- console.log('📝 Cipher Text:', CIPHER_TEXT);
- console.log('🔑 Encryption Key:', ENCRYPTION_KEY);
- console.log('📏 Cipher Text Length:', CIPHER_TEXT.length);
-
- try {
- const result = decrypt(CIPHER_TEXT, ENCRYPTION_KEY);
-
- if (result) {
- console.log('✅ Decryption successful');
- console.log('📄 Decrypted Text:', result);
- console.log('📏 Decrypted Length:', result.length);
- setDecryptedText(result);
- } else {
- console.log('❌ Decryption failed - returned null');
- Alert.alert('Error', 'Decryption failed. Please check your encrypted text and key.');
- setDecryptedText('Decryption failed');
- }
- } catch (error) {
- console.error('❌ Decryption error:', error);
- Alert.alert('Error', 'An error occurred during decryption');
- setDecryptedText('Error occurred');
- } finally {
- console.log('═══════════════════════════════════════════════════════\n');
- }
- };
-
- const handleCopyDecrypted = () => {
- if (decryptedText) {
- Clipboard.setString(decryptedText);
- Alert.alert('Copied', 'Decrypted text copied to clipboard');
- console.log('📋 Decrypted text copied to clipboard');
- }
- };
-
- const handleCopyCipher = () => {
- Clipboard.setString(CIPHER_TEXT);
- Alert.alert('Copied', 'Encrypted text copied to clipboard');
- console.log('📋 Encrypted text copied to clipboard');
- };
-
- const handleCopyKey = () => {
- Clipboard.setString(ENCRYPTION_KEY);
- Alert.alert('Copied', 'Encryption key copied to clipboard');
- console.log('📋 Encryption key copied to clipboard');
- };
-
- return (
-
-
-
-
- {/* Info Card */}
-
- 🔓 AES Decryption Tool
-
- Decryption performed automatically on screen load
-
-
- Mode: AES/ECB/PKCS7Padding
-
-
-
- {/* Encrypted Text Display */}
-
-
- 📝 Encrypted Text
-
- setShowCipher(!showCipher)}
- style={styles.iconButton}
- >
- {showCipher ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
- {showCipher ? (
-
- {CIPHER_TEXT}
-
- ) : (
-
-
- {'•'.repeat(Math.min(CIPHER_TEXT.length, 50))}
-
-
- )}
-
-
- Length:
- {CIPHER_TEXT.length}
-
-
-
- {/* Encryption Key Display */}
-
-
- 🔑 Encryption Key
-
- setShowKey(!showKey)}
- style={styles.iconButton}
- >
- {showKey ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
- {showKey ? (
-
- {ENCRYPTION_KEY}
-
- ) : (
-
-
- {'•'.repeat(ENCRYPTION_KEY.length)}
-
-
- )}
-
-
- Length:
- {ENCRYPTION_KEY.length}
-
-
-
- {/* Decrypt Button */}
-
-
- Re-Decrypt
-
-
- {/* Decrypted Result */}
- {decryptedText !== '' && (
-
-
- ✅ Decrypted Text
-
- setShowDecrypted(!showDecrypted)}
- style={styles.iconButton}
- >
- {showDecrypted ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
- {showDecrypted ? (
-
-
- {decryptedText}
-
-
- ) : (
-
-
- {'•'.repeat(Math.min(decryptedText.length, 50))}
-
-
- )}
-
-
-
- Length
- {decryptedText.length}
-
-
- Status
-
- ✓ Success
-
-
-
- Type
-
- {isNaN(Number(decryptedText)) ? 'Text' : 'Number'}
-
-
-
-
- )}
-
- {/* Instructions */}
-
- 📌 How to Use
-
- 1. Open this file in your code editor{'\n'}
- 2. Find the "HARDCODED INPUT" section at the top{'\n'}
- 3. Change CIPHER_TEXT and ENCRYPTION_KEY values{'\n'}
- 4. Save the file and reload the app{'\n'}
- 5. Screen will auto-decrypt on load
-
-
-
-
- );
-};
-
-export default DecryptionScreen;
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: colors.white,
- },
- scrollContainer: {
- padding: 16,
- paddingBottom: 40,
- },
- infoCard: {
- backgroundColor: '#E8F4FD',
- borderRadius: 12,
- padding: 16,
- marginBottom: 16,
- borderLeftWidth: 4,
- borderLeftColor: colors.primary,
- },
- infoTitle: {
- fontSize: 18,
- fontWeight: '700',
- color: '#333',
- marginBottom: 8,
- },
- infoText: {
- fontSize: 14,
- color: '#555',
- lineHeight: 20,
- marginBottom: 4,
- },
- infoSubText: {
- fontSize: 12,
- color: '#777',
- fontStyle: 'italic',
- },
- card: {
- backgroundColor: colors.primaryHighlight,
- borderRadius: 12,
- padding: 16,
- marginBottom: 16,
- elevation: 2,
- shadowColor: '#000',
- shadowOffset: { width: 0, height: 1 },
- shadowOpacity: 0.1,
- shadowRadius: 2,
- },
- resultCard: {
- backgroundColor: '#F0FDF4',
- borderWidth: 2,
- borderColor: '#10B981',
- },
- labelRow: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- marginBottom: 12,
- },
- label: {
- fontSize: 15,
- fontWeight: '600',
- color: '#333',
- },
- iconButton: {
- padding: 4,
- },
- iconRow: {
- flexDirection: 'row',
- gap: 12,
- },
- dataBox: {
- backgroundColor: '#F8F9FA',
- borderRadius: 10,
- padding: 14,
- borderWidth: 1,
- borderColor: '#E0E0E0',
- marginBottom: 10,
- },
- dataText: {
- fontSize: 14,
- color: '#222',
- lineHeight: 20,
- fontFamily: 'monospace',
- },
- hiddenText: {
- fontSize: 18,
- color: '#999',
- letterSpacing: 2,
- },
- infoRow: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- paddingTop: 8,
- borderTopWidth: 1,
- borderTopColor: '#E0E0E0',
- },
- infoLabel: {
- fontSize: 13,
- color: '#777',
- },
- infoValue: {
- fontSize: 13,
- fontWeight: '600',
- color: '#333',
- },
- decryptButton: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- gap: 10,
- backgroundColor: colors.primary,
- paddingVertical: 16,
- borderRadius: 12,
- marginBottom: 16,
- elevation: 3,
- },
- decryptButtonText: {
- color: '#fff',
- fontSize: 16,
- fontWeight: '700',
- },
- resultBox: {
- backgroundColor: '#fff',
- borderRadius: 10,
- padding: 16,
- borderWidth: 2,
- borderColor: '#10B981',
- minHeight: 80,
- marginBottom: 12,
- },
- resultText: {
- fontSize: 16,
- color: '#222',
- lineHeight: 24,
- fontWeight: '500',
- },
- statsRow: {
- flexDirection: 'row',
- justifyContent: 'space-around',
- paddingTop: 12,
- borderTopWidth: 1,
- borderTopColor: '#D1FAE5',
- },
- statItem: {
- alignItems: 'center',
- },
- statLabel: {
- fontSize: 12,
- color: '#059669',
- marginBottom: 4,
- fontWeight: '600',
- },
- statValue: {
- fontSize: 15,
- fontWeight: '700',
- color: '#333',
- },
- successText: {
- color: '#10B981',
- },
- instructionCard: {
- backgroundColor: '#FFFBEB',
- borderRadius: 12,
- padding: 16,
- borderWidth: 1,
- borderColor: '#FCD34D',
- },
- instructionTitle: {
- fontSize: 16,
- fontWeight: '700',
- color: '#92400E',
- marginBottom: 10,
- },
- instructionText: {
- fontSize: 13,
- color: '#78350F',
- lineHeight: 22,
- },
-});
diff --git a/src/Screens/index.ts b/src/Screens/index.ts
index f1ed94f..57a67d6 100644
--- a/src/Screens/index.ts
+++ b/src/Screens/index.ts
@@ -2,8 +2,7 @@ import Auction from "./Auction/Auction";
import PurchaseAuctionList from "./Auction/PurchaseAuctionList";
import PurchaseList from "./Auction/PurchaseList";
import Emd from "./Bidding/Emd";
-import StartBidding from "./Bidding/StartBidding";
-import StartReverseBidding from "./Bidding/StartReverseBidding";
+import AuctionBiddingScreen from "./Bidding/AuctionBiddingScreen";
import Home from "./Home/Home";
import KycWebViewScreen from "./Home/KycWebViewScreen";
import Login from "./Login/Login";
@@ -21,9 +20,9 @@ import ProceedToPurTender from "./Tender/ProceedToPurTender";
import PurchaseTenderList from "./Tender/PurchaseTenderList";
import Tender from "./Tender/Tender";
import TenderBidding from "./Tender/tenderBidding/TenderBidding";
-import DecryptionScreen from "./TestScreen/DecryptionScreen";
import DeviceBinding from "./Tender/DeviceBinding";
import DscBinding from "./Tender/DscBinding";
+import EnvelopeCSpreadsheet from "./Tender/EnvelopeC/EnvelopeCSpreadsheetScreen";
const Screens = {
@@ -35,9 +34,11 @@ const Screens = {
Tender,
PurchaseList,
Emd,
- StartBidding,
+ // Both bidding routes render the same pattern-driven screen; the route
+ // names are kept so existing navigation calls stay valid.
+ StartBidding: AuctionBiddingScreen,
+ StartReverseBidding: AuctionBiddingScreen,
PurchaseAuctionList,
- StartReverseBidding,
PurchaseTenderList,
ProceedToPurTender,
TenderBidding,
@@ -50,8 +51,8 @@ const Screens = {
ResetPassword,
KycWebViewScreen,
StageThreeScreen,
- DecryptionScreen,
DeviceBinding,
- DscBinding
+ DscBinding,
+ EnvelopeCSpreadsheet
}
-export default Screens
\ No newline at end of file
+export default Screens
diff --git a/src/Services/ApiCall/ApiUrls.tsx b/src/Services/ApiCall/ApiUrls.tsx
index d867022..647aa7b 100644
--- a/src/Services/ApiCall/ApiUrls.tsx
+++ b/src/Services/ApiCall/ApiUrls.tsx
@@ -1,8 +1,8 @@
-// const BASE_URL = `http://192.168.6.194:8090/`
-const BASE_URL = `https://eproc.godemo.in/`
-// const BASE_URL = `http://192.168.6.194/AuctionPostgre/api/auction/`
-// export const BASE_URL = `https://procurelinc.in/Auction/`
-// const BASE_URL = `http://192.168.29.226:8090/`
+import { env } from '../../core/config/env'
+
+// Endpoint catalog for the eproc REST backend. The host comes from the
+// environment config — never hardcode or comment-swap hosts here.
+const BASE_URL = env.apiBaseUrl
const GET_DESIGNATION = `${BASE_URL}contractor/getAllDesignationContractor`
const SAVE_CONTRACTOR_STAGE1 = `${BASE_URL}contractor/registerContractorStage1`
const GET_CONTRACTOR_CLASS = `${BASE_URL}contractor/allContractorClassSecond`
diff --git a/src/Services/ApiCall/Hooks.ts b/src/Services/ApiCall/Hooks.ts
index 89a38b6..b80e40a 100644
--- a/src/Services/ApiCall/Hooks.ts
+++ b/src/Services/ApiCall/Hooks.ts
@@ -1,4 +1,4 @@
-import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
+import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { contractorStageThreeSubmit, contractorStageTwoSubmit, fetchAuctionItem, fetchCityDropdown, fetchDashboard, fetchEmdCheck, fetchLiveAuction, fetchLiveTender, fetchMasterData, fetchPurchaseAuction, fetchPurchaseAuctionList, fetchPurchaseTender, fetchTenderById, getAuction, getDesignation, getRagistrationState, offlineAuctionPurchase, offlineTenderPurchase, partnerShipSubmit, proceedToBid, registerContractor, sendAadhaarOtp, submitEnvelopeA, submitEnvelopeB, uploadEnvelopeFile, userAuctionLogin, userLogin, verifyAadhaarOtp, verifyGstNumber, verifyPanHandler, viewAuctionDocumentDownload, viewAuctionDocuments, viewAuctionItemDocuments, fetchPurchaseAuctionPage, tenderDetails, fetchLiveTenderById, fetchContractorProfile, updateUserProfile, eSetuAdharVarification, fetchTenderBidDetailById, fetchTenderDashboard } from './apis';
// User
@@ -161,13 +161,24 @@ export const useFetchLiveAuction = () => {
// Auction Purchase offline
export const useOfflineAuctionPurchase = () => {
+ const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: any) => offlineAuctionPurchase(payload),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['fetchPurchaseAuction'] });
+ queryClient.invalidateQueries({ queryKey: ['fetchPurchaseAuctionPage'] });
+ queryClient.invalidateQueries({ queryKey: ['fetchLiveAuction'] });
+ },
});
};
export const useOfflineTenderPurchase = () => {
+ const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: any) => offlineTenderPurchase(payload),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['fetchPurchaseTender'] });
+ queryClient.invalidateQueries({ queryKey: ['fetchLiveTender'] });
+ },
});
};
@@ -182,13 +193,14 @@ export const useFetchTenderBidDetailById = (tenderId: string) => {
return useQuery({
queryKey: ['fetchTenderBidDetailById', tenderId],
queryFn: () => fetchTenderBidDetailById(tenderId),
+ enabled: !!tenderId,
});
}
export const useProceedToBid = (tenderId?: string) => {
return useQuery({
queryKey: ['proceedToBid', tenderId],
queryFn: () => proceedToBid(tenderId as string),
-
+ enabled: !!tenderId,
});
};
@@ -227,13 +239,17 @@ export const useRagistrationState = () => {
};
export const useUpdateUserProfile = () => {
+ const queryClient = useQueryClient();
return useMutation({
- mutationFn: (payload:any) => updateUserProfile(payload),
+ mutationFn: (payload: any) => updateUserProfile(payload),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['Profile'] });
+ },
});
};
-export const usefetchMasterData = () => {
+export const useFetchMasterData = () => {
return useQuery({
queryKey: ['MasterData'],
queryFn: () => fetchMasterData()
@@ -288,20 +304,29 @@ export const usePartnerShipSubmit = () =>
-export const useSubmitEnvelopeA = () =>
- useMutation({
+export const useSubmitEnvelopeA = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
mutationFn: submitEnvelopeA,
- })
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['proceedToBid'] }),
+ });
+};
-export const useSubmitEnvelopeB = () =>
- useMutation({
+export const useSubmitEnvelopeB = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
mutationFn: submitEnvelopeB,
- })
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['proceedToBid'] }),
+ });
+};
-export const useUploadEnvelopeFile = () =>
- useMutation({
+export const useUploadEnvelopeFile = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
mutationFn: uploadEnvelopeFile,
- })
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['proceedToBid'] }),
+ });
+};
@@ -314,22 +339,25 @@ export const usePurchaseAuctionList = () => {
export const useGetAuciton = (payload: { auctionId: string }) => {
return useQuery({
- queryKey: ['getAuction'],
- queryFn: () => getAuction(payload)
+ queryKey: ['getAuction', payload.auctionId],
+ queryFn: () => getAuction(payload),
+ enabled: !!payload.auctionId,
});
};
export const useGetAucitonDocument = (payload: { auctionId: number }) => {
return useQuery({
- queryKey: ['viewAuctionDocuments'],
- queryFn: () => viewAuctionDocuments(payload)
+ queryKey: ['viewAuctionDocuments', payload.auctionId],
+ queryFn: () => viewAuctionDocuments(payload),
+ enabled: !!payload.auctionId,
});
};
export const useAucitonItemDetail = (payload: { auctionItemId: number }) => {
return useQuery({
- queryKey: [],
- queryFn: () => viewAuctionItemDocuments(payload)
+ queryKey: ['viewAuctionItemDocuments', payload.auctionItemId],
+ queryFn: () => viewAuctionItemDocuments(payload),
+ enabled: !!payload.auctionItemId,
});
};
@@ -351,7 +379,8 @@ export const useTenderDetails = () => {
export const useFetchLiveTenderById = (tenderId: number) => {
return useQuery({
queryKey: ['fetchLiveTenderById', tenderId],
- queryFn: () => fetchLiveTenderById(tenderId)
+ queryFn: () => fetchLiveTenderById(tenderId),
+ enabled: !!tenderId,
});
};
@@ -360,3 +389,4 @@ export const useESetuAdharVarification = () => {
mutationFn: (payload: any) => eSetuAdharVarification(payload),
});
};
+
diff --git a/src/Services/ApiCall/apis.ts b/src/Services/ApiCall/apis.ts
index 466b2d6..591eaf8 100644
--- a/src/Services/ApiCall/apis.ts
+++ b/src/Services/ApiCall/apis.ts
@@ -1,10 +1,10 @@
-import axios from "axios";
import {
- AUCTION_DOCUMENT_DOWNLOAD, DASHBOARD_COUNT, EMD_CHECK, FORARD_AUCTION_ITEM, GET_AUCTION_DETAILS, GET_MY_AUCTION, GET_MY_TENDER, GET_TENDER_DETAILS, LIVE_AUCTION, LIVE_TENDER, LOG_IN_URL, PROCEED_PURCHASE_AUCTION, PROCEED_TO_BID, PURCHASE_TENDER_OFFLINE, TENDER_LOGIN, PURCHASE_AUCTION_LIST, PURCHASE_OFFLINE_AUCTION, REVERSE_AUCTION_ITEM, VIEW_AUCTION_DOCUMENTS, VIEW_AUCTION_ITEM_DETAIL, STAGETHREE, REJ_STAGE_TWO, GETCONTRACTORBYSTATE, GET_MASTER_DATA, ALL_DESIGNATION, GET_REGISTRATION_STATE, PARTNER_API_ENDPOINT, SAVE_CONTRACTOR_STAGE1, VERIFY_GST, VERIFY_ADHAR, SEND_ADHAROTP, SUBMIT_ENVELOPE_FILE_B, SUBMIT_ENVELOPE_FILE_A, SUBMIT_ENVELOPE_A, SUBMIT_ENVELOPE_B, VERIFY_ORGPAN, PURCHASE_AUCTION_PAGE, GET_TENDER_BY_ID, GET_CONTRACTOR_PROFILE, CONTRACTOR_PROFILE_UPDATE,
+ AUCTION_DOCUMENT_DOWNLOAD, DASHBOARD_COUNT, EMD_CHECK, FORARD_AUCTION_ITEM, GET_AUCTION_DETAILS, GET_MY_AUCTION, GET_MY_TENDER, GET_TENDER_DETAILS, LIVE_AUCTION, LIVE_TENDER, LOG_IN_URL, PROCEED_TO_BID, PURCHASE_TENDER_OFFLINE, TENDER_LOGIN, PURCHASE_AUCTION_LIST, PURCHASE_OFFLINE_AUCTION, REVERSE_AUCTION_ITEM, VIEW_AUCTION_DOCUMENTS, VIEW_AUCTION_ITEM_DETAIL, STAGETHREE, REJ_STAGE_TWO, GETCONTRACTORBYSTATE, GET_MASTER_DATA, ALL_DESIGNATION, GET_REGISTRATION_STATE, PARTNER_API_ENDPOINT, SAVE_CONTRACTOR_STAGE1, VERIFY_GST, VERIFY_ADHAR, SEND_ADHAROTP, SUBMIT_ENVELOPE_FILE_B, SUBMIT_ENVELOPE_FILE_A, SUBMIT_ENVELOPE_A, SUBMIT_ENVELOPE_B, VERIFY_ORGPAN, PURCHASE_AUCTION_PAGE, GET_TENDER_BY_ID, GET_CONTRACTOR_PROFILE, CONTRACTOR_PROFILE_UPDATE,
ESETU_VARIFICATION, TENDER_BID_DETAIL,
TENDER_DASHBOARD
} from "./ApiUrls";
import { apiCall } from "../../Axios/Axios";
+import { request } from "../../core/http/client";
// User
export type UploadArgs = {
@@ -16,33 +16,14 @@ export type UploadArgs = {
}
payload: any
}
-type DropdownOption = { label: string; value: number | string };
-
-type State = { stateId: number; stateName: string };
-type ContractorClass = { contClassId: number; contClassName?: string };
-type Org = { orgId: number; organizationType: string };
-type MSME = { msmeId: number; comName: string };
-type Social = { socialCategoryId: number; categoryName: string };
-type Designation = { designationid: number; designationName: string };
type City = { id: number; name: string };
-export const userLogin = async (payload: { emailAdd: string, password: string }): Promise => {
- try {
- // const response = await axios.post(LOG_IN_URL, { ...payload, divice: "mobileApp" });
- const response = await axios.post(TENDER_LOGIN, { ...payload, device: "mobileApp" });
- return response.data;
- } catch (error: any) {
- throw error.response?.data || error.message;
- }
+export const userLogin = async (payload: { emailAdd: string, password: string, device?: string }): Promise => {
+ return request({ method: 'post', url: TENDER_LOGIN, data: { ...payload, device: 'mobileApp' } });
};
-export const userAuctionLogin = async (payload: { emailAdd: string, password: string }): Promise => {
- try {
- const response = await axios.post(LOG_IN_URL, { ...payload, device: "mobileApp" });
- return response.data;
- } catch (error: any) {
- throw error.response?.data || error.message;
- }
+export const userAuctionLogin = async (payload: { emailAdd: string, password: string, device?: string }): Promise => {
+ return request({ method: 'post', url: LOG_IN_URL, data: { ...payload, device: 'mobileApp' } });
};
export const fetchDashboard = async (): Promise => {
@@ -261,12 +242,10 @@ export const fetchMasterData = async (): Promise => {
'get',
`${GET_MASTER_DATA}`
);
- console.log('data------------------', data)
return data;
};
export const getDesignation = async (): Promise => {
- const data = await axios.get(ALL_DESIGNATION).then((response) => response.data);
- return data;
+ return request({ method: 'get', url: ALL_DESIGNATION });
}
export const getRagistrationState = async (): Promise => {
@@ -300,14 +279,11 @@ export const partnerShipSubmit = (
};
export const registerContractor = async (payload: any): Promise => {
-
- try {
- const response = await axios.post(SAVE_CONTRACTOR_STAGE1, { ...payload, device: "mobileApp" });
- console.log('Stage One Res : ', response.data)
- return response.data;
- } catch (error: any) {
- throw error.response?.data || error.message;
- }
+ return request({
+ method: 'post',
+ url: SAVE_CONTRACTOR_STAGE1,
+ data: { ...payload, device: 'mobileApp' },
+ });
};
diff --git a/src/Services/Auth/__tests__/registrationRouting.test.ts b/src/Services/Auth/__tests__/registrationRouting.test.ts
new file mode 100644
index 0000000..d4b6716
--- /dev/null
+++ b/src/Services/Auth/__tests__/registrationRouting.test.ts
@@ -0,0 +1,87 @@
+import { applyRouteDecision, decideRegistrationRoute, decideRouteByOrgType } from '../registrationRouting';
+
+describe('decideRouteByOrgType', () => {
+ it('routes org types 4/5/6 to StageThree with params', () => {
+ for (const orgType of ['4', '5', '6', 4, 5, 6]) {
+ expect(decideRouteByOrgType({ otherId: 9, orgType })).toEqual({
+ kind: 'replace',
+ name: 'StageThree',
+ params: { otherId: 9, orgType },
+ });
+ }
+ });
+
+ it('routes org type 7 to Partnership', () => {
+ expect(decideRouteByOrgType({ otherId: 9, orgType: 7 }).name).toBe('Partnership');
+ });
+
+ it('routes any other org type to Home (was the nonexistent "Dashboard")', () => {
+ expect(decideRouteByOrgType({ otherId: 9, orgType: 8 })).toEqual({
+ kind: 'replace',
+ name: 'Home',
+ });
+ });
+});
+
+describe('decideRegistrationRoute', () => {
+ it('resets to Login on explicit failure shapes', () => {
+ expect(decideRegistrationRoute({ status: false })).toEqual({ kind: 'reset', name: 'Login' });
+ expect(
+ decideRegistrationRoute({ data: { message: 'No Response From Server', success: false } }),
+ ).toEqual({ kind: 'reset', name: 'Login' });
+ });
+
+ it('sends unregistered users (404/401) to Registration', () => {
+ expect(decideRegistrationRoute({ statusCode: 404 })?.name).toBe('Registration');
+ expect(decideRegistrationRoute({ statusCode: 401 })?.name).toBe('Registration');
+ });
+
+ it('maps registrationStatus values', () => {
+ const res = (registrationStatus: any) => ({
+ statusCode: 200,
+ data: { registrationStatus, contOtherId: 1, contOrganization: 4 },
+ });
+
+ expect(decideRegistrationRoute(res(null))?.name).toBe('Registration');
+ expect(decideRegistrationRoute(res(undefined))?.name).toBe('Registration');
+ expect(decideRegistrationRoute(res('Pending'))).toEqual({
+ kind: 'replace',
+ name: 'StageThree',
+ params: { otherId: 1, orgType: 4 },
+ });
+ expect(decideRegistrationRoute(res('Complete'))?.name).toBe('Home');
+ expect(decideRegistrationRoute(res('Completed'))?.name).toBe('Home');
+ expect(decideRegistrationRoute(res('SomethingElse'))?.name).toBe('Login');
+ });
+
+ it('returns null for unclassifiable responses (caller decides the fallback)', () => {
+ expect(decideRegistrationRoute(undefined)).toBeNull();
+ expect(decideRegistrationRoute({ statusCode: 500 })).toBeNull();
+ expect(decideRegistrationRoute({ statusCode: 200 })).toBeNull(); // no data
+ });
+});
+
+describe('applyRouteDecision', () => {
+ const nav = () => ({ reset: jest.fn(), replace: jest.fn() });
+
+ it('reset clears history to the single target route', () => {
+ const navigation = nav();
+ applyRouteDecision(navigation, { kind: 'reset', name: 'Login' });
+ expect(navigation.reset).toHaveBeenCalledWith({ index: 0, routes: [{ name: 'Login' }] });
+ expect(navigation.replace).not.toHaveBeenCalled();
+ });
+
+ it('replace swaps the current screen and forwards params', () => {
+ const navigation = nav();
+ const params = { otherId: 1, orgType: 4 };
+ applyRouteDecision(navigation, { kind: 'replace', name: 'StageThree', params });
+ expect(navigation.replace).toHaveBeenCalledWith('StageThree', params);
+ expect(navigation.reset).not.toHaveBeenCalled();
+ });
+
+ it('replace without params passes undefined (not an empty object)', () => {
+ const navigation = nav();
+ applyRouteDecision(navigation, { kind: 'replace', name: 'Home' });
+ expect(navigation.replace).toHaveBeenCalledWith('Home', undefined);
+ });
+});
diff --git a/src/Services/Auth/registrationRouting.ts b/src/Services/Auth/registrationRouting.ts
new file mode 100644
index 0000000..d336ba9
--- /dev/null
+++ b/src/Services/Auth/registrationRouting.ts
@@ -0,0 +1,82 @@
+/**
+ * Post-authentication routing policy — the single source of truth for where a
+ * tender user lands based on their backend registration state.
+ *
+ * Splash (cold start with a session) and Login (fresh sign-in) previously
+ * carried duplicated, already-diverging copies of this logic. They now share
+ * this pure decision function; each screen keeps only its own fallback for
+ * responses this function cannot classify (returns null).
+ */
+import NavigationString from '../../Constant/NavigationString';
+
+export type RouteDecision = {
+ /** reset clears history (auth boundary); replace swaps the current screen. */
+ kind: 'reset' | 'replace';
+ name: string;
+ params?: Record;
+};
+
+/** Incomplete ("Pending") registrations resume at the stage their org type requires. */
+export const decideRouteByOrgType = (params: {
+ otherId: unknown;
+ orgType: unknown;
+}): RouteDecision => {
+ const orgType = String(params.orgType ?? '');
+ if (orgType === '4' || orgType === '5' || orgType === '6') {
+ return { kind: 'replace', name: NavigationString.StageThreeScreen, params };
+ }
+ if (orgType === '7') {
+ return { kind: 'replace', name: NavigationString.Partnership, params };
+ }
+ // The historical code targeted 'Dashboard' — a route that has never existed,
+ // so the navigation was silently dropped and the user was stranded on the
+ // splash screen. Home is the correct in-app destination.
+ return { kind: 'replace', name: NavigationString.Home };
+};
+
+/**
+ * Maps a registration-state response to a destination, or null when the
+ * response doesn't match any known shape (caller applies its own fallback).
+ */
+export const decideRegistrationRoute = (res: any): RouteDecision | null => {
+ if (res?.status === false) {
+ return { kind: 'reset', name: NavigationString.Login };
+ }
+ if (res?.data?.message === 'No Response From Server' && res?.data?.success === false) {
+ return { kind: 'reset', name: NavigationString.Login };
+ }
+ if (res?.statusCode === 404 || res?.statusCode === 401) {
+ return { kind: 'replace', name: NavigationString.Registration };
+ }
+ if (res?.statusCode === 200 && res?.data) {
+ const { registrationStatus, contOtherId, contOrganization } = res.data;
+ const params = { otherId: contOtherId, orgType: contOrganization };
+ switch (registrationStatus) {
+ case null:
+ case undefined:
+ return { kind: 'replace', name: NavigationString.Registration };
+ case 'Pending':
+ return decideRouteByOrgType(params);
+ case 'Complete':
+ case 'Completed':
+ return { kind: 'replace', name: NavigationString.Home };
+ default:
+ return { kind: 'replace', name: NavigationString.Login };
+ }
+ }
+ return null;
+};
+
+type NavigationLike = {
+ reset: (state: { index: number; routes: { name: string }[] }) => unknown;
+ replace: (route: string, params?: Record) => void;
+};
+
+/** Executes a decision against the screen's navigation object. */
+export const applyRouteDecision = (navigation: NavigationLike, decision: RouteDecision): void => {
+ if (decision.kind === 'reset') {
+ navigation.reset({ index: 0, routes: [{ name: decision.name }] });
+ } else {
+ navigation.replace(decision.name, decision.params);
+ }
+};
diff --git a/src/Services/Auth/session.ts b/src/Services/Auth/session.ts
new file mode 100644
index 0000000..df238c5
--- /dev/null
+++ b/src/Services/Auth/session.ts
@@ -0,0 +1,18 @@
+/**
+ * Application-level session lifecycle. This is the ONLY sanctioned way to end
+ * a session — it clears every session-scoped cache in one place so the
+ * historical desyncs (state cleared but query cache warm, or persisted token
+ * surviving an in-memory logout) cannot recur.
+ */
+import { store } from '../../Redux/Store';
+import { removeToken } from '../../Redux/Slices/Token';
+import { queryClient } from '../../core/query/queryClient';
+import { appEvents } from '../../core/events/appEvents';
+import { logger } from '../../core/observability/logger';
+
+export const performLogout = (): void => {
+ logger.info('auth', 'logging out — clearing session state and caches');
+ store.dispatch(removeToken()); // persisted via keychain-backed storage
+ queryClient.clear(); // drop cached API data (may contain PII)
+ appEvents.emit('auth/logout', undefined); // sockets etc. subscribe to this
+};
diff --git a/src/Services/Encryption/__tests__/encrypt.test.ts b/src/Services/Encryption/__tests__/encrypt.test.ts
new file mode 100644
index 0000000..f738bd1
--- /dev/null
+++ b/src/Services/Encryption/__tests__/encrypt.test.ts
@@ -0,0 +1,29 @@
+import CryptoJS from 'crypto-js';
+import { encryptFileBytesToBase64, sha256HexOfBytes } from '../encrypt';
+
+describe('Envelope-C file crypto', () => {
+ it('uses SHA-1-derived AES and returns raw ciphertext bytes as Base64', () => {
+ const plain = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8]);
+ const key = 'server-envelope-key';
+ const encoded = encryptFileBytesToBase64(plain, key);
+
+ expect(encoded).toBeTruthy();
+ const ciphertext = CryptoJS.enc.Base64.parse(encoded as string);
+ expect(ciphertext.sigBytes % 16).toBe(0);
+ expect((encoded as string).startsWith('U2FsdGVk')).toBe(false);
+
+ const sha1 = CryptoJS.SHA1(key);
+ const aesKey = CryptoJS.lib.WordArray.create(sha1.words.slice(0, 4));
+ const decrypted = CryptoJS.AES.decrypt(encoded as string, aesKey, {
+ mode: CryptoJS.mode.ECB,
+ padding: CryptoJS.pad.Pkcs7,
+ });
+ expect(CryptoJS.enc.Hex.stringify(decrypted)).toBe('000102030405060708');
+ });
+
+ it('hashes the unencrypted workbook bytes', () => {
+ expect(sha256HexOfBytes(new Uint8Array([104, 101, 108, 108, 111]))).toBe(
+ '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824',
+ );
+ });
+});
diff --git a/src/Services/Encryption/encrypt.tsx b/src/Services/Encryption/encrypt.tsx
index 88184b9..812afb4 100644
--- a/src/Services/Encryption/encrypt.tsx
+++ b/src/Services/Encryption/encrypt.tsx
@@ -25,11 +25,6 @@ export function encrypt(strToEncrypt: string, encryptKey: string): string | null
return null;
}
- console.log('\n🔐 AES ENCRYPTION STARTED');
- console.log('═══════════════════════════════════════════════════════');
- console.log('📝 Plain Text:', strToEncrypt);
- console.log('🔑 Encryption Key:', encryptKey);
-
const secretKey = setKey(encryptKey);
const encrypted = CryptoJS.AES.encrypt(strToEncrypt, secretKey, {
@@ -39,10 +34,6 @@ export function encrypt(strToEncrypt: string, encryptKey: string): string | null
const base64Result = encrypted.toString();
- console.log('✅ Encryption successful');
- console.log('📤 Encrypted (Base64):', base64Result);
- console.log('═══════════════════════════════════════════════════════\n');
-
return base64Result;
} catch (error) {
console.error('❌ Exception in encrypt():', error);
@@ -60,11 +51,6 @@ export function decrypt(responseString: string, encryptKey: string): string | nu
return null;
}
- console.log('\n🔓 AES DECRYPTION STARTED');
- console.log('═══════════════════════════════════════════════════════');
- console.log('📝 Cipher Text (Base64):', responseString);
- console.log('🔑 Decryption Key:', encryptKey);
-
const secretKey = setKey(encryptKey);
const decrypted = CryptoJS.AES.decrypt(responseString, secretKey, {
@@ -79,10 +65,6 @@ export function decrypt(responseString: string, encryptKey: string): string | nu
return null;
}
- console.log('✅ Decryption successful');
- console.log('📥 Decrypted Text:', utf8Result);
- console.log('═══════════════════════════════════════════════════════\n');
-
return utf8Result;
} catch (error) {
console.error('❌ Exception in decrypt():', error);
@@ -122,27 +104,18 @@ export function encryptWithRSA(text: string, publicKey: string): string | null {
return null;
}
- console.log('\n🔐 RSA ENCRYPTION STARTED');
- console.log('═══════════════════════════════════════════════════════');
- console.log('📝 Text to encrypt:', text);
- console.log('🔑 Original Public key length:', publicKey.length,publicKey);
-
// Step 1: Remove all non-base64 characters
// Matches: publicKey.replaceAll("[^A-Za-z0-9+/=]", "")
let cleanedKey = publicKey.replace(/[^A-Za-z0-9+/=]/g, '');
- console.log(' Cleaned key length:', cleanedKey.length);
// Step 2: Convert to PEM format (X509EncodedKeySpec equivalent)
const pemKey = convertToPEM(cleanedKey);
- console.log(' ✅ Converted to PEM format');
// Step 3: Create JSEncrypt instance and set public key
const encryptor = new JSEncrypt();
encryptor.setPublicKey(pemKey);
- console.log(' ✅ Public key loaded');
// Step 4: Encrypt the data
- console.log(' 🔄 Encrypting...');
const encrypted = encryptor.encrypt(text);
if (!encrypted) {
@@ -150,11 +123,6 @@ export function encryptWithRSA(text: string, publicKey: string): string | null {
return null;
}
- console.log('✅ RSA Encryption successful');
- console.log('📤 Encrypted length:', encrypted.length);
- console.log('📤 Encrypted preview:', encrypted);
- console.log('═══════════════════════════════════════════════════════\n');
-
return encrypted;
} catch (error) {
console.error('❌ RSA Encryption error:', error);
@@ -163,4 +131,41 @@ export function encryptWithRSA(text: string, publicKey: string): string | null {
}
}
+/* Envelope-C file contract: AES-128/ECB/PKCS5 with the first 16 bytes of
+ * SHA-1(K), uploaded as raw ciphertext bytes. Text values continue to use
+ * the SHA-256-derived key above. */
+function bytesToWordArray(bytes: Uint8Array): CryptoJS.lib.WordArray {
+ const words: number[] = [];
+ for (let i = 0; i < bytes.length; i++) {
+ words[i >>> 2] |= bytes[i] << (24 - (i % 4) * 8);
+ }
+ return CryptoJS.lib.WordArray.create(words, bytes.length);
+}
+
+function setFileKey(encryptKey: string): CryptoJS.lib.WordArray {
+ return CryptoJS.lib.WordArray.create(CryptoJS.SHA1(encryptKey).words.slice(0, 4));
+}
+
+/** Returns Base64 representing the raw AES ciphertext bytes. */
+export function encryptFileBytesToBase64(
+ bytes: Uint8Array,
+ encryptKey: string,
+): string | null {
+ try {
+ if (!bytes?.length || !encryptKey) return null;
+ const encrypted = CryptoJS.AES.encrypt(bytesToWordArray(bytes), setFileKey(encryptKey), {
+ mode: CryptoJS.mode.ECB,
+ padding: CryptoJS.pad.Pkcs7,
+ });
+ return CryptoJS.enc.Base64.stringify(encrypted.ciphertext);
+ } catch {
+ return null;
+ }
+}
+
+/** SHA-256 hex digest of the unencrypted workbook bytes. */
+export function sha256HexOfBytes(bytes: Uint8Array): string {
+ return CryptoJS.SHA256(bytesToWordArray(bytes)).toString(CryptoJS.enc.Hex);
+}
+
export { setKey };
diff --git a/src/Spreadsheet/README.md b/src/Spreadsheet/README.md
new file mode 100644
index 0000000..3fae986
--- /dev/null
+++ b/src/Spreadsheet/README.md
@@ -0,0 +1,163 @@
+# Native BOQ Spreadsheet Editor (Envelope-C)
+
+A fully native React Native Excel-like editor for the Envelope-C (BOQ) workflow.
+It opens existing `.xlsx` BOQ files, lets users edit **only the cells the
+workbook itself marks editable**, recalculates formulas live, validates input,
+and saves the workbook back **without altering its structure or metadata** — so
+the result stays byte-compatible with Microsoft Excel and the existing
+Envelope-C server contract.
+
+No WebView. No HTML spreadsheet. No web-only libraries. No backend calculation.
+Everything runs offline.
+
+---
+
+## Architecture (hybrid)
+
+```
+React Native (UI, selection, editing, clipboard, undo/redo, validation, search)
+ │
+ ├── HyperFormula ── dependency graph, recalculation, cycle detection
+ │
+ └── WorkbookManager ── picks a backend:
+ ├── NativePoiBackend → ExcelBridge → Apache POI (Android) [preferred]
+ └── JsExcelBackend → ExcelJS (pure JS) [fallback / iOS]
+```
+
+- **Apache POI (Android native)** is the authoritative parser/writer. It reads
+ every metadata feature (styles, merges, comments, hidden rows/cols, frozen
+ panes, protection, cell locking, data validation, number formats) and, on
+ save, rewrites **only the edited cell values** — everything else is untouched.
+- **HyperFormula** runs in JS purely for calculation: it builds the dependency
+ graph, recalculates dependents on each edit, and detects/rejects cycles.
+- **ExcelJS** is a cross-platform fallback so the editor also runs on iOS and in
+ Jest, and anywhere the native module isn't built. Both backends expose the
+ same lock/formula/protection metadata, so the editability contract is
+ identical regardless of which one runs.
+
+### Modules
+
+| Concern | File |
+| --- | --- |
+| Domain types + `isCellEditable` | `types.ts` |
+| A1 ↔ coords, number/date formatting, geometry | `utils/*` |
+| Calculation engine | `engine/FormulaEngine.ts` |
+| Validation | `engine/ValidationEngine.ts` |
+| Selection / clipboard / history | `engine/SelectionManager.ts`, `ClipboardManager.ts`, `UndoRedoManager.ts` |
+| Backend strategy | `engine/backends/*`, `engine/WorkbookManager.ts` |
+| Parse/write (JS fallback) | `engine/WorkbookParser.ts`, `engine/WorkbookWriter.ts` |
+| Perf tuning | `engine/PerformanceManager.ts` |
+| File IO (picker, RNFS, base64) | `engine/FileManager.ts` |
+| Native bridge | `native/NativeExcelModule.ts`, `native/ExcelBridge.ts`, `native/dto.ts` |
+| State hook | `hooks/useSpreadsheet.ts` |
+| UI | `components/*` (`SpreadsheetView`, `SpreadsheetGrid`, `CellView`, …) |
+
+---
+
+## Editability — workbook-defined
+
+The single source of truth is `isCellEditable(cell, sheetProtected)`:
+
+- **Formula cells** → never editable.
+- **Locked cells** → read-only **when the sheet is protected** (Excel semantics).
+- **Unlocked, non-formula cells** → editable.
+- Blank cells on a protected sheet are read-only unless the template explicitly
+ unlocked them.
+
+Read-only cells render with a distinct background; formula/locked cells show a
+lock glyph. Paste, clear and fill can never touch a read-only cell. Users cannot
+insert/delete rows or columns, rename/add/remove sheets, or edit formulas —
+the workbook structure is immutable by design.
+
+---
+
+## Usage
+
+```tsx
+import { useSpreadsheet, SpreadsheetView } from '../Spreadsheet';
+
+const ss = useSpreadsheet();
+useEffect(() => { ss.loadFromBytes(bytes, 'boq.xlsx'); }, []);
+
+// render
+
+
+// submit
+const errors = ss.validateAll();
+if (errors.length === 0) {
+ const outBytes = await ss.exportBytes(); // preserves the original workbook
+}
+```
+
+Envelope-C wires this in `Screens/Tender/EnvelopeC/` (`useEnvelopeCWorkbook` +
+`EnvelopeCSpreadsheetScreen`), reached from `EnvelopeCExcelPreview`'s launcher.
+
+---
+
+## Native module build notes (Android / Apache POI)
+
+Already wired:
+
+- `android/app/src/main/java/com/procure/excel/ExcelModule.kt` + `ExcelPackage.kt`
+- Registered in `MainApplication.kt` (`add(ExcelPackage())`)
+- `android/app/build.gradle`: `implementation("org.apache.poi:poi-ooxml:5.2.5")`
+ and `packagingOptions` excludes for POI's duplicate `META-INF` entries.
+
+Rebuild the Android app (`npx react-native run-android`) so the module links.
+Until then the JS backend is used automatically.
+
+**POI-on-Android caveat.** POI references a few `java.awt` types in image-sizing
+/ autosize code paths. This module never calls those paths (no image insert, no
+autosize), so value/style/merge/comment/protection read-write works. If a
+specific device surfaces a stray `java.awt` `NoClassDefFoundError`, switch the
+dependency to an Android-shaded POI fork, e.g.:
+
+```gradle
+// Alternative if stock POI hits java.awt on a device:
+// implementation "com.github.SUPERCILEX.poi-android:poi:"
+```
+
+and keep the same `ExcelModule` code.
+
+**iOS.** No POI module is provided; iOS uses the ExcelJS backend automatically
+(full metadata preservation via ExcelJS). A native iOS writer can be added later
+behind the same `WorkbookBackend` interface without touching the RN layer.
+
+### Dependencies added
+
+- `hyperformula@^2.7.0` — calculation engine
+- `@shopify/flash-list@1.7.6` — virtualization
+
+(`exceljs`, `buffer`, `react-native-fs`, `@react-native-documents/picker`,
+`react-native-gesture-handler`, `react-native-reanimated` were already present.)
+
+---
+
+## Tests
+
+Pure-logic unit tests run under Jest (`npm test`):
+
+- `__tests__/FormulaEngine.test.ts` — SUM/AVERAGE/MIN/MAX/ROUND/IF/AND, nested
+ formulas, incremental recalculation, cycle rejection, error surfacing.
+- `__tests__/ValidationEngine.test.ts` — required/min-max/list/duplicate,
+ formula-error surfacing, editable-only rule enforcement.
+- `__tests__/utils.test.ts` — address conversions, number/date formatting,
+ input parsing, `isCellEditable`.
+- `__tests__/managers.test.ts` — selection navigation (hidden-line skipping),
+ clipboard copy/paste tiling with read-only skipping, TSV round-trip.
+
+---
+
+## Performance
+
+- Vertical virtualization via FlashList; horizontal panning via a single shared
+ ScrollView so header + body never drift.
+- Memoized cells (`CellView`) keyed on primitive props; a repaint only
+ re-renders cells whose value/flags changed.
+- Uniform row height for smooth virtualization (original per-row heights are
+ preserved in the model and on export).
+- Large files parse after interactions so the loader paints first.
+- Incremental recalculation — only dependents of an edit are recomputed and
+ repainted.
+
+
diff --git a/src/Spreadsheet/__fixtures__/buildModel.ts b/src/Spreadsheet/__fixtures__/buildModel.ts
new file mode 100644
index 0000000..65f66aa
--- /dev/null
+++ b/src/Spreadsheet/__fixtures__/buildModel.ts
@@ -0,0 +1,76 @@
+/**
+ * Test fixtures — build minimal `WorkbookModel`s for unit tests.
+ * Lives outside `__tests__` so Jest doesn't collect it as a suite.
+ */
+import {
+ CellModel,
+ CellType,
+ CellValue,
+ DataValidationRule,
+ SheetModel,
+ WorkbookModel,
+ cellKey,
+} from '../types';
+
+export interface CellSpec {
+ r: number;
+ c: number;
+ raw?: CellValue;
+ formula?: string | null;
+ type?: CellType;
+ locked?: boolean;
+ numFmt?: string;
+ validation?: DataValidationRule;
+}
+
+export const makeSheet = (
+ specs: CellSpec[],
+ opts: { index?: number; name?: string; protected?: boolean } = {},
+): SheetModel => {
+ const cells = new Map();
+ let maxRow = 0;
+ let maxCol = 0;
+ for (const s of specs) {
+ maxRow = Math.max(maxRow, s.r);
+ maxCol = Math.max(maxCol, s.c);
+ cells.set(cellKey(s.r, s.c), {
+ row: s.r,
+ col: s.c,
+ raw: s.raw ?? null,
+ formula: s.formula ?? null,
+ type: s.type ?? (typeof s.raw === 'number' ? 'number' : 'text'),
+ numFmt: s.numFmt,
+ locked: s.locked ?? false,
+ validation: s.validation,
+ dropdownOptions: s.validation?.kind === 'list' ? s.validation.list : undefined,
+ });
+ }
+ return {
+ id: `sheet-${opts.index ?? 0}`,
+ name: opts.name ?? `Sheet${(opts.index ?? 0) + 1}`,
+ index: opts.index ?? 0,
+ hfSheetId: opts.index ?? 0,
+ rowCount: maxRow + 1,
+ colCount: maxCol + 1,
+ cells,
+ columnWidths: new Map(),
+ rowHeights: new Map(),
+ hiddenRows: new Set(),
+ hiddenColumns: new Set(),
+ merges: [],
+ frozenRows: 0,
+ frozenCols: 0,
+ state: 'visible',
+ protected: opts.protected ?? false,
+ defaultColWidth: 96,
+ defaultRowHeight: 40,
+ };
+};
+
+export const makeModel = (sheets: SheetModel[]): WorkbookModel => ({
+ sheets,
+ activeSheetIndex: 0,
+ fileName: 'test.xlsx',
+});
+
+
diff --git a/src/Spreadsheet/__tests__/FormulaEngine.test.ts b/src/Spreadsheet/__tests__/FormulaEngine.test.ts
new file mode 100644
index 0000000..e4e3777
--- /dev/null
+++ b/src/Spreadsheet/__tests__/FormulaEngine.test.ts
@@ -0,0 +1,87 @@
+import FormulaEngine from '../engine/FormulaEngine';
+import { makeModel, makeSheet } from '../__fixtures__/buildModel';
+
+describe('FormulaEngine (HyperFormula)', () => {
+ const build = (specs: Parameters[0]) =>
+ FormulaEngine.fromModel(makeModel([makeSheet(specs)]));
+
+ it('evaluates SUM across a range', () => {
+ const engine = build([
+ { r: 0, c: 0, raw: 10 },
+ { r: 1, c: 0, raw: 20 },
+ { r: 2, c: 0, raw: 30 },
+ { r: 3, c: 0, formula: 'SUM(A1:A3)' },
+ ]);
+ expect(engine.getValue(0, 3, 0)).toBe(60);
+ engine.destroy();
+ });
+
+ it('evaluates AVERAGE, MIN, MAX, ROUND, IF, nested formulas', () => {
+ const engine = build([
+ { r: 0, c: 0, raw: 2 },
+ { r: 1, c: 0, raw: 4 },
+ { r: 2, c: 0, raw: 6 },
+ { r: 0, c: 1, formula: 'AVERAGE(A1:A3)' }, // 4
+ { r: 1, c: 1, formula: 'MIN(A1:A3)' }, // 2
+ { r: 2, c: 1, formula: 'MAX(A1:A3)' }, // 6
+ { r: 3, c: 1, formula: 'ROUND(B1*1.5,0)' }, // 6
+ { r: 4, c: 1, formula: 'IF(A3>A1,"big","small")' }, // big
+ { r: 5, c: 1, formula: 'IF(AND(A1>0,A2>0),SUM(A1:A3),0)' }, // 12
+ ]);
+ expect(engine.getValue(0, 0, 1)).toBe(4);
+ expect(engine.getValue(0, 1, 1)).toBe(2);
+ expect(engine.getValue(0, 2, 1)).toBe(6);
+ expect(engine.getValue(0, 3, 1)).toBe(6);
+ expect(engine.getValue(0, 4, 1)).toBe('big');
+ expect(engine.getValue(0, 5, 1)).toBe(12);
+ engine.destroy();
+ });
+
+ it('recalculates dependents after an edit and reports changed cells', () => {
+ const engine = build([
+ { r: 0, c: 0, raw: 5 }, // A1
+ { r: 0, c: 1, raw: 3 }, // B1
+ { r: 0, c: 2, formula: 'A1*B1' }, // C1 = 15
+ { r: 1, c: 2, formula: 'C1+1' }, // C2 = 16
+ ]);
+ expect(engine.getValue(0, 0, 2)).toBe(15);
+
+ const result = engine.setCell(0, 0, 0, null, 10); // A1 = 10
+ expect(result.ok).toBe(true);
+ expect(engine.getValue(0, 0, 2)).toBe(30); // C1 recalculated
+ expect(engine.getValue(0, 1, 2)).toBe(31); // C2 chained
+ // C1 and C2 should be among the reported changes.
+ const changed = result.changes.map(c => `${c.row},${c.col}`);
+ expect(changed).toEqual(expect.arrayContaining(['0,2', '1,2']));
+ engine.destroy();
+ });
+
+ it('rejects edits that create a circular reference and rolls back', () => {
+ const engine = build([
+ { r: 0, c: 0, raw: 1 }, // A1
+ { r: 0, c: 1, formula: 'A1+1' }, // B1
+ ]);
+ // Make A1 reference B1 -> A1 -> B1 cycle.
+ const result = engine.setCell(0, 0, 0, 'B1+1', null);
+ expect(result.ok).toBe(false);
+ expect(result.cycle).toBe(true);
+ // A1 restored to its prior literal.
+ expect(engine.getValue(0, 0, 0)).toBe(1);
+ engine.destroy();
+ });
+
+ it('preserves the original formula text for retrieval', () => {
+ const engine = build([{ r: 0, c: 0, raw: 4 }, { r: 0, c: 1, formula: 'SQRT(A1)' }]);
+ expect(engine.getFormula(0, 0, 1)).toBe('=SQRT(A1)');
+ expect(engine.getValue(0, 0, 1)).toBe(2);
+ engine.destroy();
+ });
+
+ it('surfaces evaluation errors (#DIV/0!)', () => {
+ const engine = build([{ r: 0, c: 0, raw: 0 }, { r: 0, c: 1, formula: '1/A1' }]);
+ expect(engine.getValue(0, 0, 1)).toBe('#DIV/0!');
+ engine.destroy();
+ });
+});
+
+
diff --git a/src/Spreadsheet/__tests__/ValidationEngine.test.ts b/src/Spreadsheet/__tests__/ValidationEngine.test.ts
new file mode 100644
index 0000000..f8480b4
--- /dev/null
+++ b/src/Spreadsheet/__tests__/ValidationEngine.test.ts
@@ -0,0 +1,89 @@
+import FormulaEngine from '../engine/FormulaEngine';
+import ValidationEngine from '../engine/ValidationEngine';
+import { makeModel, makeSheet } from '../__fixtures__/buildModel';
+import { ValidationError } from '../types';
+
+const errorAt = (errors: ValidationError[], r: number, c: number) =>
+ errors.find(e => e.address.row === r && e.address.col === c);
+
+describe('ValidationEngine', () => {
+ it('flags a required editable cell that is empty', () => {
+ const sheet = makeSheet([
+ { r: 0, c: 0, raw: null, type: 'number', locked: false, validation: { kind: 'none', required: true } },
+ ]);
+ const model = makeModel([sheet]);
+ const engine = FormulaEngine.fromModel(model);
+ const errors = ValidationEngine.validateWorkbook(model, engine);
+ expect(errorAt(errors, 0, 0)?.ruleKind).toBe('required');
+ engine.destroy();
+ });
+
+ it('enforces decimal min/max bounds', () => {
+ const sheet = makeSheet([
+ { r: 0, c: 0, raw: 500, type: 'number', locked: false, validation: { kind: 'decimal', min: 0, max: 100 } },
+ { r: 1, c: 0, raw: 50, type: 'number', locked: false, validation: { kind: 'decimal', min: 0, max: 100 } },
+ ]);
+ const model = makeModel([sheet]);
+ const engine = FormulaEngine.fromModel(model);
+ const errors = ValidationEngine.validateWorkbook(model, engine);
+ expect(errorAt(errors, 0, 0)).toBeTruthy();
+ expect(errorAt(errors, 1, 0)).toBeFalsy();
+ engine.destroy();
+ });
+
+ it('enforces list membership', () => {
+ const rule = { kind: 'list' as const, list: ['Yes', 'No'] };
+ const sheet = makeSheet([
+ { r: 0, c: 0, raw: 'Maybe', type: 'dropdown', locked: false, validation: rule },
+ { r: 1, c: 0, raw: 'Yes', type: 'dropdown', locked: false, validation: rule },
+ ]);
+ const model = makeModel([sheet]);
+ const engine = FormulaEngine.fromModel(model);
+ const errors = ValidationEngine.validateWorkbook(model, engine);
+ expect(errorAt(errors, 0, 0)).toBeTruthy();
+ expect(errorAt(errors, 1, 0)).toBeFalsy();
+ engine.destroy();
+ });
+
+ it('detects duplicates within a column', () => {
+ const rule = { kind: 'none' as const, noDuplicates: true };
+ const sheet = makeSheet([
+ { r: 0, c: 0, raw: 'ITEM-1', locked: false, validation: rule },
+ { r: 1, c: 0, raw: 'ITEM-1', locked: false, validation: rule },
+ ]);
+ const model = makeModel([sheet]);
+ const engine = FormulaEngine.fromModel(model);
+ const errors = ValidationEngine.validateWorkbook(model, engine);
+ expect(errorAt(errors, 1, 0)?.ruleKind).toBe('duplicate');
+ engine.destroy();
+ });
+
+ it('surfaces formula errors even on read-only cells', () => {
+ const sheet = makeSheet(
+ [
+ { r: 0, c: 0, raw: 0, locked: true },
+ { r: 0, c: 1, formula: '1/A1', locked: true }, // #DIV/0!, locked on a protected sheet
+ ],
+ { protected: true },
+ );
+ const model = makeModel([sheet]);
+ const engine = FormulaEngine.fromModel(model);
+ const errors = ValidationEngine.validateWorkbook(model, engine);
+ expect(errorAt(errors, 0, 1)?.message).toContain('#DIV/0!');
+ engine.destroy();
+ });
+
+ it('does not rule-validate non-editable (locked, protected) input cells', () => {
+ const sheet = makeSheet(
+ [{ r: 0, c: 0, raw: null, type: 'number', locked: true, validation: { kind: 'none', required: true } }],
+ { protected: true },
+ );
+ const model = makeModel([sheet]);
+ const engine = FormulaEngine.fromModel(model);
+ const errors = ValidationEngine.validateWorkbook(model, engine);
+ expect(errorAt(errors, 0, 0)).toBeFalsy();
+ engine.destroy();
+ });
+});
+
+
diff --git a/src/Spreadsheet/__tests__/managers.test.ts b/src/Spreadsheet/__tests__/managers.test.ts
new file mode 100644
index 0000000..83e190a
--- /dev/null
+++ b/src/Spreadsheet/__tests__/managers.test.ts
@@ -0,0 +1,59 @@
+import SelectionManager from '../engine/SelectionManager';
+import ClipboardManager from '../engine/ClipboardManager';
+import { Address } from '../types';
+
+describe('SelectionManager', () => {
+ const bounds = { rowCount: 10, colCount: 5 };
+
+ it('moves and clamps at edges', () => {
+ let s = SelectionManager.create({ row: 0, col: 0 });
+ s = SelectionManager.move(s, -1, 0, bounds);
+ expect(s.active).toEqual({ row: 0, col: 0 }); // clamped at top
+ s = SelectionManager.move(s, 1, 1, bounds);
+ expect(s.active).toEqual({ row: 1, col: 1 });
+ });
+
+ it('skips hidden rows during navigation', () => {
+ const s0 = SelectionManager.create({ row: 0, col: 0 });
+ const s1 = SelectionManager.move(s0, 1, 0, { ...bounds, hiddenRows: new Set([1, 2]) });
+ expect(s1.active.row).toBe(3);
+ });
+
+ it('extends a range from the anchor', () => {
+ let s = SelectionManager.create({ row: 1, col: 1 });
+ s = SelectionManager.extendTo(s, { row: 3, col: 2 });
+ expect(s.range).toEqual({ start: { row: 1, col: 1 }, end: { row: 3, col: 2 } });
+ expect(s.active).toEqual({ row: 3, col: 2 });
+ });
+});
+
+describe('ClipboardManager', () => {
+ const get = (grid: string[][]) => (a: Address) => grid[a.row]?.[a.col] ?? '';
+
+ it('copies a rectangular block', () => {
+ const grid = [
+ ['a', 'b'],
+ ['c', 'd'],
+ ];
+ const clip = ClipboardManager.copy({ start: { row: 0, col: 0 }, end: { row: 1, col: 1 } }, get(grid));
+ expect(clip.contents).toEqual([['a', 'b'], ['c', 'd']]);
+ });
+
+ it('pastes, tiling to fill a larger target and skipping read-only cells', () => {
+ const clip = { contents: [['x']], rows: 1, cols: 1 };
+ const target = { start: { row: 0, col: 0 }, end: { row: 2, col: 0 } };
+ const canEdit = (a: Address) => a.row !== 1; // row 1 read-only
+ const edits = ClipboardManager.paste(clip, target, { rowCount: 5, colCount: 5 }, canEdit);
+ expect(edits.map(e => e.address.row)).toEqual([0, 2]);
+ expect(edits.every(e => e.content === 'x')).toBe(true);
+ });
+
+ it('round-trips TSV', () => {
+ const clip = ClipboardManager.fromTSV('1\t2\n3\t4');
+ expect(clip.rows).toBe(2);
+ expect(clip.cols).toBe(2);
+ expect(ClipboardManager.toTSV(clip)).toBe('1\t2\n3\t4');
+ });
+});
+
+
diff --git a/src/Spreadsheet/__tests__/utils.test.ts b/src/Spreadsheet/__tests__/utils.test.ts
new file mode 100644
index 0000000..e9e0d15
--- /dev/null
+++ b/src/Spreadsheet/__tests__/utils.test.ts
@@ -0,0 +1,81 @@
+import { columnToLetter, letterToColumn, addressToA1, a1ToAddress, parseRangeA1 } from '../utils/address';
+import { formatCellValue, parseInput, excelSerialToDate, dateToExcelSerial } from '../utils/cellFormat';
+import { isCellEditable, CellModel } from '../types';
+
+describe('address utils', () => {
+ it('converts columns to/from letters', () => {
+ expect(columnToLetter(0)).toBe('A');
+ expect(columnToLetter(25)).toBe('Z');
+ expect(columnToLetter(26)).toBe('AA');
+ expect(columnToLetter(701)).toBe('ZZ');
+ expect(letterToColumn('A')).toBe(0);
+ expect(letterToColumn('AA')).toBe(26);
+ expect(letterToColumn('ZZ')).toBe(701);
+ });
+
+ it('round-trips A1 addresses', () => {
+ expect(addressToA1({ row: 0, col: 0 })).toBe('A1');
+ expect(addressToA1({ row: 9, col: 2 })).toBe('C10');
+ expect(a1ToAddress('C10')).toEqual({ row: 9, col: 2 });
+ expect(a1ToAddress('$B$2')).toEqual({ row: 1, col: 1 });
+ });
+
+ it('parses A1 ranges', () => {
+ expect(parseRangeA1('A1:B3')).toEqual({ start: { row: 0, col: 0 }, end: { row: 2, col: 1 } });
+ expect(parseRangeA1('B2')).toEqual({ start: { row: 1, col: 1 }, end: { row: 1, col: 1 } });
+ });
+});
+
+describe('cellFormat', () => {
+ it('formats numbers, currency and percentages', () => {
+ expect(formatCellValue(1234.5, 'number', '#,##0.00')).toBe('1,234.50');
+ expect(formatCellValue(1000, 'currency', '"₹"#,##0.00')).toBe('₹1,000.00');
+ expect(formatCellValue(0.125, 'percentage', '0.0%')).toBe('12.5%');
+ });
+
+ it('formats Excel serial dates', () => {
+ // 2020-01-01 is serial 43831.
+ const serial = dateToExcelSerial(new Date(Date.UTC(2020, 0, 1)));
+ expect(Math.round(serial)).toBe(43831);
+ expect(formatCellValue(serial, 'date', 'dd/mm/yyyy')).toBe('01/01/2020');
+ expect(excelSerialToDate(43831).getUTCFullYear()).toBe(2020);
+ });
+
+ it('parses input by type', () => {
+ expect(parseInput('=SUM(A1:A2)', 'number')).toEqual({ formula: 'SUM(A1:A2)', value: null });
+ expect(parseInput('1,234.5', 'number')).toEqual({ formula: null, value: 1234.5 });
+ expect(parseInput('12%', 'percentage')).toEqual({ formula: null, value: 0.12 });
+ expect(parseInput('true', 'boolean')).toEqual({ formula: null, value: true });
+ expect(parseInput('', 'text')).toEqual({ formula: null, value: null });
+ });
+});
+
+describe('isCellEditable', () => {
+ const cell = (over: Partial): CellModel => ({
+ row: 0,
+ col: 0,
+ raw: null,
+ formula: null,
+ type: 'text',
+ locked: false,
+ ...over,
+ });
+
+ it('never allows editing formula cells', () => {
+ expect(isCellEditable(cell({ formula: 'A1+1' }), false)).toBe(false);
+ expect(isCellEditable(cell({ formula: 'A1+1' }), true)).toBe(false);
+ });
+
+ it('respects protection for locked cells', () => {
+ expect(isCellEditable(cell({ locked: true }), true)).toBe(false); // locked + protected
+ expect(isCellEditable(cell({ locked: true }), false)).toBe(true); // locked but unprotected
+ expect(isCellEditable(cell({ locked: false }), true)).toBe(true); // unlocked input cell
+ });
+
+ it('treats a blank cell on a protected sheet as read-only', () => {
+ expect(isCellEditable(undefined, true)).toBe(false);
+ expect(isCellEditable(undefined, false)).toBe(true);
+ });
+});
+
+
diff --git a/src/Spreadsheet/components/CellView.tsx b/src/Spreadsheet/components/CellView.tsx
new file mode 100644
index 0000000..097e180
--- /dev/null
+++ b/src/Spreadsheet/components/CellView.tsx
@@ -0,0 +1,211 @@
+/**
+ * CellView — a single grid cell (presentational + inline editor).
+ *
+ * Memoized on primitive props so a repaint only re-renders cells whose value /
+ * flags changed. While editing, text lives in local state so keystrokes never
+ * touch the shared store. Read-only cells get a distinct background; formula /
+ * locked cells show a lock glyph, per spec.
+ */
+import React, { memo, useEffect, useRef, useState } from 'react';
+import {
+ NativeSyntheticEvent,
+ Pressable,
+ StyleSheet,
+ Text,
+ TextInput,
+ TextInputSubmitEditingEventData,
+ View,
+} from 'react-native';
+import { Lock } from 'lucide-react-native';
+import { Address, CellStyle, CellType } from '../types';
+import { GridTheme } from '../hooks/useGridTheme';
+import { readableTextOn } from '../utils/color';
+
+export interface CellViewProps {
+ row: number;
+ col: number;
+ text: string;
+ width: number;
+ height: number;
+ type: CellType;
+ style?: CellStyle;
+ isActive: boolean;
+ isSelected: boolean;
+ inLane: boolean;
+ hasError: boolean;
+ readOnly: boolean;
+ showLock: boolean;
+ isDropdown: boolean;
+ isSearchHit: boolean;
+ editing: boolean;
+ initialText: string;
+ gridTheme: GridTheme;
+ onPress: (addr: Address) => void;
+ onLongPress: (addr: Address) => void;
+ onCommit: (text: string, move: 'down' | 'right' | 'none') => void;
+ onCancel: () => void;
+}
+
+const alignFor = (type: CellType, style?: CellStyle): 'left' | 'center' | 'right' => {
+ if (style?.hAlign === 'left' || style?.hAlign === 'right' || style?.hAlign === 'center') {
+ return style.hAlign;
+ }
+ if (type === 'number' || type === 'currency' || type === 'percentage') return 'right';
+ if (type === 'boolean' || type === 'date') return 'center';
+ return 'left';
+};
+
+const CellViewBase: React.FC = props => {
+ const {
+ row, col, text, width, height, type, style,
+ isActive, isSelected, inLane, hasError, readOnly, showLock, isDropdown, isSearchHit,
+ editing, initialText, gridTheme, onPress, onLongPress, onCommit,
+ } = props;
+
+ const [draft, setDraft] = useState(initialText);
+ const committedRef = useRef(false);
+
+ useEffect(() => {
+ if (editing) {
+ setDraft(initialText);
+ committedRef.current = false;
+ }
+ }, [editing, initialText]);
+
+ let backgroundColor = style?.bgColor ?? 'transparent';
+ if (readOnly && !style?.bgColor) backgroundColor = gridTheme.readOnlyBg;
+ if (inLane && !isSelected && !isActive) backgroundColor = gridTheme.laneHighlight;
+ if (isSearchHit) backgroundColor = gridTheme.searchHighlight;
+ if (isSelected && !isActive) backgroundColor = gridTheme.selectionFill;
+ if (hasError) backgroundColor = gridTheme.errorFill;
+
+ let borderColor = gridTheme.gridLine;
+ let borderWidth = StyleSheet.hairlineWidth;
+ if (isActive) {
+ borderColor = gridTheme.selectionBorder;
+ borderWidth = 2;
+ } else if (hasError) {
+ borderColor = gridTheme.errorBorder;
+ borderWidth = 1.5;
+ }
+
+ const textColor = style?.bgColor
+ ? (style.fontColor ?? readableTextOn(style.bgColor))
+ : (gridTheme.isDark ? gridTheme.cellText : (style?.fontColor ?? gridTheme.cellText));
+ const align = alignFor(type, style);
+
+ const containerStyle = {
+ width,
+ height,
+ backgroundColor,
+ borderColor,
+ borderWidth,
+ borderRightColor: gridTheme.gridLine,
+ borderBottomColor: gridTheme.gridLine,
+ justifyContent: 'center' as const,
+ paddingHorizontal: 6,
+ };
+
+ if (editing && !isDropdown) {
+ const submit = (
+ e?: NativeSyntheticEvent,
+ move: 'down' | 'right' | 'none' = 'down',
+ ) => {
+ if (committedRef.current) return;
+ committedRef.current = true;
+ onCommit(e ? e.nativeEvent.text : draft, move);
+ };
+ const numeric = type === 'number' || type === 'currency' || type === 'percentage';
+ return (
+
+ submit(e, 'down')}
+ onBlur={() => submit(undefined, 'none')}
+ blurOnSubmit
+ keyboardType={numeric ? 'numeric' : 'default'}
+ style={[styles.input, { color: gridTheme.cellText, textAlign: align, backgroundColor: gridTheme.editorBg }]}
+ selectTextOnFocus
+ />
+
+ );
+ }
+
+ return (
+ onPress({ row, col })}
+ onLongPress={() => onLongPress({ row, col })}
+ delayLongPress={220}
+ style={containerStyle}
+ >
+
+
+ {text}
+
+ {isDropdown && ▾}
+ {showLock && !isDropdown && (
+
+
+
+ )}
+
+ {hasError && }
+
+ );
+};
+
+const styles = StyleSheet.create({
+ input: { flex: 1, padding: 0, margin: 0, fontSize: 13 },
+ contentRow: { flexDirection: 'row', alignItems: 'center' },
+ lockIcon: { marginLeft: 2 },
+ errorFlag: {
+ position: 'absolute',
+ top: 0,
+ right: 0,
+ width: 0,
+ height: 0,
+ borderTopWidth: 7,
+ borderLeftWidth: 7,
+ borderLeftColor: 'transparent',
+ },
+});
+
+const areEqual = (a: CellViewProps, b: CellViewProps): boolean =>
+ a.text === b.text &&
+ a.width === b.width &&
+ a.height === b.height &&
+ a.type === b.type &&
+ a.style === b.style &&
+ a.isActive === b.isActive &&
+ a.isSelected === b.isSelected &&
+ a.inLane === b.inLane &&
+ a.hasError === b.hasError &&
+ a.readOnly === b.readOnly &&
+ a.showLock === b.showLock &&
+ a.isDropdown === b.isDropdown &&
+ a.isSearchHit === b.isSearchHit &&
+ a.editing === b.editing &&
+ a.initialText === b.initialText &&
+ a.gridTheme === b.gridTheme &&
+ a.onPress === b.onPress &&
+ a.onLongPress === b.onLongPress &&
+ a.onCommit === b.onCommit;
+
+export const CellView = memo(CellViewBase, areEqual);
+export default CellView;
+
+
diff --git a/src/Spreadsheet/components/ColumnHeaderRow.tsx b/src/Spreadsheet/components/ColumnHeaderRow.tsx
new file mode 100644
index 0000000..3bf0ca1
--- /dev/null
+++ b/src/Spreadsheet/components/ColumnHeaderRow.tsx
@@ -0,0 +1,65 @@
+/**
+ * ColumnHeaderRow — frozen Excel-style column-letter header (A, B, C, …).
+ * Rendered above the body list inside the horizontal scroller so it pans with
+ * the columns but never scrolls vertically. Tapping a letter selects the column.
+ */
+import React from 'react';
+import { Pressable, StyleSheet, Text, View } from 'react-native';
+import { SheetModel } from '../types';
+import { GridTheme } from '../hooks/useGridTheme';
+import { colWidth, HEADER_HEIGHT } from '../utils/geometry';
+import { columnToLetter } from '../utils/address';
+
+export interface ColumnHeaderRowProps {
+ sheet: SheetModel;
+ cols: number[];
+ width: number;
+ gridTheme: GridTheme;
+ activeCol: number;
+ selStartCol: number;
+ selEndCol: number;
+ onSelectColumn: (col: number) => void;
+}
+
+export const ColumnHeaderRow: React.FC = ({
+ sheet, cols, width, gridTheme, activeCol, selStartCol, selEndCol, onSelectColumn,
+}) => (
+
+ {cols.map(col => {
+ const selected = col >= selStartCol && col <= selEndCol;
+ return (
+ onSelectColumn(col)}
+ style={[
+ styles.cell,
+ {
+ width: colWidth(sheet, col),
+ backgroundColor: selected ? gridTheme.headerActiveBg : gridTheme.headerBg,
+ borderRightColor: gridTheme.gridLine,
+ borderBottomColor: gridTheme.gridLine,
+ },
+ ]}
+ >
+
+ {columnToLetter(col)}
+
+
+ );
+ })}
+
+);
+
+const styles = StyleSheet.create({
+ row: { flexDirection: 'row' },
+ cell: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRightWidth: StyleSheet.hairlineWidth,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ },
+});
+
+export default ColumnHeaderRow;
+
+
diff --git a/src/Spreadsheet/components/DataRow.tsx b/src/Spreadsheet/components/DataRow.tsx
new file mode 100644
index 0000000..380edff
--- /dev/null
+++ b/src/Spreadsheet/components/DataRow.tsx
@@ -0,0 +1,96 @@
+/**
+ * DataRow — renders one worksheet row's visible cells, honouring merged spans.
+ * Derives each cell's primitive props and defers to the memoized `CellView`.
+ */
+import React from 'react';
+import { View } from 'react-native';
+import { Address, CellModel, CellRange, SheetModel, ValidationError, cellKey } from '../types';
+import { GridTheme } from '../hooks/useGridTheme';
+import { MergeIndex, rowRenderPlan } from '../utils/geometry';
+import { isInRange } from '../utils/address';
+import { SelectionState } from '../engine/SelectionManager';
+import CellView from './CellView';
+
+export interface DataRowAccessors {
+ getDisplayValue: (row: number, col: number) => string;
+ getCellModel: (row: number, col: number) => CellModel | undefined;
+ getCellError: (row: number, col: number) => ValidationError | undefined;
+ getUserContent: (row: number, col: number) => string;
+ canEdit: (addr: Address) => boolean;
+}
+
+export interface DataRowProps extends DataRowAccessors {
+ sheet: SheetModel;
+ row: number;
+ cols: number[];
+ mergeIndex: MergeIndex;
+ height: number;
+ width: number;
+ gridTheme: GridTheme;
+ selection: SelectionState;
+ editingRow: number | null;
+ editingCol: number | null;
+ searchHits: Set;
+ onCellPress: (addr: Address) => void;
+ onCellLongPress: (addr: Address) => void;
+ onCommit: (text: string, move: 'down' | 'right' | 'none') => void;
+ onCancel: () => void;
+}
+
+const inRange = (row: number, col: number, range: CellRange): boolean => isInRange({ row, col }, range);
+
+export const DataRow: React.FC = ({
+ sheet, row, cols, mergeIndex, height, width, gridTheme, selection,
+ editingRow, editingCol, searchHits,
+ getDisplayValue, getCellModel, getCellError, getUserContent, canEdit,
+ onCellPress, onCellLongPress, onCommit, onCancel,
+}) => {
+ const plan = rowRenderPlan(sheet, row, cols, mergeIndex);
+ const { active, range } = selection;
+
+ return (
+
+ {plan.map(({ col, width: cw, skip }) => {
+ if (skip) return null;
+ const cell = getCellModel(row, col);
+ const editable = canEdit({ row, col });
+ const isDropdown =
+ cell?.type === 'dropdown' ||
+ cell?.validation?.kind === 'list' ||
+ !!cell?.dropdownOptions?.length;
+
+ return (
+
+ );
+ })}
+
+ );
+};
+
+export default DataRow;
+
+
diff --git a/src/Spreadsheet/components/DropdownModal.tsx b/src/Spreadsheet/components/DropdownModal.tsx
new file mode 100644
index 0000000..38a810f
--- /dev/null
+++ b/src/Spreadsheet/components/DropdownModal.tsx
@@ -0,0 +1,46 @@
+/**
+ * DropdownModal — option picker for list-validated / dropdown cells.
+ */
+import React from 'react';
+import { FlatList, Modal, Pressable, StyleSheet, Text } from 'react-native';
+import { GridTheme } from '../hooks/useGridTheme';
+
+export interface DropdownModalProps {
+ visible: boolean;
+ options: string[];
+ gridTheme: GridTheme;
+ onSelect: (value: string) => void;
+ onClose: () => void;
+}
+
+export const DropdownModal: React.FC = ({ visible, options, gridTheme, onSelect, onClose }) => (
+
+
+ {}}>
+ Select a value
+ `${item}-${i}`}
+ style={{ maxHeight: 320 }}
+ renderItem={({ item }) => (
+ onSelect(item)} style={[styles.option, { borderBottomColor: gridTheme.gridLine }]}>
+ {item}
+
+ )}
+ ListEmptyComponent={No options available.}
+ />
+
+
+
+);
+
+const styles = StyleSheet.create({
+ backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.4)', justifyContent: 'center', alignItems: 'center', padding: 24 },
+ sheet: { width: '100%', maxWidth: 420, borderRadius: 14, borderWidth: StyleSheet.hairlineWidth, paddingVertical: 12 },
+ title: { fontSize: 13, fontWeight: '700', paddingHorizontal: 16, paddingBottom: 8 },
+ option: { paddingVertical: 14, paddingHorizontal: 16, borderBottomWidth: StyleSheet.hairlineWidth },
+});
+
+export default DropdownModal;
+
+
diff --git a/src/Spreadsheet/components/FormulaBar.tsx b/src/Spreadsheet/components/FormulaBar.tsx
new file mode 100644
index 0000000..0af7fac
--- /dev/null
+++ b/src/Spreadsheet/components/FormulaBar.tsx
@@ -0,0 +1,63 @@
+/**
+ * FormulaBar — shows the active cell reference and its content/formula, and
+ * offers a reliable editing surface for editable cells. Read-only (formula /
+ * locked / protected) cells show their content but cannot be edited here.
+ */
+import React, { useEffect, useState } from 'react';
+import { StyleSheet, Text, TextInput, View } from 'react-native';
+import { Lock } from 'lucide-react-native';
+import { UseSpreadsheet } from '../hooks/useSpreadsheet';
+import { GridTheme } from '../hooks/useGridTheme';
+import { addressToA1 } from '../utils/address';
+
+export interface FormulaBarProps {
+ ss: UseSpreadsheet;
+ gridTheme: GridTheme;
+}
+
+export const FormulaBar: React.FC = ({ ss, gridTheme }) => {
+ const active = ss.selection.active;
+ const ref = addressToA1(active);
+ const content = ss.getUserContent(active.row, active.col);
+ const editable = ss.canEdit(active);
+ const [text, setText] = useState(content);
+
+ useEffect(() => {
+ setText(content);
+ }, [ref, ss.version, content]);
+
+ const submit = () => {
+ if (editable) ss.setCellContent(active, text);
+ };
+
+ return (
+
+
+ {ref}
+
+ ƒx
+
+ {!editable && }
+
+ );
+};
+
+const styles = StyleSheet.create({
+ bar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6, borderBottomWidth: StyleSheet.hairlineWidth },
+ ref: { minWidth: 52, paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center' },
+ input: { flex: 1, paddingVertical: 4, fontSize: 14 },
+});
+
+export default FormulaBar;
+
+
diff --git a/src/Spreadsheet/components/RowNumberCell.tsx b/src/Spreadsheet/components/RowNumberCell.tsx
new file mode 100644
index 0000000..ebb9110
--- /dev/null
+++ b/src/Spreadsheet/components/RowNumberCell.tsx
@@ -0,0 +1,51 @@
+/**
+ * RowNumberCell — one cell of the frozen left-hand row-number column (rendered
+ * by the synced left list). Tapping selects the whole row.
+ */
+import React, { memo } from 'react';
+import { Pressable, StyleSheet, Text } from 'react-native';
+import { GridTheme } from '../hooks/useGridTheme';
+import { ROW_NUMBER_WIDTH } from '../utils/geometry';
+
+export interface RowNumberCellProps {
+ row: number;
+ height: number;
+ gridTheme: GridTheme;
+ selected: boolean;
+ isActive: boolean;
+ onSelectRow: (row: number) => void;
+}
+
+const RowNumberCellBase: React.FC = ({ row, height, gridTheme, selected, isActive, onSelectRow }) => (
+ onSelectRow(row)}
+ style={[
+ styles.cell,
+ {
+ width: ROW_NUMBER_WIDTH,
+ height,
+ backgroundColor: selected ? gridTheme.headerActiveBg : gridTheme.headerBg,
+ borderRightColor: gridTheme.gridLine,
+ borderBottomColor: gridTheme.gridLine,
+ },
+ ]}
+ >
+
+ {row + 1}
+
+
+);
+
+const styles = StyleSheet.create({
+ cell: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRightWidth: StyleSheet.hairlineWidth,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ },
+});
+
+export const RowNumberCell = memo(RowNumberCellBase);
+export default RowNumberCell;
+
+
diff --git a/src/Spreadsheet/components/SearchBar.tsx b/src/Spreadsheet/components/SearchBar.tsx
new file mode 100644
index 0000000..87bb5ba
--- /dev/null
+++ b/src/Spreadsheet/components/SearchBar.tsx
@@ -0,0 +1,64 @@
+/**
+ * SearchBar — find text within the active sheet and step through matches.
+ * Shown when the toolbar's search action is toggled on.
+ */
+import React, { useState } from 'react';
+import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
+import { ChevronUp, ChevronDown, X } from 'lucide-react-native';
+import { UseSpreadsheet } from '../hooks/useSpreadsheet';
+import { GridTheme } from '../hooks/useGridTheme';
+
+export interface SearchBarProps {
+ ss: UseSpreadsheet;
+ gridTheme: GridTheme;
+ onClose: () => void;
+}
+
+export const SearchBar: React.FC = ({ ss, gridTheme, onClose }) => {
+ const [text, setText] = useState(ss.search.query);
+ const { matches, index } = ss.search;
+ const count = matches.length;
+
+ return (
+
+ ss.runSearch(text)}
+ autoFocus
+ placeholder="Find in sheet…"
+ placeholderTextColor={gridTheme.mutedText}
+ style={[styles.input, { color: gridTheme.cellText, borderColor: gridTheme.gridLine }]}
+ returnKeyType="search"
+ />
+
+ {count ? `${index + 1}/${count}` : '0/0'}
+
+
+
+
+
+
+
+ {
+ ss.clearSearch();
+ onClose();
+ }}
+ style={styles.iconBtn}
+ >
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ bar: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 8, paddingVertical: 6, borderBottomWidth: StyleSheet.hairlineWidth },
+ input: { flex: 1, borderWidth: StyleSheet.hairlineWidth, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, fontSize: 14 },
+ iconBtn: { padding: 4 },
+});
+
+export default SearchBar;
+
+
diff --git a/src/Spreadsheet/components/SheetTabs.tsx b/src/Spreadsheet/components/SheetTabs.tsx
new file mode 100644
index 0000000..8bebba6
--- /dev/null
+++ b/src/Spreadsheet/components/SheetTabs.tsx
@@ -0,0 +1,53 @@
+/**
+ * SheetTabs — worksheet switcher (visible sheets only). Hidden / very-hidden
+ * sheets stay in the model and are preserved on export. Sheets cannot be
+ * renamed, added or removed — the workbook structure is immutable by design.
+ */
+import React from 'react';
+import { Pressable, ScrollView, StyleSheet, Text } from 'react-native';
+import { UseSpreadsheet } from '../hooks/useSpreadsheet';
+import { GridTheme } from '../hooks/useGridTheme';
+
+export interface SheetTabsProps {
+ ss: UseSpreadsheet;
+ gridTheme: GridTheme;
+}
+
+export const SheetTabs: React.FC = ({ ss, gridTheme }) => {
+ const sheets = ss.model?.sheets.filter(s => s.state === 'visible') ?? [];
+ if (sheets.length <= 1) return null;
+
+ return (
+
+ {sheets.map(sheet => {
+ const active = sheet.index === ss.activeSheetIndex;
+ return (
+ ss.setActiveSheet(sheet.index)}
+ style={[styles.tab, { backgroundColor: active ? gridTheme.surface : 'transparent', borderColor: active ? gridTheme.primary : gridTheme.gridLine }]}
+ >
+
+ {sheet.name}
+
+
+ );
+ })}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ wrap: { borderTopWidth: StyleSheet.hairlineWidth, maxHeight: 44 },
+ row: { alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6, gap: 6 },
+ tab: { paddingHorizontal: 14, paddingVertical: 6, borderRadius: 8, borderWidth: 1 },
+});
+
+export default SheetTabs;
+
+
diff --git a/src/Spreadsheet/components/SpreadsheetGrid.tsx b/src/Spreadsheet/components/SpreadsheetGrid.tsx
new file mode 100644
index 0000000..c17f4d7
--- /dev/null
+++ b/src/Spreadsheet/components/SpreadsheetGrid.tsx
@@ -0,0 +1,307 @@
+/**
+ * SpreadsheetGrid — the virtualized, Excel-like grid surface.
+ *
+ * ┌────────────┬──────────────────────────────────────────┐
+ * │ corner │ ColumnHeaderRow (frozen top, pans X) │
+ * ├────────────┼──────────────────────────────────────────┤
+ * │ RowNumber │ body rows (FlashList, virtualized Y, │
+ * │ FlashList │ panning X with the horizontal ScrollView)│
+ * │ (frozen X, │ │
+ * │ synced Y) │ │
+ * └────────────┴──────────────────────────────────────────┘
+ *
+ * Vertical: the body FlashList virtualizes rows; its offset is mirrored onto
+ * the frozen row-number list. Horizontal: one ScrollView pans header + body
+ * together so they can't drift. The row-number column and header live outside
+ * the horizontal scroller, which is what freezes them.
+ */
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ Keyboard,
+ NativeScrollEvent,
+ NativeSyntheticEvent,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View,
+} from 'react-native';
+import { FlashList } from '@shopify/flash-list';
+
+import { Address, cellKey } from '../types';
+import { UseSpreadsheet } from '../hooks/useSpreadsheet';
+import { GridTheme } from '../hooks/useGridTheme';
+import PerformanceManager from '../engine/PerformanceManager';
+import {
+ HEADER_HEIGHT,
+ ROW_NUMBER_WIDTH,
+ buildMergeIndex,
+ rowHeight,
+ totalWidth,
+ visibleColumns,
+ visibleRows,
+} from '../utils/geometry';
+import DataRow from './DataRow';
+import ColumnHeaderRow from './ColumnHeaderRow';
+import RowNumberCell from './RowNumberCell';
+import DropdownModal from './DropdownModal';
+
+export interface SpreadsheetGridProps {
+ ss: UseSpreadsheet;
+ gridTheme: GridTheme;
+}
+
+export const SpreadsheetGrid: React.FC = ({ ss, gridTheme }) => {
+ const sheet = ss.activeSheet;
+
+ const ssRef = useRef(ss);
+ ssRef.current = ss;
+
+ const leftListRef = useRef | null>(null);
+ const bodyListRef = useRef | null>(null);
+
+ const [containerH, setContainerH] = useState(0);
+ const [dropdown, setDropdown] = useState<{ addr: Address; options: string[] } | null>(null);
+
+ const cols = useMemo(() => (sheet ? visibleColumns(sheet) : []), [sheet]);
+ const rows = useMemo(() => (sheet ? visibleRows(sheet) : []), [sheet]);
+ const mergeIndex = useMemo(() => (sheet ? buildMergeIndex(sheet) : { coveredBy: new Map() }), [sheet]);
+ const rowH = sheet ? rowHeight(sheet) : 40;
+ const totalW = useMemo(() => (sheet ? totalWidth(sheet, cols) : 0), [sheet, cols]);
+ const bodyH = Math.max(0, containerH - HEADER_HEIGHT);
+
+ // Search hits on the active sheet as a fast lookup set.
+ const searchHits = useMemo(() => {
+ const set = new Set();
+ for (const m of ss.search.matches) {
+ if (m.sheetIndex === ss.activeSheetIndex) set.add(cellKey(m.address.row, m.address.col));
+ }
+ return set;
+ }, [ss.search.matches, ss.activeSheetIndex]);
+
+ const dropdownOptionsFor = useCallback((addr: Address): string[] => {
+ const cell = ssRef.current.getCellModel(addr.row, addr.col);
+ return cell?.dropdownOptions ?? cell?.validation?.list ?? [];
+ }, []);
+
+ const isDropdownCell = useCallback((addr: Address): boolean => {
+ const cell = ssRef.current.getCellModel(addr.row, addr.col);
+ return cell?.type === 'dropdown' || cell?.validation?.kind === 'list' || !!cell?.dropdownOptions?.length;
+ }, []);
+
+ const onCellPress = useCallback(
+ (addr: Address) => {
+ const s = ssRef.current;
+ const active = s.selection.active;
+ if (active.row === addr.row && active.col === addr.col) {
+ if (!s.canEdit(addr)) return;
+ if (isDropdownCell(addr)) setDropdown({ addr, options: dropdownOptionsFor(addr) });
+ else s.beginEdit(addr);
+ } else {
+ s.selectCell(addr);
+ }
+ },
+ [isDropdownCell, dropdownOptionsFor],
+ );
+
+ const onCellLongPress = useCallback(
+ (addr: Address) => {
+ const s = ssRef.current;
+ s.selectCell(addr);
+ if (!s.canEdit(addr)) return;
+ if (isDropdownCell(addr)) setDropdown({ addr, options: dropdownOptionsFor(addr) });
+ else s.beginEdit(addr);
+ },
+ [isDropdownCell, dropdownOptionsFor],
+ );
+
+ const onCommit = useCallback((text: string, move: 'down' | 'right' | 'none') => ssRef.current.commitEdit(text, move), []);
+ const onCancel = useCallback(() => ssRef.current.cancelEdit(), []);
+ const onSelectColumn = useCallback((col: number) => ssRef.current.selectColumnHeader(col), []);
+ const onSelectRow = useCallback((row: number) => ssRef.current.selectRowHeader(row), []);
+ const onSelectAll = useCallback(() => ssRef.current.selectAll(), []);
+
+ const onDropdownSelect = useCallback(
+ (value: string) => {
+ if (dropdown) ssRef.current.setCellContent(dropdown.addr, value);
+ setDropdown(null);
+ },
+ [dropdown],
+ );
+
+ const onBodyScroll = useCallback((e: NativeSyntheticEvent) => {
+ leftListRef.current?.scrollToOffset({ offset: e.nativeEvent.contentOffset.y, animated: false });
+ }, []);
+
+ // Keep the focused / edited row visible above the keyboard. Row height is
+ // uniform, so we scroll by a deterministic pixel offset (scrollToIndex is
+ // unreliable here, especially inside the horizontal scroller). The target is
+ // placed ~35% down the visible area so it clears the on-screen keyboard.
+ const activeRow = ss.selection.active.row;
+ const editingRowForScroll = ss.editing ? ss.editing.row : -1;
+
+ // Latest geometry for the imperative scroller (read from listeners/effects).
+ const geomRef = useRef({ rowH: 40, bodyH: 0, rows: [] as number[], target: 0 });
+ geomRef.current = {
+ rowH,
+ bodyH,
+ rows,
+ target: editingRowForScroll >= 0 ? editingRowForScroll : activeRow,
+ };
+
+ const scrollActiveIntoView = useCallback((animated: boolean) => {
+ const { rowH: rh, bodyH: bh, rows: rws, target } = geomRef.current;
+ const index = rws.indexOf(target);
+ if (index < 0 || bh <= 0) return;
+ const offset = Math.max(0, index * rh - Math.max(0, bh * 0.35));
+ try {
+ bodyListRef.current?.scrollToOffset({ offset, animated });
+ leftListRef.current?.scrollToOffset({ offset, animated });
+ } catch {
+ /* list not measured yet */
+ }
+ }, []);
+
+ // Re-center when the selection/edit target changes or the viewport resizes.
+ useEffect(() => {
+ const id = setTimeout(() => scrollActiveIntoView(true), 60);
+ return () => clearTimeout(id);
+ }, [activeRow, editingRowForScroll, containerH, rows, scrollActiveIntoView]);
+
+ // The formula bar opens the keyboard WITHOUT changing selection, so also
+ // react to the keyboard appearing.
+ useEffect(() => {
+ const sub = Keyboard.addListener('keyboardDidShow', () => {
+ setTimeout(() => scrollActiveIntoView(true), 90);
+ });
+ return () => sub.remove();
+ }, [scrollActiveIntoView]);
+
+ const selectionSig = `${ss.selection.range.start.row},${ss.selection.range.start.col},${ss.selection.range.end.row},${ss.selection.range.end.col}`;
+ const editingSig = ss.editing ? `${ss.editing.row}:${ss.editing.col}` : 'none';
+ const extra = `${ss.version}|${selectionSig}|${editingSig}|${ss.search.index}|${searchHits.size}`;
+
+ if (!sheet) return ;
+
+ const editingRow = ss.editing?.row ?? null;
+ const editingCol = ss.editing?.col ?? null;
+ const flProps = PerformanceManager.flashListProps(rowH);
+
+ const renderBodyRow = ({ item: row }: { item: number }) => (
+
+ );
+
+ const renderRowNumber = ({ item: row }: { item: number }) => {
+ const { range, active } = ss.selection;
+ return (
+ = range.start.row && row <= range.end.row}
+ isActive={row === active.row}
+ onSelectRow={onSelectRow}
+ />
+ );
+ };
+
+ return (
+ setContainerH(e.nativeEvent.layout.height)}>
+
+
+
+ ▤
+
+ {bodyH > 0 && (
+ `rn-${r}`}
+ renderItem={renderRowNumber}
+ estimatedItemSize={rowH}
+ scrollEnabled={false}
+ showsVerticalScrollIndicator={false}
+ extraData={selectionSig}
+ style={{ width: ROW_NUMBER_WIDTH, height: bodyH }}
+ />
+ )}
+
+
+
+
+
+ {bodyH > 0 && (
+ `r-${r}`}
+ renderItem={renderBodyRow}
+ estimatedItemSize={flProps.estimatedItemSize}
+ drawDistance={flProps.drawDistance}
+ onScroll={onBodyScroll}
+ scrollEventThrottle={16}
+ extraData={extra}
+ showsVerticalScrollIndicator
+ style={{ width: Math.max(totalW, 1), height: bodyH }}
+ />
+ )}
+
+
+
+
+ setDropdown(null)}
+ />
+
+ );
+};
+
+const styles = StyleSheet.create({
+ gridRow: { flex: 1, flexDirection: 'row' },
+ corner: {
+ width: ROW_NUMBER_WIDTH,
+ height: HEADER_HEIGHT,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRightWidth: StyleSheet.hairlineWidth,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ },
+});
+
+export default SpreadsheetGrid;
+
+
diff --git a/src/Spreadsheet/components/SpreadsheetView.tsx b/src/Spreadsheet/components/SpreadsheetView.tsx
new file mode 100644
index 0000000..d4e6e70
--- /dev/null
+++ b/src/Spreadsheet/components/SpreadsheetView.tsx
@@ -0,0 +1,90 @@
+/**
+ * SpreadsheetView — the reusable editor surface: toolbar + (search) + formula
+ * bar + virtualized grid + sheet tabs, plus loading/error states, transient
+ * notices and the active-cell validation message. Screens own file open/save
+ * and wrap this with their own header/actions.
+ */
+import React, { useState } from 'react';
+import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
+import { AlertTriangle, Info } from 'lucide-react-native';
+import { UseSpreadsheet } from '../hooks/useSpreadsheet';
+import useGridTheme from '../hooks/useGridTheme';
+import Toolbar from './Toolbar';
+import FormulaBar from './FormulaBar';
+import SearchBar from './SearchBar';
+import SheetTabs from './SheetTabs';
+import SpreadsheetGrid from './SpreadsheetGrid';
+
+export interface SpreadsheetViewProps {
+ ss: UseSpreadsheet;
+ emptyHint?: string;
+}
+
+export const SpreadsheetView: React.FC = ({ ss, emptyHint }) => {
+ const gridTheme = useGridTheme();
+ const [searchOpen, setSearchOpen] = useState(false);
+ const activeError = ss.getCellError(ss.selection.active.row, ss.selection.active.col);
+
+ if (ss.status === 'loading') {
+ return (
+
+
+ Opening workbook…
+
+ );
+ }
+
+ if (ss.status === 'error') {
+ return (
+
+
+ Could not open workbook
+ {ss.error}
+
+ );
+ }
+
+ if (ss.status !== 'ready' || !ss.activeSheet) {
+ return (
+
+ {emptyHint ?? 'No workbook loaded.'}
+
+ );
+ }
+
+ return (
+
+ {ss.notice && (
+
+
+ {ss.notice}
+
+ )}
+
+ {activeError && (
+
+
+ {activeError.message}
+
+ )}
+
+ setSearchOpen(o => !o)} />
+ {searchOpen ? setSearchOpen(false)} /> : }
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24, gap: 10 },
+ hint: { fontSize: 14, textAlign: 'center' },
+ errorTitle: { fontSize: 16, fontWeight: '700' },
+ notice: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 12, paddingVertical: 8 },
+ noticeText: { color: '#fff', fontSize: 13, flex: 1 },
+ errorBar: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 12, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth },
+});
+
+export default SpreadsheetView;
+
+
diff --git a/src/Spreadsheet/components/Toolbar.tsx b/src/Spreadsheet/components/Toolbar.tsx
new file mode 100644
index 0000000..746780a
--- /dev/null
+++ b/src/Spreadsheet/components/Toolbar.tsx
@@ -0,0 +1,69 @@
+/**
+ * Toolbar — editing actions permitted by the BOQ spec: undo/redo, copy,
+ * paste-values, clear (editable cells only), and search. No drag-fill / row-col
+ * insertion. Shows a live validation-error count.
+ */
+import React from 'react';
+import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
+import { Undo2, Redo2, Copy, ClipboardPaste, Eraser, Search, AlertCircle } from 'lucide-react-native';
+import { UseSpreadsheet } from '../hooks/useSpreadsheet';
+import { GridTheme } from '../hooks/useGridTheme';
+
+export interface ToolbarProps {
+ ss: UseSpreadsheet;
+ gridTheme: GridTheme;
+ onToggleSearch: () => void;
+}
+
+const Btn: React.FC<{
+ icon: React.ReactNode;
+ label: string;
+ onPress: () => void;
+ disabled?: boolean;
+ gridTheme: GridTheme;
+}> = ({ icon, label, onPress, disabled, gridTheme }) => (
+
+ {icon}
+ {label}
+
+);
+
+export const Toolbar: React.FC = ({ ss, gridTheme, onToggleSearch }) => {
+ const color = gridTheme.cellText;
+ const SIZE = 18;
+
+ return (
+
+
+ } />
+ } />
+
+ } />
+ } />
+ } />
+
+ } />
+
+
+ {ss.errorCount > 0 && (
+
+
+ {ss.errorCount}
+
+ )}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ wrap: { flexDirection: 'row', alignItems: 'center', borderBottomWidth: StyleSheet.hairlineWidth },
+ row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 6, paddingVertical: 6, gap: 4 },
+ btn: { alignItems: 'center', justifyContent: 'center', paddingHorizontal: 12, paddingVertical: 4, borderRadius: 8, borderWidth: StyleSheet.hairlineWidth, minWidth: 56 },
+ btnLabel: { fontSize: 10, marginTop: 2 },
+ divider: { width: StyleSheet.hairlineWidth, height: 28, marginHorizontal: 4 },
+ errors: { flexDirection: 'row', alignItems: 'center', gap: 4, paddingHorizontal: 10 },
+});
+
+export default Toolbar;
+
+
diff --git a/src/Spreadsheet/engine/ClipboardManager.ts b/src/Spreadsheet/engine/ClipboardManager.ts
new file mode 100644
index 0000000..e848a4a
--- /dev/null
+++ b/src/Spreadsheet/engine/ClipboardManager.ts
@@ -0,0 +1,77 @@
+/**
+ * ClipboardManager — copy / paste-values as pure transforms.
+ *
+ * Works with "user-facing content" strings (a `=formula` or a literal) — the
+ * same representation the editor and undo stack use. Per the BOQ spec there is
+ * NO drag-fill; only copy and paste-values into editable cells. An in-memory
+ * clipboard avoids a new native dependency; TSV (de)serialization is provided
+ * for optional interop with a system clipboard.
+ */
+import { Address, CellRange, ClipboardData } from '../types';
+import { rangeCols, rangeRows } from '../utils/address';
+
+export interface PasteEdit {
+ address: Address;
+ content: string;
+}
+
+export const ClipboardManager = {
+ copy(range: CellRange, getContent: (addr: Address) => string): ClipboardData {
+ const rows = rangeRows(range);
+ const cols = rangeCols(range);
+ const contents: string[][] = [];
+ for (let r = 0; r < rows; r++) {
+ const rowArr: string[] = [];
+ for (let c = 0; c < cols; c++) {
+ rowArr.push(getContent({ row: range.start.row + r, col: range.start.col + c }));
+ }
+ contents.push(rowArr);
+ }
+ return { contents, rows, cols };
+ },
+
+ /**
+ * Edits for pasting `clip` with its top-left at `target`. Tiles the clip to
+ * fill a larger target (Excel behaviour). `canEdit` skips read-only cells so
+ * formulas / locked / protected cells are never overwritten.
+ */
+ paste(
+ clip: ClipboardData,
+ target: CellRange,
+ bounds: { rowCount: number; colCount: number },
+ canEdit: (addr: Address) => boolean = () => true,
+ ): PasteEdit[] {
+ const edits: PasteEdit[] = [];
+ if (clip.rows === 0 || clip.cols === 0) return edits;
+
+ const spanRows = Math.max(rangeRows(target), clip.rows);
+ const spanCols = Math.max(rangeCols(target), clip.cols);
+
+ for (let r = 0; r < spanRows; r++) {
+ for (let c = 0; c < spanCols; c++) {
+ const row = target.start.row + r;
+ const col = target.start.col + c;
+ if (row >= bounds.rowCount || col >= bounds.colCount) continue;
+ const addr = { row, col };
+ if (!canEdit(addr)) continue;
+ edits.push({ address: addr, content: clip.contents[r % clip.rows][c % clip.cols] });
+ }
+ }
+ return edits;
+ },
+
+ toTSV(clip: ClipboardData): string {
+ return clip.contents.map(row => row.join('\t')).join('\n');
+ },
+
+ fromTSV(text: string): ClipboardData {
+ const contents = text.replace(/\r\n/g, '\n').split('\n').map(line => line.split('\t'));
+ const rows = contents.length;
+ const cols = contents.reduce((m, r) => Math.max(m, r.length), 0);
+ return { contents, rows, cols };
+ },
+};
+
+export default ClipboardManager;
+
+
diff --git a/src/Spreadsheet/engine/FileManager.ts b/src/Spreadsheet/engine/FileManager.ts
new file mode 100644
index 0000000..3543554
--- /dev/null
+++ b/src/Spreadsheet/engine/FileManager.ts
@@ -0,0 +1,108 @@
+/**
+ * FileManager — generic .xlsx byte IO for the editor.
+ *
+ * Transport-agnostic: document picking, RNFS read/write and base64 conversion.
+ * Envelope-C specifics (endpoints, AES encryption, officer keys, FormData) live
+ * in the screen hook so this stays reusable.
+ */
+import '../polyfills';
+import { Buffer } from 'buffer';
+import RNFS from 'react-native-fs';
+import { pick, types } from '@react-native-documents/picker';
+
+export interface PickedWorkbook {
+ name: string;
+ uri: string;
+ size: number;
+ bytes: Uint8Array;
+}
+
+export const FileManager = {
+ base64ToBytes(base64: string): Uint8Array {
+ return new Uint8Array(Buffer.from(base64, 'base64'));
+ },
+
+ bytesToBase64(bytes: Uint8Array): string {
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64');
+ },
+
+ arrayBufferToBase64(buffer: ArrayBuffer): string {
+ return Buffer.from(new Uint8Array(buffer)).toString('base64');
+ },
+
+ async readBytesFromUri(uri: string): Promise {
+ const base64 = await RNFS.readFile(uri, 'base64');
+ return FileManager.base64ToBytes(base64);
+ },
+
+ async pickWorkbook(): Promise {
+ try {
+ const results = await pick({ type: [types.xlsx, types.xls], allowMultiSelection: false });
+ const file = results?.[0];
+ if (!file?.uri) return null;
+ const bytes = await FileManager.readBytesFromUri(file.uri);
+ return {
+ name: file.name || 'workbook.xlsx',
+ uri: file.uri,
+ size: file.size || bytes.byteLength,
+ bytes,
+ };
+ } catch (err: any) {
+ if (
+ err?.code === 'DOCUMENT_PICKER_CANCELED' ||
+ err?.code === 'OPERATION_CANCELED' ||
+ /cancel/i.test(err?.message || '')
+ ) {
+ return null;
+ }
+ throw err;
+ }
+ },
+
+ async writeBytes(fileName: string, bytes: Uint8Array): Promise {
+ const path = `${RNFS.DocumentDirectoryPath}/${fileName}`;
+ await RNFS.writeFile(path, FileManager.bytesToBase64(bytes), 'base64');
+ return path;
+ },
+
+ async writeUtf8(fileName: string, content: string): Promise {
+ const path = `${RNFS.DocumentDirectoryPath}/${fileName}`;
+ await RNFS.writeFile(path, content, 'utf8');
+ return path;
+ },
+
+ /**
+ * Write a Base64 string decoded to RAW bytes (RNFS `'base64'` mode). Use this
+ * for already-encrypted file blobs where the file on disk must be the raw
+ * ciphertext bytes, not Base64 text.
+ */
+ async writeBase64(fileName: string, base64: string): Promise {
+ const path = `${RNFS.DocumentDirectoryPath}/${fileName}`;
+ await RNFS.writeFile(path, base64, 'base64');
+ return path;
+ },
+
+ async exists(path: string): Promise {
+ try {
+ return await RNFS.exists(path);
+ } catch {
+ return false;
+ }
+ },
+
+ async remove(path: string): Promise {
+ try {
+ if (await RNFS.exists(path)) await RNFS.unlink(path);
+ } catch {
+ /* best-effort */
+ }
+ },
+
+ documentDir(): string {
+ return RNFS.DocumentDirectoryPath;
+ },
+};
+
+export default FileManager;
+
+
diff --git a/src/Spreadsheet/engine/FormulaEngine.ts b/src/Spreadsheet/engine/FormulaEngine.ts
new file mode 100644
index 0000000..0f152e8
--- /dev/null
+++ b/src/Spreadsheet/engine/FormulaEngine.ts
@@ -0,0 +1,212 @@
+/**
+ * FormulaEngine — typed wrapper around HyperFormula.
+ *
+ * Owns the workbook's values + formulas + dependency graph. Recalculates
+ * dependents incrementally on every edit, surfaces exactly which cells changed
+ * so the UI repaints only what moved, detects & rejects circular references,
+ * and preserves the original formula text for anything it cannot evaluate.
+ *
+ * No other module imports HyperFormula directly.
+ */
+import {
+ HyperFormula,
+ DetailedCellError,
+ ConfigParams,
+ RawCellContent,
+ ExportedCellChange,
+} from 'hyperformula';
+import { CellValue, WorkbookModel, SheetModel } from '../types';
+import { dateToExcelSerial } from '../utils/cellFormat';
+
+export interface ChangedCell {
+ sheetIndex: number;
+ row: number;
+ col: number;
+ value: CellValue;
+}
+
+export interface SetContentResult {
+ ok: boolean;
+ cycle: boolean;
+ changes: ChangedCell[];
+}
+
+// GPL open-source licence key accepted by HyperFormula.
+const HF_CONFIG: Partial = {
+ licenseKey: 'gpl-v3',
+ useColumnIndex: false,
+ smartRounding: true,
+ functionArgSeparator: ',',
+ dateFormats: ['DD/MM/YYYY', 'DD-MM-YYYY', 'YYYY-MM-DD', 'MM/DD/YYYY'],
+ maxRows: 200000,
+ maxColumns: 20000,
+};
+
+const CYCLE_VALUE = '#CYCLE!';
+
+const normalizeValue = (v: unknown): CellValue => {
+ if (v === undefined) return null;
+ if (v instanceof DetailedCellError) return v.value; // "#DIV/0!" etc.
+ if (v === null || typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean') {
+ return v;
+ }
+ return String(v);
+};
+
+const toRawContent = (formula: string | null, raw: CellValue): RawCellContent => {
+ if (formula) return `=${formula}`;
+ if (raw instanceof Date) return dateToExcelSerial(raw);
+ if (raw === undefined) return null;
+ return raw;
+};
+
+export class FormulaEngine {
+ private hf: HyperFormula;
+ private idByIndex = new Map();
+
+ private constructor(hf: HyperFormula, idByIndex: Map) {
+ this.hf = hf;
+ this.idByIndex = idByIndex;
+ }
+
+ static fromModel(model: WorkbookModel): FormulaEngine {
+ const sheetsData: Record = {};
+ const usedNames = new Set();
+
+ const safeName = (sheet: SheetModel): string => {
+ const base = sheet.name && sheet.name.trim() ? sheet.name.trim() : `Sheet${sheet.index + 1}`;
+ let name = base;
+ let n = 2;
+ while (usedNames.has(name.toLowerCase())) name = `${base} (${n++})`;
+ usedNames.add(name.toLowerCase());
+ return name;
+ };
+
+ const ordered: { sheet: SheetModel; name: string }[] = [];
+ for (const sheet of model.sheets) {
+ const name = safeName(sheet);
+ ordered.push({ sheet, name });
+ sheetsData[name] = FormulaEngine.buildSheetData(sheet);
+ }
+
+ const hf = HyperFormula.buildFromSheets(sheetsData, HF_CONFIG);
+
+ const idByIndex = new Map();
+ for (const { sheet, name } of ordered) {
+ const id = hf.getSheetId(name);
+ const resolved = id === undefined ? 0 : id;
+ idByIndex.set(sheet.index, resolved);
+ sheet.hfSheetId = resolved;
+ }
+
+ return new FormulaEngine(hf, idByIndex);
+ }
+
+ private static buildSheetData(sheet: SheetModel): RawCellContent[][] {
+ let maxRow = -1;
+ let maxCol = -1;
+ for (const cell of sheet.cells.values()) {
+ if (cell.row > maxRow) maxRow = cell.row;
+ if (cell.col > maxCol) maxCol = cell.col;
+ }
+ maxRow = Math.max(maxRow, Math.min(sheet.rowCount - 1, 0));
+ maxCol = Math.max(maxCol, Math.min(sheet.colCount - 1, 0));
+
+ const data: RawCellContent[][] = [];
+ for (let r = 0; r <= maxRow; r++) data.push(new Array(maxCol + 1).fill(null));
+ for (const cell of sheet.cells.values()) {
+ data[cell.row][cell.col] = toRawContent(cell.formula, cell.raw);
+ }
+ return data;
+ }
+
+ private idOf(sheetIndex: number): number {
+ return this.idByIndex.get(sheetIndex) ?? 0;
+ }
+
+ getValue(sheetIndex: number, row: number, col: number): CellValue {
+ return normalizeValue(this.hf.getCellValue({ sheet: this.idOf(sheetIndex), row, col }));
+ }
+
+ getFormula(sheetIndex: number, row: number, col: number): string | null {
+ return this.hf.getCellFormula({ sheet: this.idOf(sheetIndex), row, col }) ?? null;
+ }
+
+ isCycle(sheetIndex: number, row: number, col: number): boolean {
+ return this.getValue(sheetIndex, row, col) === CYCLE_VALUE;
+ }
+
+ /**
+ * Apply a parsed edit (formula without `=`, or a literal value). The value is
+ * fed to HyperFormula exactly as given so engine and domain model never
+ * diverge. Cycles are rolled back and reported.
+ */
+ setCell(
+ sheetIndex: number,
+ row: number,
+ col: number,
+ formula: string | null,
+ value: CellValue,
+ ): SetContentResult {
+ const sheet = this.idOf(sheetIndex);
+ const addr = { sheet, row, col };
+ const previous = this.hf.getCellSerialized(addr);
+ const raw = toRawContent(formula, value);
+
+ let exported: ExportedCellChange[];
+ try {
+ exported = this.hf.setCellContents(addr, [[raw]]) as ExportedCellChange[];
+ } catch {
+ return { ok: false, cycle: false, changes: [] };
+ }
+
+ if (this.isCycle(sheetIndex, row, col)) {
+ this.hf.setCellContents(addr, [[previous]]);
+ return { ok: false, cycle: true, changes: [] };
+ }
+
+ const changes: ChangedCell[] = [];
+ for (const change of exported) {
+ if (change instanceof ExportedCellChange) {
+ const idx = this.indexOfId(change.sheet);
+ if (idx === -1) continue;
+ changes.push({
+ sheetIndex: idx,
+ row: change.row,
+ col: change.col,
+ value: normalizeValue(change.newValue),
+ });
+ }
+ }
+ return { ok: true, cycle: false, changes };
+ }
+
+ private indexOfId(id: number): number {
+ for (const [index, sheetId] of this.idByIndex.entries()) {
+ if (sheetId === id) return index;
+ }
+ return -1;
+ }
+
+ getColumnValues(sheetIndex: number, col: number, rowCount: number): CellValue[] {
+ const out: CellValue[] = [];
+ for (let r = 0; r < rowCount; r++) out.push(this.getValue(sheetIndex, r, col));
+ return out;
+ }
+
+ getSheetSerialized(sheetIndex: number): RawCellContent[][] {
+ return this.hf.getSheetSerialized(this.idOf(sheetIndex));
+ }
+
+ destroy(): void {
+ try {
+ this.hf.destroy();
+ } catch {
+ /* already destroyed */
+ }
+ }
+}
+
+export default FormulaEngine;
+
+
diff --git a/src/Spreadsheet/engine/PerformanceManager.ts b/src/Spreadsheet/engine/PerformanceManager.ts
new file mode 100644
index 0000000..805671a
--- /dev/null
+++ b/src/Spreadsheet/engine/PerformanceManager.ts
@@ -0,0 +1,38 @@
+/**
+ * PerformanceManager — centralizes the tuning knobs that keep the grid smooth
+ * on large BOQ files (5k–20k rows, 200+ columns). Pure config + helpers, so the
+ * numbers live in one place and can be adjusted without touching the grid.
+ */
+import { InteractionManager } from 'react-native';
+
+export const PerformanceManager = {
+ /** Files above this byte size parse after interactions (loader paints first). */
+ DEFER_PARSE_BYTES: 256 * 1024,
+
+ /** FlashList render window multipliers. */
+ DRAW_DISTANCE: 320, // px of off-screen content to keep mounted
+
+ /** Recommended FlashList props for a fixed row height. */
+ flashListProps(estimatedRowHeight: number) {
+ return {
+ estimatedItemSize: estimatedRowHeight,
+ drawDistance: PerformanceManager.DRAW_DISTANCE,
+ // FlashList already recycles; extra clipping avoids retaining heavy cells.
+ removeClippedSubviews: true,
+ } as const;
+ },
+
+ /** Run heavy work after the current interaction/animation completes. */
+ afterInteractions(): Promise {
+ return new Promise(resolve => InteractionManager.runAfterInteractions(() => resolve()));
+ },
+
+ /** Whether to defer parsing this payload off the initial render. */
+ shouldDeferParse(byteLength: number): boolean {
+ return byteLength >= PerformanceManager.DEFER_PARSE_BYTES;
+ },
+};
+
+export default PerformanceManager;
+
+
diff --git a/src/Spreadsheet/engine/SelectionManager.ts b/src/Spreadsheet/engine/SelectionManager.ts
new file mode 100644
index 0000000..a5d5f21
--- /dev/null
+++ b/src/Spreadsheet/engine/SelectionManager.ts
@@ -0,0 +1,91 @@
+/**
+ * SelectionManager — pure selection state transitions (no React state).
+ * Single & multi-cell selection, shift-extend, keyboard navigation (skipping
+ * hidden rows/cols) and whole row/column/all selection.
+ */
+import { Address, CellRange } from '../types';
+import { normalizeRange } from '../utils/address';
+
+export interface SelectionState {
+ active: Address;
+ range: CellRange;
+ anchor: Address;
+}
+
+export interface GridBounds {
+ rowCount: number;
+ colCount: number;
+ hiddenRows?: Set;
+ hiddenColumns?: Set;
+}
+
+const clamp = (v: number, min: number, max: number): number =>
+ Math.max(min, Math.min(max, v));
+
+const nextVisible = (from: number, dir: number, count: number, hidden?: Set): number => {
+ let i = from + dir;
+ while (i >= 0 && i < count && hidden && hidden.has(i)) i += dir;
+ if (i < 0 || i >= count) return from;
+ return i;
+};
+
+export const SelectionManager = {
+ create(active: Address = { row: 0, col: 0 }): SelectionState {
+ return { active, range: normalizeRange(active, active), anchor: active };
+ },
+
+ selectCell(addr: Address): SelectionState {
+ return { active: addr, range: normalizeRange(addr, addr), anchor: addr };
+ },
+
+ extendTo(state: SelectionState, addr: Address): SelectionState {
+ return { active: addr, anchor: state.anchor, range: normalizeRange(state.anchor, addr) };
+ },
+
+ move(state: SelectionState, dRow: number, dCol: number, bounds: GridBounds): SelectionState {
+ let { row, col } = state.active;
+ if (dRow !== 0) row = nextVisible(row, Math.sign(dRow), bounds.rowCount, bounds.hiddenRows);
+ if (dCol !== 0) col = nextVisible(col, Math.sign(dCol), bounds.colCount, bounds.hiddenColumns);
+ return SelectionManager.selectCell({
+ row: clamp(row, 0, bounds.rowCount - 1),
+ col: clamp(col, 0, bounds.colCount - 1),
+ });
+ },
+
+ moveAndExtend(state: SelectionState, dRow: number, dCol: number, bounds: GridBounds): SelectionState {
+ let { row, col } = state.active;
+ if (dRow !== 0) row = nextVisible(row, Math.sign(dRow), bounds.rowCount, bounds.hiddenRows);
+ if (dCol !== 0) col = nextVisible(col, Math.sign(dCol), bounds.colCount, bounds.hiddenColumns);
+ return SelectionManager.extendTo(state, {
+ row: clamp(row, 0, bounds.rowCount - 1),
+ col: clamp(col, 0, bounds.colCount - 1),
+ });
+ },
+
+ selectRow(row: number, colCount: number): SelectionState {
+ const active = { row, col: 0 };
+ return { active, anchor: active, range: normalizeRange({ row, col: 0 }, { row, col: colCount - 1 }) };
+ },
+
+ selectColumn(col: number, rowCount: number): SelectionState {
+ const active = { row: 0, col };
+ return { active, anchor: active, range: normalizeRange({ row: 0, col }, { row: rowCount - 1, col }) };
+ },
+
+ selectAll(bounds: GridBounds): SelectionState {
+ const active = { row: 0, col: 0 };
+ return {
+ active,
+ anchor: active,
+ range: normalizeRange({ row: 0, col: 0 }, { row: bounds.rowCount - 1, col: bounds.colCount - 1 }),
+ };
+ },
+
+ selectRange(anchor: Address, focus: Address): SelectionState {
+ return { active: focus, anchor, range: normalizeRange(anchor, focus) };
+ },
+};
+
+export default SelectionManager;
+
+
diff --git a/src/Spreadsheet/engine/UndoRedoManager.ts b/src/Spreadsheet/engine/UndoRedoManager.ts
new file mode 100644
index 0000000..7d008f2
--- /dev/null
+++ b/src/Spreadsheet/engine/UndoRedoManager.ts
@@ -0,0 +1,59 @@
+/**
+ * UndoRedoManager — bounded transaction history. Each entry is a batch of edits
+ * applied/reverted atomically. Pushing a new transaction discards the redo tail.
+ */
+import { EditTransaction } from '../types';
+
+const DEFAULT_LIMIT = 200;
+
+export class UndoRedoManager {
+ private undoStack: EditTransaction[] = [];
+ private redoStack: EditTransaction[] = [];
+ private limit: number;
+
+ constructor(limit = DEFAULT_LIMIT) {
+ this.limit = limit;
+ }
+
+ push(tx: EditTransaction): void {
+ if (tx.edits.length === 0) return;
+ this.undoStack.push(tx);
+ if (this.undoStack.length > this.limit) this.undoStack.shift();
+ this.redoStack = [];
+ }
+
+ canUndo(): boolean {
+ return this.undoStack.length > 0;
+ }
+
+ canRedo(): boolean {
+ return this.redoStack.length > 0;
+ }
+
+ undo(): EditTransaction | null {
+ const tx = this.undoStack.pop();
+ if (!tx) return null;
+ this.redoStack.push(tx);
+ return tx;
+ }
+
+ redo(): EditTransaction | null {
+ const tx = this.redoStack.pop();
+ if (!tx) return null;
+ this.undoStack.push(tx);
+ return tx;
+ }
+
+ clear(): void {
+ this.undoStack = [];
+ this.redoStack = [];
+ }
+
+ get depth(): { undo: number; redo: number } {
+ return { undo: this.undoStack.length, redo: this.redoStack.length };
+ }
+}
+
+export default UndoRedoManager;
+
+
diff --git a/src/Spreadsheet/engine/ValidationEngine.ts b/src/Spreadsheet/engine/ValidationEngine.ts
new file mode 100644
index 0000000..b546e16
--- /dev/null
+++ b/src/Spreadsheet/engine/ValidationEngine.ts
@@ -0,0 +1,233 @@
+/**
+ * ValidationEngine — declarative, pure validation of the EDITABLE cells.
+ *
+ * Rules: required, numeric, decimal/whole, min/max (operators), list/dropdown,
+ * text length, duplicate detection (column-scoped) and custom predicates.
+ * Formula evaluation errors (#DIV/0!, #REF!, #CYCLE!, …) are surfaced for any
+ * cell. Non-editable input cells are never flagged for rule violations.
+ *
+ * Results are a flat `ValidationError[]`; the UI indexes them by address, and
+ * the submit flow blocks while any error exists.
+ */
+import {
+ Address,
+ CellModel,
+ CellValue,
+ DataValidationRule,
+ SheetModel,
+ ValidationContext,
+ ValidationError,
+ ValidationOperator,
+ WorkbookModel,
+ cellKey,
+ isCellEditable,
+} from '../types';
+import FormulaEngine from './FormulaEngine';
+
+const isBlank = (v: CellValue): boolean =>
+ v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
+
+const asNumber = (v: CellValue): number | null => {
+ if (typeof v === 'number') return isFinite(v) ? v : null;
+ if (typeof v === 'string') {
+ const n = Number(v.replace(/[₹$€£¥,\s%]/g, ''));
+ return isNaN(n) ? null : n;
+ }
+ if (typeof v === 'boolean') return v ? 1 : 0;
+ return null;
+};
+
+const CELL_ERROR_RE = /^#[A-Z0-9/!?]+!?$/;
+
+const compare = (
+ value: number,
+ op: ValidationOperator | undefined,
+ min: number | undefined,
+ max: number | undefined,
+): boolean => {
+ switch (op) {
+ case 'between':
+ return min == null || max == null || (value >= min && value <= max);
+ case 'notBetween':
+ return min == null || max == null || value < min || value > max;
+ case 'equal':
+ return min == null || value === min;
+ case 'notEqual':
+ return min == null || value !== min;
+ case 'greaterThan':
+ return min == null || value > min;
+ case 'lessThan':
+ return min == null || value < min;
+ case 'greaterThanOrEqual':
+ return min == null || value >= min;
+ case 'lessThanOrEqual':
+ return min == null || value <= min;
+ default:
+ if (min != null && value < min) return false;
+ if (max != null && value > max) return false;
+ return true;
+ }
+};
+
+const boundsLabel = (rule: DataValidationRule): string => {
+ if (rule.min != null && rule.max != null) return `between ${rule.min} and ${rule.max}`;
+ if (rule.min != null) return `≥ ${rule.min}`;
+ if (rule.max != null) return `≤ ${rule.max}`;
+ return '';
+};
+
+export class ValidationEngine {
+ static validateValue(
+ cell: CellModel,
+ value: CellValue,
+ ctx: ValidationContext,
+ ): ValidationError | null {
+ const addr: Address = { row: cell.row, col: cell.col };
+ const err = (
+ message: string,
+ ruleKind: ValidationError['ruleKind'],
+ ): ValidationError => ({ address: addr, sheetIndex: ctx.sheetIndex, message, ruleKind });
+
+ if (typeof value === 'string' && CELL_ERROR_RE.test(value)) {
+ return err(`Formula error: ${value}`, 'type');
+ }
+
+ const rule = cell.validation;
+ const blank = isBlank(value);
+
+ if (rule?.required && blank) {
+ return err(rule.errorMessage || 'This field is required.', 'required');
+ }
+ if (blank) return null;
+
+ if (
+ (cell.type === 'number' || cell.type === 'currency' || cell.type === 'percentage') &&
+ asNumber(value) === null
+ ) {
+ return err('Enter a valid number.', 'type');
+ }
+
+ if (!rule || rule.kind === 'none') return null;
+
+ switch (rule.kind) {
+ case 'list': {
+ const opts = rule.list || [];
+ if (opts.length && !opts.map(String).includes(String(value))) {
+ return err(rule.errorMessage || `Choose one of: ${opts.join(', ')}`, 'type');
+ }
+ return null;
+ }
+ case 'decimal':
+ case 'whole': {
+ const n = asNumber(value);
+ if (n === null) return err('Enter a valid number.', 'type');
+ if (rule.kind === 'whole' && !Number.isInteger(n)) {
+ return err(rule.errorMessage || 'Enter a whole number.', 'type');
+ }
+ if (!compare(n, rule.operator, rule.min, rule.max)) {
+ return err(rule.errorMessage || `Value must be ${boundsLabel(rule)}.`, 'type');
+ }
+ return null;
+ }
+ case 'textLength': {
+ const len = String(value).length;
+ if (!compare(len, rule.operator, rule.min, rule.max)) {
+ return err(rule.errorMessage || `Length must be ${boundsLabel(rule)}.`, 'type');
+ }
+ return null;
+ }
+ case 'date': {
+ if (asNumber(value) === null && isNaN(Date.parse(String(value)))) {
+ return err(rule.errorMessage || 'Enter a valid date.', 'type');
+ }
+ return null;
+ }
+ case 'custom': {
+ if (rule.validate && !rule.validate(value, ctx)) {
+ return err(rule.errorMessage || 'Invalid value.', 'type');
+ }
+ return null;
+ }
+ default:
+ return null;
+ }
+ }
+
+ static validateSheet(
+ sheet: SheetModel,
+ sheetIndex: number,
+ engine: FormulaEngine,
+ ): ValidationError[] {
+ const errors: ValidationError[] = [];
+ const columnCache = new Map();
+ const columnValues = (col: number): CellValue[] => {
+ let cached = columnCache.get(col);
+ if (!cached) {
+ cached = engine.getColumnValues(sheetIndex, col, sheet.rowCount);
+ columnCache.set(col, cached);
+ }
+ return cached;
+ };
+ const seenByCol = new Map>();
+
+ for (const cell of sheet.cells.values()) {
+ const value = cell.formula
+ ? engine.getValue(sheetIndex, cell.row, cell.col)
+ : cell.raw;
+
+ // Always surface formula errors; only rule-validate editable input cells.
+ const editable = isCellEditable(cell, sheet.protected);
+ const isFormulaError = typeof value === 'string' && CELL_ERROR_RE.test(value);
+ if (!editable && !isFormulaError) continue;
+
+ const ctx: ValidationContext = {
+ address: { row: cell.row, col: cell.col },
+ sheetIndex,
+ columnValues: columnValues(cell.col),
+ };
+
+ const e = ValidationEngine.validateValue(cell, value, ctx);
+ if (e) errors.push(e);
+
+ if (editable && cell.validation?.noDuplicates && !isBlank(value)) {
+ let seen = seenByCol.get(cell.col);
+ if (!seen) {
+ seen = new Map();
+ seenByCol.set(cell.col, seen);
+ }
+ const norm = String(value).trim().toLowerCase();
+ if (seen.has(norm)) {
+ errors.push({
+ address: { row: cell.row, col: cell.col },
+ sheetIndex,
+ message: cell.validation.errorMessage || 'Duplicate value is not allowed in this column.',
+ ruleKind: 'duplicate',
+ });
+ } else {
+ seen.set(norm, { row: cell.row, col: cell.col });
+ }
+ }
+ }
+ return errors;
+ }
+
+ static validateWorkbook(model: WorkbookModel, engine: FormulaEngine): ValidationError[] {
+ const all: ValidationError[] = [];
+ for (const sheet of model.sheets) {
+ all.push(...ValidationEngine.validateSheet(sheet, sheet.index, engine));
+ }
+ return all;
+ }
+
+ static index(errors: ValidationError[]): Map {
+ const map = new Map();
+ for (const e of errors) {
+ map.set(`${e.sheetIndex}:${cellKey(e.address.row, e.address.col)}`, e);
+ }
+ return map;
+ }
+}
+
+export default ValidationEngine;
+
+
diff --git a/src/Spreadsheet/engine/WorkbookManager.ts b/src/Spreadsheet/engine/WorkbookManager.ts
new file mode 100644
index 0000000..7b8b6d1
--- /dev/null
+++ b/src/Spreadsheet/engine/WorkbookManager.ts
@@ -0,0 +1,41 @@
+/**
+ * WorkbookManager — orchestrates workbook open/save by selecting the best
+ * available backend: Apache POI native (preferred, Android) when the native
+ * module is built, else the pure-JS ExcelJS backend. The rest of the app talks
+ * only to this manager and never cares which engine is underneath.
+ */
+import { WorkbookModel } from '../types';
+import FormulaEngine from './FormulaEngine';
+import { EditedMap, OpenResult, WorkbookBackend } from './backends/WorkbookBackend';
+import NativePoiBackend from './backends/NativePoiBackend';
+import JsExcelBackend from './backends/JsExcelBackend';
+
+export class WorkbookManager {
+ private backend: WorkbookBackend;
+
+ constructor(preferNative = true) {
+ const native = new NativePoiBackend();
+ this.backend = preferNative && native.isAvailable() ? native : new JsExcelBackend();
+ }
+
+ /** Which backend is in use — 'native-poi' or 'js-exceljs'. */
+ get backendName(): WorkbookBackend['name'] {
+ return this.backend.name;
+ }
+
+ open(bytes: Uint8Array, fileName: string): Promise {
+ return this.backend.open(bytes, fileName);
+ }
+
+ save(model: WorkbookModel, engine: FormulaEngine, edited: EditedMap): Promise {
+ return this.backend.save(model, engine, edited);
+ }
+
+ close(): Promise {
+ return this.backend.close();
+ }
+}
+
+export default WorkbookManager;
+
+
diff --git a/src/Spreadsheet/engine/WorkbookParser.ts b/src/Spreadsheet/engine/WorkbookParser.ts
new file mode 100644
index 0000000..e9cb23f
--- /dev/null
+++ b/src/Spreadsheet/engine/WorkbookParser.ts
@@ -0,0 +1,320 @@
+/**
+ * WorkbookParser — ExcelJS workbook -> `WorkbookModel` (JS fallback backend).
+ *
+ * Reads every metadata feature ExcelJS understands: values, formulas, number
+ * formats, fonts/fills/borders/alignment, merged cells, comments, column
+ * widths, row heights, hidden rows/cols, frozen panes, data validations, sheet
+ * state, and — critically for the BOQ editability contract — cell LOCKED flags
+ * and sheet PROTECTION. The primary Android path uses Apache POI; this exists
+ * so the editor also runs on iOS and wherever the native module is absent.
+ */
+import '../polyfills';
+import ExcelJS from 'exceljs';
+import {
+ CellModel,
+ CellStyle,
+ CellType,
+ CellValue,
+ DataValidationRule,
+ SheetModel,
+ WorkbookModel,
+ cellKey,
+} from '../types';
+import { argbToHex } from '../utils/color';
+import { dateToExcelSerial } from '../utils/cellFormat';
+import { parseRangeA1 } from '../utils/address';
+
+export interface ParseResult {
+ model: WorkbookModel;
+ warnings: string[];
+ /** The ExcelJS workbook, retained for byte-faithful writes. */
+ source: unknown;
+}
+
+const MAX_ROWS = 100000;
+const MAX_COLS = 1024;
+const DEFAULT_COL_WIDTH_PX = 96;
+const DEFAULT_ROW_HEIGHT_PX = 40;
+
+const charsToPx = (chars?: number): number | undefined =>
+ chars == null ? undefined : Math.max(48, Math.min(420, Math.round(chars * 7.5 + 8)));
+const pointsToPx = (pts?: number): number | undefined =>
+ pts == null ? undefined : Math.max(28, Math.min(240, Math.round(pts * 1.3333)));
+
+const typeFromNumFmt = (numFmt: string | undefined): CellType => {
+ if (numFmt) {
+ const stripped = numFmt.replace(/\[[^\]]*\]/g, '').replace(/"[^"]*"/g, '');
+ if (/%/.test(numFmt)) return 'percentage';
+ if (/[dmy]/i.test(stripped) && !/[#0]/.test(stripped)) return 'date';
+ if (/[$€£₹¥]/.test(numFmt)) return 'currency';
+ }
+ return 'number';
+};
+
+// ExcelJS.ValueType is an ambient `const enum`; under `isolatedModules` we
+// cannot reference its members, so mirror the numeric values locally.
+const VT = {
+ Null: 0,
+ Merge: 1,
+ Number: 2,
+ String: 3,
+ Date: 4,
+ Hyperlink: 5,
+ Formula: 6,
+ SharedString: 7,
+ RichText: 8,
+ Boolean: 9,
+ Error: 10,
+} as const;
+
+const inferType = (excelType: number, numFmt: string | undefined): CellType => {
+ if (excelType === VT.Boolean) return 'boolean';
+ if (excelType === VT.Date) return 'date';
+ if (excelType === VT.Number || excelType === VT.Formula) return typeFromNumFmt(numFmt);
+ return 'text';
+};
+
+const extractText = (value: any): string => {
+ if (value == null) return '';
+ if (typeof value === 'object') {
+ if (Array.isArray(value.richText)) return value.richText.map((r: any) => r.text ?? '').join('');
+ if (value.text != null) return String(value.text);
+ if (value.error != null) return String(value.error);
+ if (value.result != null) return String(value.result);
+ }
+ return String(value);
+};
+
+const toRaw = (cell: any, type: CellType, isFormula: boolean): CellValue => {
+ const v = cell.value;
+ if (v == null) return null;
+ if (isFormula) {
+ const result = cell.result ?? (typeof v === 'object' ? v.result : v);
+ if (result instanceof Date) return dateToExcelSerial(result);
+ if (result && typeof result === 'object') {
+ return result.error != null ? String(result.error) : extractText(result);
+ }
+ return (result ?? null) as CellValue;
+ }
+ if (v instanceof Date) return dateToExcelSerial(v);
+ if (type === 'date' && typeof v === 'number') return v;
+ if (typeof v === 'object') return extractText(v);
+ return v as CellValue;
+};
+
+const mapStyle = (style: any): CellStyle | undefined => {
+ if (!style) return undefined;
+ const out: CellStyle = {};
+ const { font, fill, border, alignment } = style;
+ if (font) {
+ if (font.bold) out.bold = true;
+ if (font.italic) out.italic = true;
+ if (font.underline) out.underline = true;
+ if (font.strike) out.strike = true;
+ if (font.size) out.fontSize = font.size;
+ if (font.name) out.fontFamily = font.name;
+ const fc = argbToHex(font.color?.argb);
+ if (fc) out.fontColor = fc;
+ }
+ if (fill && fill.type === 'pattern' && fill.pattern === 'solid') {
+ const bg = argbToHex(fill.fgColor?.argb);
+ if (bg) out.bgColor = bg;
+ }
+ if (alignment) {
+ if (alignment.horizontal) out.hAlign = alignment.horizontal;
+ if (alignment.vertical) out.vAlign = alignment.vertical;
+ if (alignment.wrapText) out.wrap = true;
+ }
+ if (border) {
+ const b: NonNullable = {};
+ if (border.top?.style) b.top = true;
+ if (border.right?.style) b.right = true;
+ if (border.bottom?.style) b.bottom = true;
+ if (border.left?.style) b.left = true;
+ if (b.top || b.right || b.bottom || b.left) out.border = b;
+ }
+ return Object.keys(out).length ? out : undefined;
+};
+
+const mapValidation = (dv: any): { rule?: DataValidationRule; options?: string[] } => {
+ if (!dv || !dv.type) return {};
+ const allowBlank = dv.allowBlank !== false;
+ if (dv.type === 'list') {
+ let options: string[] = [];
+ const f = Array.isArray(dv.formulae) ? dv.formulae[0] : dv.formulae;
+ if (typeof f === 'string') {
+ const inline = f.replace(/^"|"$/g, '');
+ if (!/^[A-Za-z]+\$?\d/.test(inline)) {
+ options = inline.split(',').map(s => s.trim()).filter(Boolean);
+ }
+ }
+ return {
+ options,
+ rule: { kind: 'list', list: options, allowBlank, errorMessage: dv.error || 'Value must be one of the allowed options.' },
+ };
+ }
+ if (dv.type === 'decimal' || dv.type === 'whole') {
+ const nums = (dv.formulae || []).map((x: any) => Number(x));
+ return { rule: { kind: dv.type === 'whole' ? 'whole' : 'decimal', operator: dv.operator, min: nums[0], max: nums[1], allowBlank, errorMessage: dv.error } };
+ }
+ if (dv.type === 'textLength') {
+ const nums = (dv.formulae || []).map((x: any) => Number(x));
+ return { rule: { kind: 'textLength', operator: dv.operator, min: nums[0], max: nums[1], allowBlank, errorMessage: dv.error } };
+ }
+ return {};
+};
+
+const detectSheetProtection = (ws: any): boolean => {
+ const candidates = [ws.sheetProtection, ws.model?.sheetProtection, (ws as any)._sheetProtection];
+ for (const c of candidates) {
+ if (c === true) return true;
+ if (c && (c.sheet === true || c.enabled === true)) return true;
+ }
+ return false;
+};
+
+const parseWorksheet = (ws: any, index: number): SheetModel => {
+ const cells = new Map();
+ const columnWidths = new Map();
+ const rowHeights = new Map();
+ const hiddenRows = new Set();
+ const hiddenColumns = new Set();
+
+ const actualRows = Math.min(ws.actualRowCount || ws.rowCount || 0, MAX_ROWS);
+ const actualCols = Math.min(ws.actualColumnCount || ws.columnCount || 0, MAX_COLS);
+ let maxRow = actualRows - 1;
+ let maxCol = actualCols - 1;
+ let sawUnlocked = false;
+
+ for (let c = 1; c <= actualCols; c++) {
+ const column = ws.getColumn(c);
+ const px = charsToPx(column?.width);
+ if (px) columnWidths.set(c - 1, px);
+ if (column?.hidden) hiddenColumns.add(c - 1);
+ }
+
+ ws.eachRow({ includeEmpty: true }, (row: any, rowNumber: number) => {
+ if (rowNumber > MAX_ROWS) return;
+ const r = rowNumber - 1;
+ if (r > maxRow) maxRow = r;
+ const hpx = pointsToPx(row.height);
+ if (hpx) rowHeights.set(r, hpx);
+ if (row.hidden) hiddenRows.add(r);
+
+ row.eachCell({ includeEmpty: true }, (cell: any, colNumber: number) => {
+ if (colNumber > MAX_COLS) return;
+ const c = colNumber - 1;
+ if (c > maxCol) maxCol = c;
+
+ const isMergeSlave = cell.type === VT.Merge;
+ const numFmt: string | undefined = cell.numFmt || undefined;
+ const type = inferType(cell.type, numFmt);
+ const isFormula = cell.type === VT.Formula;
+ const style = mapStyle(cell.style);
+ const note = cell.note != null ? extractText(cell.note) : undefined;
+ const { rule, options } = mapValidation((cell as any).dataValidation);
+
+ // Excel defaults locked=true; a template unlocks its input cells.
+ const explicitlyUnlocked = cell.style?.protection?.locked === false;
+ if (explicitlyUnlocked) sawUnlocked = true;
+ const locked = !explicitlyUnlocked;
+
+ const hasContent = cell.value != null && !isMergeSlave;
+ const hasMeta = !!style || !!note || !!rule || explicitlyUnlocked;
+ if (!hasContent && !hasMeta) return;
+
+ const formula: string | null = isFormula
+ ? String(cell.formula ?? (cell.value as any)?.formula ?? '').replace(/^=/, '')
+ : null;
+
+ cells.set(cellKey(r, c), {
+ row: r,
+ col: c,
+ raw: isMergeSlave ? null : toRaw(cell, type, isFormula),
+ formula,
+ type,
+ numFmt,
+ style,
+ comment: note,
+ locked,
+ validation: rule,
+ dropdownOptions: options && options.length ? options : undefined,
+ });
+ });
+ });
+
+ let frozenRows = 0;
+ let frozenCols = 0;
+ const view = Array.isArray(ws.views) ? ws.views[0] : undefined;
+ if (view && view.state === 'frozen') {
+ frozenRows = view.ySplit || 0;
+ frozenCols = view.xSplit || 0;
+ }
+
+ const merges: SheetModel['merges'] = [];
+ for (const m of (ws.model?.merges || []) as string[]) {
+ const range = parseRangeA1(m);
+ if (range) {
+ merges.push(range);
+ if (range.end.row > maxRow) maxRow = range.end.row;
+ if (range.end.col > maxCol) maxCol = range.end.col;
+ }
+ }
+
+ // A sheet is "protected" if POI/ExcelJS reports it, OR the template unlocked
+ // specific input cells (unlocking only matters under protection).
+ const isProtected = detectSheetProtection(ws) || sawUnlocked;
+
+ return {
+ id: `sheet-${index}`,
+ name: ws.name || `Sheet${index + 1}`,
+ index,
+ hfSheetId: index,
+ rowCount: Math.max(maxRow + 1, 1),
+ colCount: Math.max(maxCol + 1, 1),
+ cells,
+ columnWidths,
+ rowHeights,
+ hiddenRows,
+ hiddenColumns,
+ merges,
+ frozenRows,
+ frozenCols,
+ state: (ws.state as SheetModel['state']) || 'visible',
+ protected: isProtected,
+ defaultColWidth: DEFAULT_COL_WIDTH_PX,
+ defaultRowHeight: DEFAULT_ROW_HEIGHT_PX,
+ };
+};
+
+export class WorkbookParser {
+ static async parse(data: ArrayBuffer | Uint8Array, fileName = 'workbook.xlsx'): Promise {
+ const warnings: string[] = [];
+ const workbook = new ExcelJS.Workbook();
+ const buffer =
+ data instanceof Uint8Array
+ ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
+ : data;
+
+ await workbook.xlsx.load(buffer as ArrayBuffer);
+
+ const sheets: SheetModel[] = [];
+ workbook.worksheets.forEach((ws, i) => {
+ try {
+ sheets.push(parseWorksheet(ws, i));
+ } catch (e: any) {
+ warnings.push(`Sheet "${ws.name}" partially parsed: ${e?.message || e}`);
+ }
+ });
+
+ if (sheets.length === 0) throw new Error('Workbook contains no readable worksheets.');
+
+ const activeSheetIndex = Math.max(0, sheets.findIndex(s => s.state === 'visible'));
+ const model: WorkbookModel = { sheets, activeSheetIndex, fileName };
+ return { model, warnings, source: workbook };
+ }
+}
+
+export default WorkbookParser;
+
+
diff --git a/src/Spreadsheet/engine/WorkbookWriter.ts b/src/Spreadsheet/engine/WorkbookWriter.ts
new file mode 100644
index 0000000..6325956
--- /dev/null
+++ b/src/Spreadsheet/engine/WorkbookWriter.ts
@@ -0,0 +1,91 @@
+/**
+ * WorkbookWriter — write edited values back onto the ORIGINAL ExcelJS workbook
+ * (JS fallback backend). Mutates only the values/formulas of cells the user
+ * actually changed; every other feature (styles, merges, comments, widths,
+ * hidden rows, sheet order, protection, named ranges, …) is re-serialized
+ * untouched. Setting `cell.value` does not clear `cell.style`/`numFmt`, so
+ * formatting survives even on rewritten cells. The file opens in Excel with no
+ * repair dialog.
+ */
+import '../polyfills';
+import { CellModel, WorkbookModel } from '../types';
+import { excelSerialToDate } from '../utils/cellFormat';
+import FormulaEngine from './FormulaEngine';
+
+type EditedMap = Map>;
+
+const isErrorString = (v: unknown): v is string =>
+ typeof v === 'string' && /^#[A-Z0-9/!?]+!?$/.test(v);
+
+const toUint8 = (buf: any): Uint8Array => {
+ if (buf instanceof Uint8Array) return buf;
+ if (buf instanceof ArrayBuffer) return new Uint8Array(buf);
+ if (buf && buf.buffer instanceof ArrayBuffer) {
+ return new Uint8Array(buf.buffer, buf.byteOffset || 0, buf.byteLength);
+ }
+ return Uint8Array.from(buf);
+};
+
+export class WorkbookWriter {
+ static async toUint8Array(
+ source: any,
+ model: WorkbookModel,
+ engine: FormulaEngine,
+ edited?: EditedMap,
+ ): Promise {
+ for (const sheet of model.sheets) {
+ const ws = source.worksheets[sheet.index];
+ if (!ws) continue;
+ const keys: Iterable = edited?.get(sheet.index) ?? sheet.cells.keys();
+ for (const key of keys) {
+ const cell = sheet.cells.get(key);
+ if (!cell) {
+ const [r, c] = key.split(':').map(Number);
+ ws.getCell(r + 1, c + 1).value = null;
+ continue;
+ }
+ WorkbookWriter.writeCell(ws, sheet.index, cell, engine);
+ }
+ }
+ return toUint8(await source.xlsx.writeBuffer());
+ }
+
+ private static writeCell(ws: any, sheetIndex: number, cell: CellModel, engine: FormulaEngine): void {
+ const target = ws.getCell(cell.row + 1, cell.col + 1);
+
+ if (cell.formula) {
+ const result = engine.getValue(sheetIndex, cell.row, cell.col);
+ target.value = {
+ formula: cell.formula,
+ result: isErrorString(result) ? { error: result } : (result as any),
+ };
+ return;
+ }
+
+ const raw = cell.raw;
+ if (raw === null || raw === undefined || raw === '') {
+ target.value = null;
+ return;
+ }
+
+ switch (cell.type) {
+ case 'date':
+ target.value = typeof raw === 'number' ? excelSerialToDate(raw) : (raw as any);
+ break;
+ case 'boolean':
+ target.value = Boolean(raw);
+ break;
+ case 'number':
+ case 'currency':
+ case 'percentage':
+ target.value = typeof raw === 'number' ? raw : Number(raw);
+ break;
+ default:
+ target.value = raw as any;
+ }
+ }
+}
+
+export default WorkbookWriter;
+
+
diff --git a/src/Spreadsheet/engine/backends/JsExcelBackend.ts b/src/Spreadsheet/engine/backends/JsExcelBackend.ts
new file mode 100644
index 0000000..2c45141
--- /dev/null
+++ b/src/Spreadsheet/engine/backends/JsExcelBackend.ts
@@ -0,0 +1,40 @@
+/**
+ * JsExcelBackend — pure-JS workbook backend built on ExcelJS.
+ *
+ * The cross-platform fallback used on iOS and wherever the native POI module is
+ * not built. Retains the parsed ExcelJS workbook so `save()` writes only the
+ * edited values back onto the original file (no rebuild).
+ */
+import { WorkbookModel } from '../../types';
+import FormulaEngine from '../FormulaEngine';
+import WorkbookParser from '../WorkbookParser';
+import WorkbookWriter from '../WorkbookWriter';
+import { EditedMap, OpenResult, WorkbookBackend } from './WorkbookBackend';
+
+export class JsExcelBackend implements WorkbookBackend {
+ readonly name = 'js-exceljs' as const;
+ private source: unknown = null;
+
+ isAvailable(): boolean {
+ return true; // always available (pure JS)
+ }
+
+ async open(bytes: Uint8Array, fileName: string): Promise {
+ const { model, warnings, source } = await WorkbookParser.parse(bytes, fileName);
+ this.source = source;
+ return { model, warnings };
+ }
+
+ async save(model: WorkbookModel, engine: FormulaEngine, edited: EditedMap): Promise {
+ if (!this.source) throw new Error('No workbook is open.');
+ return WorkbookWriter.toUint8Array(this.source, model, engine, edited);
+ }
+
+ async close(): Promise {
+ this.source = null;
+ }
+}
+
+export default JsExcelBackend;
+
+
diff --git a/src/Spreadsheet/engine/backends/NativePoiBackend.ts b/src/Spreadsheet/engine/backends/NativePoiBackend.ts
new file mode 100644
index 0000000..93a66b5
--- /dev/null
+++ b/src/Spreadsheet/engine/backends/NativePoiBackend.ts
@@ -0,0 +1,160 @@
+/**
+ * NativePoiBackend — the preferred Android backend, delegating parse/write to
+ * Apache POI via `ExcelBridge`. POI holds the authoritative workbook natively;
+ * this class maps the native `WorkbookDTO` into the domain model on open and
+ * sends only edited values back on save, so nothing but changed input cells is
+ * ever touched.
+ */
+import '../../polyfills';
+import { Buffer } from 'buffer';
+import {
+ CellModel,
+ CellStyle,
+ CellValue,
+ DataValidationRule,
+ SheetModel,
+ WorkbookModel,
+ cellKey,
+} from '../../types';
+import { parseRangeA1 } from '../../utils/address';
+import FormulaEngine from '../FormulaEngine';
+import ExcelBridge from '../../native/ExcelBridge';
+import { CellDTO, EditDTO, SheetDTO, StyleDTO, ValidationDTO, WorkbookDTO } from '../../native/dto';
+import { EditedMap, OpenResult, WorkbookBackend } from './WorkbookBackend';
+
+const mapStyle = (s?: StyleDTO): CellStyle | undefined => {
+ if (!s) return undefined;
+ const out: CellStyle = {};
+ if (s.bold) out.bold = true;
+ if (s.italic) out.italic = true;
+ if (s.underline) out.underline = true;
+ if (s.strike) out.strike = true;
+ if (s.fontSize) out.fontSize = s.fontSize;
+ if (s.fontColor) out.fontColor = s.fontColor;
+ if (s.fontFamily) out.fontFamily = s.fontFamily;
+ if (s.bgColor) out.bgColor = s.bgColor;
+ if (s.hAlign) out.hAlign = s.hAlign as CellStyle['hAlign'];
+ if (s.vAlign) out.vAlign = s.vAlign as CellStyle['vAlign'];
+ if (s.wrap) out.wrap = true;
+ if (s.borderTop || s.borderRight || s.borderBottom || s.borderLeft) {
+ out.border = {
+ top: s.borderTop,
+ right: s.borderRight,
+ bottom: s.borderBottom,
+ left: s.borderLeft,
+ };
+ }
+ return Object.keys(out).length ? out : undefined;
+};
+
+const mapValidation = (dv?: ValidationDTO): DataValidationRule | undefined => {
+ if (!dv) return undefined;
+ return {
+ kind: dv.kind,
+ list: dv.list,
+ min: dv.min,
+ max: dv.max,
+ operator: dv.operator as DataValidationRule['operator'],
+ required: dv.required,
+ errorMessage: dv.errorMessage,
+ };
+};
+
+const mapCell = (c: CellDTO): CellModel => ({
+ row: c.r,
+ col: c.c,
+ raw: c.v as CellValue,
+ formula: c.f,
+ type: c.t,
+ numFmt: c.numFmt,
+ style: mapStyle(c.style),
+ comment: c.comment,
+ locked: c.locked,
+ validation: mapValidation(c.dv),
+ dropdownOptions: c.dv?.kind === 'list' ? c.dv.list : undefined,
+});
+
+const mapSheet = (s: SheetDTO): SheetModel => {
+ const cells = new Map();
+ for (const c of s.cells) cells.set(cellKey(c.r, c.c), mapCell(c));
+
+ const columnWidths = new Map();
+ for (const [k, v] of Object.entries(s.columnWidths || {})) columnWidths.set(Number(k), Number(v));
+ const rowHeights = new Map();
+ for (const [k, v] of Object.entries(s.rowHeights || {})) rowHeights.set(Number(k), Number(v));
+
+ const merges = (s.merges || [])
+ .map(parseRangeA1)
+ .filter((r): r is NonNullable => !!r);
+
+ return {
+ id: `sheet-${s.index}`,
+ name: s.name,
+ index: s.index,
+ hfSheetId: s.index,
+ rowCount: Math.max(s.rowCount, 1),
+ colCount: Math.max(s.colCount, 1),
+ cells,
+ columnWidths,
+ rowHeights,
+ hiddenRows: new Set(s.hiddenRows || []),
+ hiddenColumns: new Set(s.hiddenColumns || []),
+ merges,
+ frozenRows: s.frozenRows || 0,
+ frozenCols: s.frozenCols || 0,
+ state: s.state || 'visible',
+ protected: !!s.protected,
+ defaultColWidth: 96,
+ defaultRowHeight: 40,
+ };
+};
+
+const toModel = (dto: WorkbookDTO, fileName: string): WorkbookModel => ({
+ sheets: dto.sheets.map(mapSheet),
+ activeSheetIndex: dto.activeSheet ?? 0,
+ fileName: dto.fileName || fileName,
+});
+
+export class NativePoiBackend implements WorkbookBackend {
+ readonly name = 'native-poi' as const;
+
+ isAvailable(): boolean {
+ return ExcelBridge.isAvailable();
+ }
+
+ async open(bytes: Uint8Array, fileName: string): Promise {
+ const base64 = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64');
+ const dto = await ExcelBridge.open(base64);
+ return { model: toModel(dto, fileName), warnings: dto.warnings || [] };
+ }
+
+ async save(model: WorkbookModel, _engine: FormulaEngine, edited: EditedMap): Promise {
+ const edits: EditDTO[] = [];
+ for (const [sheetIndex, keys] of edited.entries()) {
+ const sheet = model.sheets[sheetIndex];
+ if (!sheet) continue;
+ for (const key of keys) {
+ const [r, c] = key.split(':').map(Number);
+ const cell = sheet.cells.get(key);
+ if (!cell) {
+ edits.push({ sheet: sheetIndex, r, c, clear: true });
+ } else if (cell.formula) {
+ edits.push({ sheet: sheetIndex, r, c, f: cell.formula });
+ } else if (cell.raw === null || cell.raw === undefined || cell.raw === '') {
+ edits.push({ sheet: sheetIndex, r, c, clear: true });
+ } else {
+ edits.push({ sheet: sheetIndex, r, c, v: cell.raw as any });
+ }
+ }
+ }
+ const base64 = await ExcelBridge.save({ edits });
+ return new Uint8Array(Buffer.from(base64, 'base64'));
+ }
+
+ async close(): Promise {
+ await ExcelBridge.close();
+ }
+}
+
+export default NativePoiBackend;
+
diff --git a/src/Spreadsheet/engine/backends/WorkbookBackend.ts b/src/Spreadsheet/engine/backends/WorkbookBackend.ts
new file mode 100644
index 0000000..83d8dce
--- /dev/null
+++ b/src/Spreadsheet/engine/backends/WorkbookBackend.ts
@@ -0,0 +1,41 @@
+/**
+ * WorkbookBackend — Strategy interface abstracting workbook parse/write.
+ *
+ * Two implementations:
+ * - NativePoiBackend (Android, Apache POI via ExcelBridge) — preferred
+ * - JsExcelBackend (ExcelJS, pure JS) — fallback / iOS
+ *
+ * Both preserve the full workbook and expose identical lock/formula/protection
+ * metadata, so the editability contract is the same regardless of backend.
+ */
+import { WorkbookModel } from '../../types';
+import FormulaEngine from '../FormulaEngine';
+
+export interface OpenResult {
+ model: WorkbookModel;
+ warnings: string[];
+}
+
+export type EditedMap = Map>; // sheetIndex -> "row:col"
+
+export interface WorkbookBackend {
+ readonly name: 'native-poi' | 'js-exceljs';
+
+ /** Whether this backend can run in the current environment. */
+ isAvailable(): boolean;
+
+ /** Parse xlsx bytes into the domain model (retaining source for writeback). */
+ open(bytes: Uint8Array, fileName: string): Promise;
+
+ /**
+ * Write the edited cell values back and return xlsx bytes. Only the cells in
+ * `edited` (plus recalculated dependents) are modified; everything else is
+ * preserved byte-for-byte.
+ */
+ save(model: WorkbookModel, engine: FormulaEngine, edited: EditedMap): Promise;
+
+ /** Release any retained resources. */
+ close(): Promise;
+}
+
+
diff --git a/src/Spreadsheet/hooks/useGridTheme.ts b/src/Spreadsheet/hooks/useGridTheme.ts
new file mode 100644
index 0000000..22bc238
--- /dev/null
+++ b/src/Spreadsheet/hooks/useGridTheme.ts
@@ -0,0 +1,63 @@
+/**
+ * Derives a spreadsheet-specific palette from the app theme so the grid matches
+ * the product and supports light + dark mode out of the box.
+ */
+import { useMemo } from 'react';
+import { useTheme } from '../../Hooks/useTheme';
+
+export interface GridTheme {
+ isDark: boolean;
+ background: string;
+ gridLine: string;
+ headerBg: string;
+ headerText: string;
+ headerActiveBg: string;
+ cellText: string;
+ mutedText: string;
+ selectionFill: string;
+ selectionBorder: string;
+ laneHighlight: string;
+ readOnlyBg: string;
+ errorBorder: string;
+ errorFill: string;
+ editorBg: string;
+ editorBorder: string;
+ searchHighlight: string;
+ lockIcon: string;
+ primary: string;
+ surface: string;
+}
+
+export const useGridTheme = (): GridTheme => {
+ const { colors, isDarkMode } = useTheme();
+
+ return useMemo(
+ () => ({
+ isDark: isDarkMode,
+ background: isDarkMode ? '#1b1c1d' : '#ffffff',
+ gridLine: isDarkMode ? 'rgba(255,255,255,0.12)' : 'rgba(15,23,42,0.12)',
+ headerBg: isDarkMode ? '#2b2c2e' : '#f1f5f9',
+ headerText: isDarkMode ? '#cbd5e1' : '#475569',
+ headerActiveBg: colors.primary,
+ cellText: colors.text,
+ mutedText: colors.textSecondary,
+ selectionFill: isDarkMode ? 'rgba(227,149,70,0.20)' : 'rgba(227,149,70,0.16)',
+ selectionBorder: colors.primary,
+ laneHighlight: isDarkMode ? 'rgba(227,149,70,0.10)' : 'rgba(227,149,70,0.08)',
+ readOnlyBg: isDarkMode ? 'rgba(255,255,255,0.05)' : 'rgba(15,23,42,0.045)',
+ errorBorder: colors.error,
+ errorFill: isDarkMode ? 'rgba(248,113,113,0.16)' : 'rgba(239,68,68,0.10)',
+ editorBg: colors.surface,
+ editorBorder: colors.primary,
+ searchHighlight: isDarkMode ? 'rgba(253,186,116,0.30)' : 'rgba(253,186,116,0.45)',
+ lockIcon: '#000000',
+ primary: colors.primary,
+ surface: colors.surface,
+ }),
+ [colors, isDarkMode],
+ );
+};
+
+export default useGridTheme;
+
+
diff --git a/src/Spreadsheet/hooks/useSpreadsheet.ts b/src/Spreadsheet/hooks/useSpreadsheet.ts
new file mode 100644
index 0000000..d794b47
--- /dev/null
+++ b/src/Spreadsheet/hooks/useSpreadsheet.ts
@@ -0,0 +1,602 @@
+/**
+ * useSpreadsheet — the single stateful bridge between the pure engine layer and
+ * the React UI. Owns the workbook model, formula engine, selection, editing,
+ * validation, undo/redo, clipboard and search, and exposes a flat imperative
+ * API. Heavy objects live in refs; a `version` counter repaints the
+ * (virtualized, therefore small) set of visible cells.
+ *
+ * Editability is strictly workbook-defined via `isCellEditable` — formula and
+ * locked/protected cells can never be edited, pasted into, or cleared.
+ */
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+import {
+ Address,
+ CellModel,
+ CellRange,
+ EditTransaction,
+ SearchMatch,
+ SheetModel,
+ ValidationError,
+ WorkbookModel,
+ cellKey,
+ isCellEditable,
+} from '../types';
+import WorkbookManager from '../engine/WorkbookManager';
+import FormulaEngine from '../engine/FormulaEngine';
+import ValidationEngine from '../engine/ValidationEngine';
+import UndoRedoManager from '../engine/UndoRedoManager';
+import ClipboardManager, { PasteEdit } from '../engine/ClipboardManager';
+import SelectionManager, { SelectionState } from '../engine/SelectionManager';
+import PerformanceManager from '../engine/PerformanceManager';
+import { formatCellValue, parseInput, toEditableText } from '../utils/cellFormat';
+
+export type SpreadsheetStatus = 'idle' | 'loading' | 'ready' | 'error';
+
+export interface EditingState {
+ row: number;
+ col: number;
+ text: string;
+}
+
+export interface SearchState {
+ query: string;
+ matches: SearchMatch[];
+ index: number;
+}
+
+interface RawEdit {
+ address: Address;
+ content: string;
+}
+
+export interface UseSpreadsheet {
+ status: SpreadsheetStatus;
+ error?: string;
+ warnings: string[];
+ notice?: string;
+ backendName: string;
+
+ model?: WorkbookModel;
+ activeSheet?: SheetModel;
+ activeSheetIndex: number;
+ version: number;
+
+ selection: SelectionState;
+ editing: EditingState | null;
+ isDirty: boolean;
+ canUndo: boolean;
+ canRedo: boolean;
+ hasClipboard: boolean;
+ validationErrors: Map;
+ errorCount: number;
+ search: SearchState;
+
+ // data access
+ getCellModel: (row: number, col: number) => CellModel | undefined;
+ getDisplayValue: (row: number, col: number) => string;
+ getUserContent: (row: number, col: number) => string;
+ getCellError: (row: number, col: number) => ValidationError | undefined;
+ canEdit: (addr: Address) => boolean;
+
+ // lifecycle
+ loadFromBytes: (bytes: Uint8Array | ArrayBuffer, fileName?: string) => Promise;
+ exportBytes: () => Promise;
+ setActiveSheet: (index: number) => void;
+ validateAll: () => ValidationError[];
+ dismissNotice: () => void;
+ dispose: () => void;
+
+ // selection & navigation
+ selectCell: (addr: Address, extend?: boolean) => void;
+ selectRange: (anchor: Address, focus: Address) => void;
+ moveSelection: (dRow: number, dCol: number, extend?: boolean) => void;
+ selectRowHeader: (row: number) => void;
+ selectColumnHeader: (col: number) => void;
+ selectAll: () => void;
+
+ // editing
+ beginEdit: (addr?: Address, initialText?: string) => void;
+ setEditingText: (text: string) => void;
+ commitEdit: (text?: string, move?: 'down' | 'right' | 'none') => void;
+ cancelEdit: () => void;
+ setCellContent: (addr: Address, content: string) => void;
+ deleteSelection: () => void;
+
+ // clipboard
+ copy: () => void;
+ paste: () => void;
+
+ // search
+ runSearch: (query: string) => void;
+ findNext: () => void;
+ findPrev: () => void;
+ clearSearch: () => void;
+
+ // history
+ undo: () => void;
+ redo: () => void;
+}
+
+export const useSpreadsheet = (): UseSpreadsheet => {
+ const managerRef = useRef(null);
+ const engineRef = useRef(null);
+ const undoRef = useRef(new UndoRedoManager());
+ const clipboardRef = useRef | null>(null);
+ const editedRef = useRef