From 1d7646d938d46ceb6b69f905c9cff63dcf50f4fb Mon Sep 17 00:00:00 2001 From: Abin Toppo Date: Thu, 16 Jul 2026 10:20:22 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20architecture=20refactor=20=E2=80=94=20c?= =?UTF-8?q?ore=20layer,=20spreadsheet=20engine,=20envelope=20bidding,=20te?= =?UTF-8?q?sts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add src/core/ (config, errors, events, http, observability, storage, query) - Add src/Spreadsheet/ engine with native POI backend and Excel bridge - Add Envelope C spreadsheet flow and tender bidding envelope modules - Consolidate components into src/components/, remove legacy Component/ and Hooks/ - Add registration state/validation, auth session guard, profile edit modules - Add unit tests across axios, redux, bidding, registration, encryption, core - Add CI workflow, docs (architecture, audit, DSC API spec), keystore example - Remove tracked keystore binary; ignore scripts.zip Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 31 + .gitignore | 11 + .scannerwork/.sonar_lock | 0 App.tsx | 54 +- README.md | 182 ++- android/app/appKeyStore | Bin 2708 -> 0 bytes android/app/build.gradle | 48 +- android/app/src/main/AndroidManifest.xml | 2 - .../src/main/java/com/procure/MainActivity.kt | 19 +- .../main/java/com/procure/MainApplication.kt | 4 +- .../java/com/procure/excel/ExcelModule.kt | 407 ++++++ .../java/com/procure/excel/ExcelPackage.kt | 22 + .../main/res/xml/network_security_config.xml | 23 +- android/build.gradle | 2 +- android/keystore.properties.example | 11 + babel.config.js | 13 +- docs/ARCHITECTURE.md | 153 ++ docs/ARCHITECTURE_AUDIT.md | 363 +++++ docs/MOBILE_BID_DSC_API_SPEC.md | 15 + index.js | 6 +- jest.config.js | 5 + metro.config.js | 8 - package-lock.json | 329 +++-- package.json | 8 +- sonar-project.properties | 9 +- src/Axios/Axios.ts | 155 +- src/Axios/__tests__/apiCall.test.ts | 58 + src/Component/Card/Card.tsx | 163 --- src/Component/Counter/Counter.tsx | 45 - src/Component/Header/Header.tsx | 90 -- src/Component/Index.tsx | 6 - .../InternetConnectivity.tsx | 186 --- .../InternetConnectivity/ServerDown.tsx | 379 ----- src/Component/Loader/Loader.tsx | 18 - src/Component/MyInput.tsx | 106 -- src/Component/PrimaryButton/PrimaryButton.tsx | 67 - src/Component/SideDrawer/SideDrawer.tsx | 256 ---- src/Constant/AllImports.tsx | 10 - src/Constant/AllUrls.tsx | 69 - src/Constant/Color.tsx | 24 +- src/Constant/NavigationString.tsx | 8 +- src/Constant/ShowDailog.tsx | 2 +- src/Constant/Variable.tsx | 16 +- src/Hooks/deviceDetail.tsx | 15 - src/Hooks/useCreate.ts | 20 - src/Hooks/useDelete.ts | 17 - src/Hooks/useFetch.ts | 17 - src/Hooks/useUpdate.ts | 17 - src/Navigation/AuthSessionGuard.tsx | 32 + src/Navigation/Bottom.tsx | 18 +- src/Navigation/CustomDrawer.tsx | 100 -- src/Navigation/DrawerStack.tsx | 40 - .../{MainStack.jsx => MainStack.tsx} | 5 +- src/Navigation/Routes.jsx | 5 - src/Navigation/Routes.tsx | 8 + src/Navigation/types.ts | 37 + src/Permission/index.tsx | 81 +- src/Redux/Slices/SelectedAuction.tsx | 2 +- src/Redux/Slices/SelectedPurchaseTender.ts | 2 +- src/Redux/Slices/Token.tsx | 70 +- src/Redux/Slices/__tests__/Token.test.ts | 34 + src/Redux/Store.js | 33 - src/Redux/Store.ts | 78 + src/Redux/hooks.ts | 6 + src/Screens/Auction/Auction.tsx | 6 +- src/Screens/Auction/LiveAuction.tsx | 15 +- src/Screens/Auction/LiveAuctionTile.tsx | 1 - src/Screens/Auction/Modal/AuctionDetail.tsx | 6 +- .../Auction/Modal/AuctionItemDetail.tsx | 8 +- src/Screens/Auction/PurchaseAuction.tsx | 17 +- src/Screens/Auction/PurchaseAuctionList.tsx | 8 +- src/Screens/Auction/PurchaseAuctionTile.tsx | 7 +- src/Screens/Auction/PurchaseList.tsx | 6 +- ...rtBidding.tsx => AuctionBiddingScreen.tsx} | 131 +- ...BiddingCard.tsx => AuctionBiddingCard.tsx} | 196 ++- .../Bidding/Component/AuctionTimer.tsx | 65 - .../Bidding/Component/ConfirmBidModal.tsx | 2 +- src/Screens/Bidding/Component/EmdComplete.tsx | 2 +- src/Screens/Bidding/Component/GoBackModal.tsx | 2 +- .../Bidding/Component/ReverseBiddingCard.tsx | 358 ----- .../Bidding/Component/TemplateFormModal.tsx | 13 +- src/Screens/Bidding/Emd.tsx | 10 +- src/Screens/Bidding/StartReverseBidding.tsx | 349 ----- .../Bidding/__tests__/biddingPattern.test.ts | 86 ++ src/Screens/Bidding/biddingPattern.ts | 78 + src/Screens/Home/Home.tsx | 13 +- src/Screens/Home/KycWebViewScreen.tsx | 9 +- src/Screens/Login/ForgotPassword.tsx | 1 - src/Screens/Login/Login.tsx | 154 +- src/Screens/Login/ResetPassword.tsx | 99 +- src/Screens/Profile/EditProfileModal.tsx | 324 ++++ src/Screens/Profile/ProfileTab.tsx | 420 +----- src/Screens/Profile/profileTypes.ts | 64 + src/Screens/Ragistration/PartnerShip.tsx | 8 +- src/Screens/Ragistration/Ragistration.tsx | 575 ++------ src/Screens/Ragistration/StageThree.tsx | 17 +- .../__tests__/registrationState.test.ts | 58 + .../__tests__/registrationValidation.test.ts | 80 + .../ragistrationStages/StageFour.tsx | 3 +- .../ragistrationStages/StageOne.tsx | 5 +- .../ragistrationStages/StageThree.tsx | 3 +- .../ragistrationStages/StageTwo.tsx | 8 +- .../ragistrationStages/StageZero.tsx | 1 - src/Screens/Ragistration/registrationState.ts | 196 +++ .../Ragistration/registrationValidation.ts | 108 ++ src/Screens/SignUp/SignUp.tsx | 12 +- src/Screens/Splash/Splash.tsx | 73 +- .../Component/EnvelopeCExcelPreview.tsx | 578 +------- src/Screens/Tender/DeviceBinding.tsx | 5 +- src/Screens/Tender/DscBinding.tsx | 4 +- .../EnvelopeC/EnvelopeCSpreadsheetScreen.tsx | 109 ++ .../Tender/EnvelopeC/useEnvelopeCWorkbook.ts | 192 +++ src/Screens/Tender/LiveTender.tsx | 19 +- src/Screens/Tender/LiveTenderDetail.tsx | 4 +- src/Screens/Tender/LiveTenderTile.tsx | 70 +- src/Screens/Tender/Modal/TenderFullDetail.tsx | 3 +- src/Screens/Tender/ProceedToBidDetail.tsx | 14 +- src/Screens/Tender/ProceedToPurTender.tsx | 6 +- src/Screens/Tender/PurchaseTenderAllList.tsx | 14 +- src/Screens/Tender/PurchaseTenderList.tsx | 9 +- src/Screens/Tender/PurchaseTenderTile.tsx | 6 +- src/Screens/Tender/Tender.tsx | 2 +- .../Tender/tenderBidding/TenderBidding.tsx | 1299 +++-------------- .../__tests__/envelopePayload.test.ts | 120 ++ .../components/EnvelopeButton.tsx | 47 + .../components/EnvelopeFieldItem.tsx | 144 ++ .../components/QcbsQuestionCard.tsx | 116 ++ .../tenderBidding/components/TermsRow.tsx | 50 + .../Tender/tenderBidding/envelopeCrypto.ts | 75 + .../Tender/tenderBidding/envelopePayload.ts | 233 +++ .../tenderBidding/envelopeSubmission.ts | 75 + .../Tender/tenderBidding/envelopeTypes.ts | 32 + src/Screens/Tender/types/types.tsx | 1 + src/Screens/TestScreen/DecryptionScreen.tsx | 449 ------ src/Screens/index.ts | 17 +- src/Services/ApiCall/ApiUrls.tsx | 10 +- src/Services/ApiCall/Hooks.ts | 70 +- src/Services/ApiCall/apis.ts | 48 +- .../__tests__/registrationRouting.test.ts | 87 ++ src/Services/Auth/registrationRouting.ts | 82 ++ src/Services/Auth/session.ts | 18 + .../Encryption/__tests__/encrypt.test.ts | 29 + src/Services/Encryption/encrypt.tsx | 69 +- src/Spreadsheet/README.md | 163 +++ src/Spreadsheet/__fixtures__/buildModel.ts | 76 + .../__tests__/FormulaEngine.test.ts | 87 ++ .../__tests__/ValidationEngine.test.ts | 89 ++ src/Spreadsheet/__tests__/managers.test.ts | 59 + src/Spreadsheet/__tests__/utils.test.ts | 81 + src/Spreadsheet/components/CellView.tsx | 211 +++ .../components/ColumnHeaderRow.tsx | 65 + src/Spreadsheet/components/DataRow.tsx | 96 ++ src/Spreadsheet/components/DropdownModal.tsx | 46 + src/Spreadsheet/components/FormulaBar.tsx | 63 + src/Spreadsheet/components/RowNumberCell.tsx | 51 + src/Spreadsheet/components/SearchBar.tsx | 64 + src/Spreadsheet/components/SheetTabs.tsx | 53 + .../components/SpreadsheetGrid.tsx | 307 ++++ .../components/SpreadsheetView.tsx | 90 ++ src/Spreadsheet/components/Toolbar.tsx | 69 + src/Spreadsheet/engine/ClipboardManager.ts | 77 + src/Spreadsheet/engine/FileManager.ts | 108 ++ src/Spreadsheet/engine/FormulaEngine.ts | 212 +++ src/Spreadsheet/engine/PerformanceManager.ts | 38 + src/Spreadsheet/engine/SelectionManager.ts | 91 ++ src/Spreadsheet/engine/UndoRedoManager.ts | 59 + src/Spreadsheet/engine/ValidationEngine.ts | 233 +++ src/Spreadsheet/engine/WorkbookManager.ts | 41 + src/Spreadsheet/engine/WorkbookParser.ts | 320 ++++ src/Spreadsheet/engine/WorkbookWriter.ts | 91 ++ .../engine/backends/JsExcelBackend.ts | 40 + .../engine/backends/NativePoiBackend.ts | 160 ++ .../engine/backends/WorkbookBackend.ts | 41 + src/Spreadsheet/hooks/useGridTheme.ts | 63 + src/Spreadsheet/hooks/useSpreadsheet.ts | 602 ++++++++ src/Spreadsheet/native/ExcelBridge.ts | 49 + src/Spreadsheet/native/NativeExcelModule.ts | 34 + src/Spreadsheet/native/dto.ts | 95 ++ src/Spreadsheet/polyfills.ts | 28 + src/Spreadsheet/types.ts | 199 +++ src/Spreadsheet/utils/address.ts | 75 + src/Spreadsheet/utils/cellFormat.ts | 183 +++ src/Spreadsheet/utils/color.ts | 39 + src/Spreadsheet/utils/geometry.ts | 98 ++ src/Styles/Styles.tsx | 218 --- src/Utils/Types.tsx | 8 +- src/api/client.ts | 36 - src/components/AppStatusBar.tsx | 2 +- .../Counter => components}/CountdownTimer.tsx | 4 +- src/components/Header.tsx | 135 -- .../Header => components}/InsideHeader.tsx | 16 +- src/components/Loader.tsx | 22 +- .../Dropdown => components}/MyDropdown.tsx | 2 +- src/components/MyInput.tsx | 4 +- .../SearchInput.tsx} | 6 +- src/components/SidebarModal.tsx | 9 +- .../Skelton.tsx => components/Skeleton.tsx} | 13 +- .../SkeletonList.tsx} | 4 +- .../StepProgressBar.tsx | 10 +- .../TermsAndConditionsModal.tsx | 8 +- src/components/index.ts | 5 + src/core/config/env.ts | 41 + src/core/errors/ErrorBoundary.tsx | 85 ++ src/core/events/__tests__/appEvents.test.ts | 36 + src/core/events/appEvents.ts | 33 + src/core/http/AppError.ts | 61 + src/core/http/__tests__/AppError.test.ts | 44 + src/core/http/client.ts | 73 + .../observability/__tests__/redact.test.ts | 79 + src/core/observability/httpLogging.ts | 89 ++ src/core/observability/logger.ts | 66 + src/core/observability/redact.ts | 71 + src/core/platform/polyfills.ts | 15 + src/core/query/queryClient.ts | 23 + .../storage/__tests__/secureStorage.test.ts | 120 ++ src/core/storage/secureStorage.ts | 88 ++ src/socket/Socket.tsx | 128 +- 217 files changed, 11068 insertions(+), 6855 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .scannerwork/.sonar_lock delete mode 100644 android/app/appKeyStore create mode 100644 android/app/src/main/java/com/procure/excel/ExcelModule.kt create mode 100644 android/app/src/main/java/com/procure/excel/ExcelPackage.kt create mode 100644 android/keystore.properties.example create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/ARCHITECTURE_AUDIT.md create mode 100644 docs/MOBILE_BID_DSC_API_SPEC.md create mode 100644 src/Axios/__tests__/apiCall.test.ts delete mode 100644 src/Component/Card/Card.tsx delete mode 100644 src/Component/Counter/Counter.tsx delete mode 100644 src/Component/Header/Header.tsx delete mode 100644 src/Component/Index.tsx delete mode 100644 src/Component/InternetConnectivity/InternetConnectivity.tsx delete mode 100644 src/Component/InternetConnectivity/ServerDown.tsx delete mode 100644 src/Component/Loader/Loader.tsx delete mode 100644 src/Component/MyInput.tsx delete mode 100644 src/Component/PrimaryButton/PrimaryButton.tsx delete mode 100644 src/Component/SideDrawer/SideDrawer.tsx delete mode 100644 src/Constant/AllImports.tsx delete mode 100644 src/Constant/AllUrls.tsx delete mode 100644 src/Hooks/deviceDetail.tsx delete mode 100644 src/Hooks/useCreate.ts delete mode 100644 src/Hooks/useDelete.ts delete mode 100644 src/Hooks/useFetch.ts delete mode 100644 src/Hooks/useUpdate.ts create mode 100644 src/Navigation/AuthSessionGuard.tsx delete mode 100644 src/Navigation/CustomDrawer.tsx delete mode 100644 src/Navigation/DrawerStack.tsx rename src/Navigation/{MainStack.jsx => MainStack.tsx} (93%) delete mode 100644 src/Navigation/Routes.jsx create mode 100644 src/Navigation/Routes.tsx create mode 100644 src/Navigation/types.ts create mode 100644 src/Redux/Slices/__tests__/Token.test.ts delete mode 100644 src/Redux/Store.js create mode 100644 src/Redux/Store.ts create mode 100644 src/Redux/hooks.ts rename src/Screens/Bidding/{StartBidding.tsx => AuctionBiddingScreen.tsx} (74%) rename src/Screens/Bidding/Component/{BiddingCard.tsx => AuctionBiddingCard.tsx} (62%) delete mode 100644 src/Screens/Bidding/Component/AuctionTimer.tsx delete mode 100644 src/Screens/Bidding/Component/ReverseBiddingCard.tsx delete mode 100644 src/Screens/Bidding/StartReverseBidding.tsx create mode 100644 src/Screens/Bidding/__tests__/biddingPattern.test.ts create mode 100644 src/Screens/Bidding/biddingPattern.ts create mode 100644 src/Screens/Profile/EditProfileModal.tsx create mode 100644 src/Screens/Profile/profileTypes.ts create mode 100644 src/Screens/Ragistration/__tests__/registrationState.test.ts create mode 100644 src/Screens/Ragistration/__tests__/registrationValidation.test.ts create mode 100644 src/Screens/Ragistration/registrationState.ts create mode 100644 src/Screens/Ragistration/registrationValidation.ts create mode 100644 src/Screens/Tender/EnvelopeC/EnvelopeCSpreadsheetScreen.tsx create mode 100644 src/Screens/Tender/EnvelopeC/useEnvelopeCWorkbook.ts create mode 100644 src/Screens/Tender/tenderBidding/__tests__/envelopePayload.test.ts create mode 100644 src/Screens/Tender/tenderBidding/components/EnvelopeButton.tsx create mode 100644 src/Screens/Tender/tenderBidding/components/EnvelopeFieldItem.tsx create mode 100644 src/Screens/Tender/tenderBidding/components/QcbsQuestionCard.tsx create mode 100644 src/Screens/Tender/tenderBidding/components/TermsRow.tsx create mode 100644 src/Screens/Tender/tenderBidding/envelopeCrypto.ts create mode 100644 src/Screens/Tender/tenderBidding/envelopePayload.ts create mode 100644 src/Screens/Tender/tenderBidding/envelopeSubmission.ts create mode 100644 src/Screens/Tender/tenderBidding/envelopeTypes.ts delete mode 100644 src/Screens/TestScreen/DecryptionScreen.tsx create mode 100644 src/Services/Auth/__tests__/registrationRouting.test.ts create mode 100644 src/Services/Auth/registrationRouting.ts create mode 100644 src/Services/Auth/session.ts create mode 100644 src/Services/Encryption/__tests__/encrypt.test.ts create mode 100644 src/Spreadsheet/README.md create mode 100644 src/Spreadsheet/__fixtures__/buildModel.ts create mode 100644 src/Spreadsheet/__tests__/FormulaEngine.test.ts create mode 100644 src/Spreadsheet/__tests__/ValidationEngine.test.ts create mode 100644 src/Spreadsheet/__tests__/managers.test.ts create mode 100644 src/Spreadsheet/__tests__/utils.test.ts create mode 100644 src/Spreadsheet/components/CellView.tsx create mode 100644 src/Spreadsheet/components/ColumnHeaderRow.tsx create mode 100644 src/Spreadsheet/components/DataRow.tsx create mode 100644 src/Spreadsheet/components/DropdownModal.tsx create mode 100644 src/Spreadsheet/components/FormulaBar.tsx create mode 100644 src/Spreadsheet/components/RowNumberCell.tsx create mode 100644 src/Spreadsheet/components/SearchBar.tsx create mode 100644 src/Spreadsheet/components/SheetTabs.tsx create mode 100644 src/Spreadsheet/components/SpreadsheetGrid.tsx create mode 100644 src/Spreadsheet/components/SpreadsheetView.tsx create mode 100644 src/Spreadsheet/components/Toolbar.tsx create mode 100644 src/Spreadsheet/engine/ClipboardManager.ts create mode 100644 src/Spreadsheet/engine/FileManager.ts create mode 100644 src/Spreadsheet/engine/FormulaEngine.ts create mode 100644 src/Spreadsheet/engine/PerformanceManager.ts create mode 100644 src/Spreadsheet/engine/SelectionManager.ts create mode 100644 src/Spreadsheet/engine/UndoRedoManager.ts create mode 100644 src/Spreadsheet/engine/ValidationEngine.ts create mode 100644 src/Spreadsheet/engine/WorkbookManager.ts create mode 100644 src/Spreadsheet/engine/WorkbookParser.ts create mode 100644 src/Spreadsheet/engine/WorkbookWriter.ts create mode 100644 src/Spreadsheet/engine/backends/JsExcelBackend.ts create mode 100644 src/Spreadsheet/engine/backends/NativePoiBackend.ts create mode 100644 src/Spreadsheet/engine/backends/WorkbookBackend.ts create mode 100644 src/Spreadsheet/hooks/useGridTheme.ts create mode 100644 src/Spreadsheet/hooks/useSpreadsheet.ts create mode 100644 src/Spreadsheet/native/ExcelBridge.ts create mode 100644 src/Spreadsheet/native/NativeExcelModule.ts create mode 100644 src/Spreadsheet/native/dto.ts create mode 100644 src/Spreadsheet/polyfills.ts create mode 100644 src/Spreadsheet/types.ts create mode 100644 src/Spreadsheet/utils/address.ts create mode 100644 src/Spreadsheet/utils/cellFormat.ts create mode 100644 src/Spreadsheet/utils/color.ts create mode 100644 src/Spreadsheet/utils/geometry.ts delete mode 100644 src/Styles/Styles.tsx delete mode 100644 src/api/client.ts rename src/{Component/Counter => components}/CountdownTimer.tsx (95%) delete mode 100644 src/components/Header.tsx rename src/{Component/Header => components}/InsideHeader.tsx (83%) rename src/{Component/Dropdown => components}/MyDropdown.tsx (98%) rename src/{Component/Serach/SeacrhInput.tsx => components/SearchInput.tsx} (94%) rename src/{Component/Skelton/Skelton.tsx => components/Skeleton.tsx} (90%) rename src/{Component/Skelton/SkeltonList.tsx => components/SkeletonList.tsx} (90%) rename src/{Component => components}/StepProgressBar.tsx (95%) rename src/{Component/TermsAndConditionsModal => components}/TermsAndConditionsModal.tsx (97%) create mode 100644 src/components/index.ts create mode 100644 src/core/config/env.ts create mode 100644 src/core/errors/ErrorBoundary.tsx create mode 100644 src/core/events/__tests__/appEvents.test.ts create mode 100644 src/core/events/appEvents.ts create mode 100644 src/core/http/AppError.ts create mode 100644 src/core/http/__tests__/AppError.test.ts create mode 100644 src/core/http/client.ts create mode 100644 src/core/observability/__tests__/redact.test.ts create mode 100644 src/core/observability/httpLogging.ts create mode 100644 src/core/observability/logger.ts create mode 100644 src/core/observability/redact.ts create mode 100644 src/core/platform/polyfills.ts create mode 100644 src/core/query/queryClient.ts create mode 100644 src/core/storage/__tests__/secureStorage.test.ts create mode 100644 src/core/storage/secureStorage.ts 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 76c6a52f7e693b811ec4661234a58a1cddcdbe72..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2708 zcma);X*kpk_s3_(jC~u8CE}j3ul2)3hGFC`Oo$tyY>^3(FcMnalPzmxYwVQlrNtJ) z*mp%K`)(Ly$yf%D|Mh>a?(2E=JTK05uJiq#b6$SV0Z8mmAz&te#ExNRlMgcpqjP~- zz}X~r1t^JK=2(^hNU);+uwZFW5-jCdes&yj%pCuo;$#OyvPm%9v8V+Qeow%-02|=k zKP4xi07VGkKdub;YXFDStNvQ@hs8ZY@39bw3@-$X1UQ*l|9>Nd4F&>4nb{n}48Sf- zP_R6d_lu;D75+>75muzsKelbN8cKrpTP%Y9^eQAM!7GwRCa-IIS@oW!fwlt~Wj~T+ z3T%Tthu$hHv^Old?BxIL zzOB!^^FrIh6StZ}QdG(_;xy~~icJeJMgx%%I#Jfn=gi!om7gkkb(oV)+DdUht1Rd` zypj^rsadCp4rU1zeB1?(b15A&j$>PW>Ly!QY-tci7iEu~ZdQh+;)Jdu;_0HBot*Bv zzQd~5=fH0zBFlfyqG~$s&-AJ)@}0Jh9V)R#xWFL>F!eX9MvZ!5F^G4hh}-=|Hks6? z?n~BtojQX<6pAB$m0WR+UtxWL{U*}jLG|p|$2YbTmFvq#XMd?3^h!WkkU$9s?^p}m z8UvcfB*xX6^EEKc!@~wt= z*{4}5_VmbEMnN49gs-;D4|O3$04=#~T|KD2EgIPwkDk`LTEPpDmE?>UK{~}C5KaRL zRg2UYHIpzMj1n17uC=rs*XKZmr~DCV5Z0g)(hCcZU9|Hhjz#$nR4vKzZiU^YacYP` zw_T3fO1X(FaYU(=7q4+LE1Ke;V<&wdm`73CtDd~?bP zU6Z&nTIJ;*IVt1O+MQ?*u0sa&?iA;w=X1Y$VU|uV#H?RDQqzyUJk>I63d$bx^Kkw) zpI}L!ea%;SQ=&+wJCHZ`%N||qoK4xi^`00ptrdwM@XkmmM&k3v6{UGq8}wu6)sIoL zGWga%WbJfd*G$kl1jCDS%Vbtf;saOTa#Gyx&Ai0J#Qj*F&{HNhY5^uqkF4duf6N9g zY;;<7$IkZ$nBOL1G#h)Xo>L#fr4AqOAh0&{NJqy#jMd@0AkPUNt~(O^jDy7yrL#3V zc?o+3Z}z8z`KV5}2}`mLr<_iiErq`IU~a71k^O@El@a<#_yO3&I|PM>%?NIkMYXK& z`eZ2fHMa`ZONkq}>`H$f^br{dtM*TybI$ z&K}V^Z!ak}zRZ0OC2DBm#A9fU$JT?q&*vTCXm~KA1bO!MtphlvnM+*a;4=RWBQj+C1gSO@jlM$V-vceAAhkLB6FuVWJTo@E<;*>TRGY^a@U z*G+C-GL7w-ACY#t^nvszZE*GNjd#uYEElx0Ufh)y6Jbr2!N5l~H@~poi=#Dg);GYp zhf1cUf7mWHvU2Q=XE@EhMh<1o(LF=tE>2q-^IuN!yuk^Tf9Vi!&zfs!Xk^UH8J3j) z_)EIYz5YDM+;J0Rcom@_w(*ZNe(w-Y6&IxguBM41b;P1ZVs zsB3%y)w~w`YANLD&b;*0DgX}(9Tc=q2#P#0hK>5FomRKjc4rk3#W5t$?;DR&l-~NK zeWbeLWt5<0%36TnZ(On=l-b2tLAO9Ipa2j6p#CxVl@U-r3pY;}q_VoYx|*tnx|*_@ znmRy&djD=>Va_H&osK1Y2pDvntp0Jp{|(Xo4lB5{D-6&1CaX%c|9JOb1x5D%3!+~z zb_Y^wi zgxjU@sE$s+yM$bj&wP5Ky$Bkv=nR#mpIGUF7V?0x1EzD5LhkP^?;*d>IR`Q8=q@du z(b-GMiSAZwoYy@AAK_JrT^jmyw2pTz2|t}Io+Du6u^>R%at!{I;Zo=ncctB7&M}6+Zss<`7qK=Ej4$)4q zwZVN*h;a+kbozU7_Q%fVj#>8G7{!aNIo~Xi1ne9^{MVHp8;HWsXNE8C;l8RChLh4J zs)ntGwKJXVrHCWv(ep?1rtQ8v4F253*vERp`&SNUG_T%%^mUiArKfI)LGH*)pr4<` z3=7{nxWJ;2mDAq$faWYFHlKv38R@U|QuI9;&GE^0H1|YPLB$jsf%Ilg{<&yHx2@p097 zbv0yRRIl6_$TZ)q&8xv3&=Uax?55dTi4kLMF_RX&y(~4iq2)yMn3p+YUT}PF0|R=7 z*)Vw^H;yPp#`6}{Zj>)Mb&Rf8JhM2ZvsN{Ro9#h>{dA>EwodVxpjHRMjP%p7-oog1 zRzZDuWX#8(=6%!~9Nh-wZJvQ9ehzQU!ncVu`2HHV=dTWNzeN;9FU+UJ`GC}=>;FzJ zPO&zsD@c#MVb)9@xqX`S!C5xgsCq*!G@-uTxMd0GkJq3CFul%B%aKY-KDmBpn9U^J zeu^*x-sQ>P8wGPV_#l2Q1EPSfwF+UrqyH6&n%9QC=YY1$6ud8q#`9D_T9Z*GU2f8M z1qR@3CP^*z$+F%Sz-o@e>(xL=qPwhdg%zE1X9qSn#Mj01s7ExaY&PY)*rbDnIz4e2 zc}76y`CN+ZqL;^$Z`tRpyR<~B^5U{8_T{Fb5g+)B4NHBzG_p?;vnO12f0V3ka4{yD zl%|!a)Q|+UmThy?#f=Y~a|mgiqM&LGcQI`iI=kimPU5K|{(9rrt}Ca=g(6H1CfBqC zByjf&H$^LHeb-wxgS9R_&}Q&4Jq}2=k!@?BjN^KD3j+Lcwh?eFjmimOi49G)f*Lfk8w&>0Gl>P6u5nZ{#3#UCU z=ghdKJvUah9gG;6YG+Ll(5uV?i~(hU>-VPy0mDF0WU2_US@%4$|3-jdPkXav{N&9a qTd`Tag`NB`!?9eEd?;eV@18@*A#r#w - @@ -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>>(new Map()); + + if (!managerRef.current) managerRef.current = new WorkbookManager(); + + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(); + const [warnings, setWarnings] = useState([]); + const [notice, setNotice] = useState(); + const [model, setModel] = useState(); + const [activeSheetIndex, setActiveSheetIndex] = useState(0); + const [version, setVersion] = useState(0); + const [selection, setSelection] = useState(SelectionManager.create()); + const [editing, setEditing] = useState(null); + const [isDirty, setIsDirty] = useState(false); + const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 }); + const [hasClipboard, setHasClipboard] = useState(false); + const [validationErrors, setValidationErrors] = useState>(new Map()); + const [search, setSearch] = useState({ query: '', matches: [], index: 0 }); + + const activeSheet = model?.sheets[activeSheetIndex]; + const bump = useCallback(() => setVersion(v => v + 1), []); + + /* --------------------------- data access --------------------------- */ + + const getCellModel = useCallback( + (row: number, col: number): CellModel | undefined => activeSheet?.cells.get(cellKey(row, col)), + [activeSheet], + ); + + const getDisplayValue = useCallback( + (row: number, col: number): string => { + const engine = engineRef.current; + if (!engine) return ''; + const value = engine.getValue(activeSheetIndex, row, col); + const cell = activeSheet?.cells.get(cellKey(row, col)); + const type = cell?.type ?? (typeof value === 'number' ? 'number' : 'text'); + return formatCellValue(value, type, cell?.numFmt); + }, + [activeSheet, activeSheetIndex], + ); + + const getUserContent = useCallback( + (row: number, col: number): string => { + const cell = activeSheet?.cells.get(cellKey(row, col)); + if (!cell) return ''; + if (cell.formula) return `=${cell.formula}`; + return toEditableText(null, cell.raw, cell.type); + }, + [activeSheet], + ); + + const getCellError = useCallback( + (row: number, col: number): ValidationError | undefined => + validationErrors.get(`${activeSheetIndex}:${cellKey(row, col)}`), + [validationErrors, activeSheetIndex], + ); + + const canEdit = useCallback( + (addr: Address): boolean => { + if (!activeSheet) return false; + const cell = activeSheet.cells.get(cellKey(addr.row, addr.col)); + return isCellEditable(cell, activeSheet.protected); + }, + [activeSheet], + ); + + /* ---------------------------- validation --------------------------- */ + + const runValidation = useCallback(() => { + const engine = engineRef.current; + if (!engine || !model) return; + setValidationErrors(ValidationEngine.index(ValidationEngine.validateWorkbook(model, engine))); + }, [model]); + + const validateAll = useCallback((): ValidationError[] => { + const engine = engineRef.current; + if (!engine || !model) return []; + const all = ValidationEngine.validateWorkbook(model, engine); + setValidationErrors(ValidationEngine.index(all)); + return all; + }, [model]); + + /* ------------------------------ editing ---------------------------- */ + + const markEdited = useCallback((sheetIndex: number, row: number, col: number) => { + let set = editedRef.current.get(sheetIndex); + if (!set) { + set = new Set(); + editedRef.current.set(sheetIndex, set); + } + set.add(cellKey(row, col)); + }, []); + + const writeCell = useCallback( + (sheetIndex: number, addr: Address, content: string): boolean => { + const engine = engineRef.current; + const sheet = model?.sheets[sheetIndex]; + if (!engine || !sheet) return false; + + const existing = sheet.cells.get(cellKey(addr.row, addr.col)); + const parsed = parseInput(content, existing?.type ?? 'text'); + + const result = engine.setCell(sheetIndex, addr.row, addr.col, parsed.formula, parsed.value); + if (!result.ok) { + if (result.cycle) setNotice('Circular reference detected — the edit was reverted.'); + return false; + } + + const isEmpty = parsed.formula === null && (parsed.value === null || parsed.value === ''); + if (isEmpty && existing && !existing.style && !existing.comment && !existing.validation && existing.locked) { + // Purely-empty, no metadata worth keeping. + sheet.cells.delete(cellKey(addr.row, addr.col)); + } else { + const next: CellModel = existing + ? { ...existing, formula: parsed.formula, raw: parsed.value } + : { + row: addr.row, + col: addr.col, + raw: parsed.value, + formula: parsed.formula, + type: typeof parsed.value === 'number' ? 'number' : 'text', + locked: false, + }; + sheet.cells.set(cellKey(addr.row, addr.col), next); + } + + markEdited(sheetIndex, addr.row, addr.col); + for (const change of result.changes) markEdited(change.sheetIndex, change.row, change.col); + return true; + }, + [model, markEdited], + ); + + const applyEdits = useCallback( + (edits: RawEdit[], label: string, recordUndo = true) => { + if (!edits.length) return; + const tx: EditTransaction = { label, edits: [] }; + for (const edit of edits) { + const before = getUserContent(edit.address.row, edit.address.col); + if (before === edit.content) continue; + if (writeCell(activeSheetIndex, edit.address, edit.content)) { + tx.edits.push({ sheetIndex: activeSheetIndex, address: edit.address, before, after: edit.content }); + } + } + if (tx.edits.length === 0) return; + if (recordUndo) undoRef.current.push(tx); + setIsDirty(true); + setHistoryDepth(undoRef.current.depth); + runValidation(); + bump(); + }, + [activeSheetIndex, getUserContent, writeCell, runValidation, bump], + ); + + const setCellContent = useCallback( + (addr: Address, content: string) => { + if (!canEdit(addr)) { + setNotice('This cell is read-only.'); + return; + } + applyEdits([{ address: addr, content }], 'Edit cell'); + }, + [applyEdits, canEdit], + ); + + const beginEdit = useCallback( + (addr?: Address, initialText?: string) => { + const target = addr ?? selection.active; + if (!canEdit(target)) { + setNotice('This cell is read-only.'); + return; + } + const text = initialText ?? getUserContent(target.row, target.col); + setSelection(SelectionManager.selectCell(target)); + setEditing({ row: target.row, col: target.col, text }); + }, + [selection.active, canEdit, getUserContent], + ); + + const setEditingText = useCallback((text: string) => { + setEditing(prev => (prev ? { ...prev, text } : prev)); + }, []); + + const commitEdit = useCallback( + (text?: string, move: 'down' | 'right' | 'none' = 'down') => { + setEditing(current => { + if (!current) return null; + const finalText = text ?? current.text; + applyEdits([{ address: { row: current.row, col: current.col }, content: finalText }], 'Edit cell'); + if (move !== 'none' && activeSheet) { + const bounds = { + rowCount: activeSheet.rowCount, + colCount: activeSheet.colCount, + hiddenRows: activeSheet.hiddenRows, + hiddenColumns: activeSheet.hiddenColumns, + }; + setSelection(prev => + SelectionManager.move(prev, move === 'down' ? 1 : 0, move === 'right' ? 1 : 0, bounds), + ); + } + return null; + }); + }, + [applyEdits, activeSheet], + ); + + const cancelEdit = useCallback(() => setEditing(null), []); + + const deleteSelection = useCallback(() => { + if (!activeSheet) return; + const edits: RawEdit[] = []; + const { start, end } = selection.range; + for (let r = start.row; r <= end.row; r++) { + for (let c = start.col; c <= end.col; c++) { + if (canEdit({ row: r, col: c })) edits.push({ address: { row: r, col: c }, content: '' }); + } + } + applyEdits(edits, 'Clear cells'); + }, [activeSheet, selection.range, canEdit, applyEdits]); + + /* --------------------------- selection ----------------------------- */ + + const selectCell = useCallback((addr: Address, extend = false) => { + setEditing(null); + setSelection(prev => (extend ? SelectionManager.extendTo(prev, addr) : SelectionManager.selectCell(addr))); + }, []); + + const selectRange = useCallback((anchor: Address, focus: Address) => { + setEditing(null); + setSelection(SelectionManager.selectRange(anchor, focus)); + }, []); + + const moveSelection = useCallback( + (dRow: number, dCol: number, extend = false) => { + if (!activeSheet) return; + const bounds = { + rowCount: activeSheet.rowCount, + colCount: activeSheet.colCount, + hiddenRows: activeSheet.hiddenRows, + hiddenColumns: activeSheet.hiddenColumns, + }; + setSelection(prev => + extend ? SelectionManager.moveAndExtend(prev, dRow, dCol, bounds) : SelectionManager.move(prev, dRow, dCol, bounds), + ); + }, + [activeSheet], + ); + + const selectRowHeader = useCallback( + (row: number) => { + if (!activeSheet) return; + setEditing(null); + setSelection(SelectionManager.selectRow(row, activeSheet.colCount)); + }, + [activeSheet], + ); + + const selectColumnHeader = useCallback( + (col: number) => { + if (!activeSheet) return; + setEditing(null); + setSelection(SelectionManager.selectColumn(col, activeSheet.rowCount)); + }, + [activeSheet], + ); + + const selectAll = useCallback(() => { + if (!activeSheet) return; + setEditing(null); + setSelection(SelectionManager.selectAll({ rowCount: activeSheet.rowCount, colCount: activeSheet.colCount })); + }, [activeSheet]); + + /* ---------------------------- clipboard ---------------------------- */ + + const copy = useCallback(() => { + clipboardRef.current = ClipboardManager.copy(selection.range, addr => getUserContent(addr.row, addr.col)); + setHasClipboard(true); + setNotice('Copied.'); + }, [selection.range, getUserContent]); + + const paste = useCallback(() => { + if (!clipboardRef.current || !activeSheet) return; + const edits: PasteEdit[] = ClipboardManager.paste( + clipboardRef.current, + selection.range, + { rowCount: activeSheet.rowCount, colCount: activeSheet.colCount }, + canEdit, + ); + if (edits.length === 0) { + setNotice('Nothing pasted — target cells are read-only.'); + return; + } + applyEdits(edits, 'Paste'); + }, [activeSheet, selection.range, canEdit, applyEdits]); + + /* ------------------------------ search ----------------------------- */ + + const runSearch = useCallback( + (query: string) => { + const q = query.trim().toLowerCase(); + if (!activeSheet || !q) { + setSearch({ query, matches: [], index: 0 }); + return; + } + const matches: SearchMatch[] = []; + for (const cell of activeSheet.cells.values()) { + const display = getDisplayValue(cell.row, cell.col).toLowerCase(); + const content = getUserContent(cell.row, cell.col).toLowerCase(); + if (display.includes(q) || content.includes(q)) { + matches.push({ + sheetIndex: activeSheetIndex, + address: { row: cell.row, col: cell.col }, + preview: getDisplayValue(cell.row, cell.col), + }); + } + } + matches.sort((a, b) => a.address.row - b.address.row || a.address.col - b.address.col); + setSearch({ query, matches, index: 0 }); + if (matches.length) { + setSelection(SelectionManager.selectCell(matches[0].address)); + } else { + setNotice('No matches found.'); + } + }, + [activeSheet, activeSheetIndex, getDisplayValue, getUserContent], + ); + + const gotoMatch = useCallback( + (nextIndex: number) => { + setSearch(prev => { + if (prev.matches.length === 0) return prev; + const index = (nextIndex + prev.matches.length) % prev.matches.length; + setSelection(SelectionManager.selectCell(prev.matches[index].address)); + return { ...prev, index }; + }); + }, + [], + ); + + const findNext = useCallback(() => gotoMatch(search.index + 1), [gotoMatch, search.index]); + const findPrev = useCallback(() => gotoMatch(search.index - 1), [gotoMatch, search.index]); + const clearSearch = useCallback(() => setSearch({ query: '', matches: [], index: 0 }), []); + + /* ------------------------------ history ---------------------------- */ + + const undo = useCallback(() => { + const tx = undoRef.current.undo(); + if (!tx) return; + for (let i = tx.edits.length - 1; i >= 0; i--) writeCell(tx.edits[i].sheetIndex, tx.edits[i].address, tx.edits[i].before); + setHistoryDepth(undoRef.current.depth); + setIsDirty(true); + runValidation(); + bump(); + }, [writeCell, runValidation, bump]); + + const redo = useCallback(() => { + const tx = undoRef.current.redo(); + if (!tx) return; + for (const e of tx.edits) writeCell(e.sheetIndex, e.address, e.after); + setHistoryDepth(undoRef.current.depth); + setIsDirty(true); + runValidation(); + bump(); + }, [writeCell, runValidation, bump]); + + /* ---------------------------- lifecycle ---------------------------- */ + + const setActiveSheet = useCallback((index: number) => { + setEditing(null); + setActiveSheetIndex(index); + setSelection(SelectionManager.create()); + setSearch({ query: '', matches: [], index: 0 }); + }, []); + + const dispose = useCallback(() => { + engineRef.current?.destroy(); + engineRef.current = null; + managerRef.current?.close().catch(() => {}); + }, []); + + const loadFromBytes = useCallback( + async (bytes: Uint8Array | ArrayBuffer, fileName = 'workbook.xlsx') => { + setStatus('loading'); + setError(undefined); + try { + const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + if (PerformanceManager.shouldDeferParse(u8.byteLength)) { + await PerformanceManager.afterInteractions(); + } + + const manager = managerRef.current!; + const { model: parsed, warnings: warns } = await manager.open(u8, fileName); + + engineRef.current?.destroy(); + engineRef.current = FormulaEngine.fromModel(parsed); + editedRef.current = new Map(); + undoRef.current.clear(); + clipboardRef.current = null; + + setModel(parsed); + setActiveSheetIndex(parsed.activeSheetIndex); + setSelection(SelectionManager.create()); + setEditing(null); + setWarnings(warns); + setHasClipboard(false); + setIsDirty(false); + setHistoryDepth({ undo: 0, redo: 0 }); + setSearch({ query: '', matches: [], index: 0 }); + + setValidationErrors( + ValidationEngine.index(ValidationEngine.validateWorkbook(parsed, engineRef.current)), + ); + setStatus('ready'); + setVersion(v => v + 1); + } catch (e: any) { + setError(e?.message || 'Failed to open the workbook.'); + setStatus('error'); + } + }, + [], + ); + + const exportBytes = useCallback(async (): Promise => { + const engine = engineRef.current; + const manager = managerRef.current; + if (!engine || !model || !manager) throw new Error('No workbook is loaded.'); + return manager.save(model, engine, editedRef.current); + }, [model]); + + const dismissNotice = useCallback(() => setNotice(undefined), []); + + useEffect(() => { + if (!notice) return; + const t = setTimeout(() => setNotice(undefined), 2600); + return () => clearTimeout(t); + }, [notice]); + + useEffect( + () => () => { + engineRef.current?.destroy(); + managerRef.current?.close().catch(() => {}); + }, + [], + ); + + const errorCount = validationErrors.size; + const backendName = managerRef.current?.backendName ?? 'js-exceljs'; + + return useMemo( + () => ({ + status, error, warnings, notice, backendName, + model, activeSheet, activeSheetIndex, version, + selection, editing, isDirty, + canUndo: historyDepth.undo > 0, + canRedo: historyDepth.redo > 0, + hasClipboard, validationErrors, errorCount, search, + getCellModel, getDisplayValue, getUserContent, getCellError, canEdit, + loadFromBytes, exportBytes, setActiveSheet, validateAll, dismissNotice, dispose, + selectCell, selectRange, moveSelection, selectRowHeader, selectColumnHeader, selectAll, + beginEdit, setEditingText, commitEdit, cancelEdit, setCellContent, deleteSelection, + copy, paste, runSearch, findNext, findPrev, clearSearch, undo, redo, + }), + [ + status, error, warnings, notice, backendName, model, activeSheet, activeSheetIndex, version, + selection, editing, isDirty, historyDepth, hasClipboard, validationErrors, errorCount, search, + getCellModel, getDisplayValue, getUserContent, getCellError, canEdit, + loadFromBytes, exportBytes, setActiveSheet, validateAll, dismissNotice, dispose, + selectCell, selectRange, moveSelection, selectRowHeader, selectColumnHeader, selectAll, + beginEdit, setEditingText, commitEdit, cancelEdit, setCellContent, deleteSelection, + copy, paste, runSearch, findNext, findPrev, clearSearch, undo, redo, + ], + ); +}; + +export default useSpreadsheet; + + diff --git a/src/Spreadsheet/native/ExcelBridge.ts b/src/Spreadsheet/native/ExcelBridge.ts new file mode 100644 index 0000000..e0e3542 --- /dev/null +++ b/src/Spreadsheet/native/ExcelBridge.ts @@ -0,0 +1,49 @@ +/** + * ExcelBridge — the single access point to the native Apache POI module. + * + * Resolves the module via the TurboModule registry first, then the legacy + * `NativeModules` registry, so it works on both RN architectures. Exposes typed + * open/save/close and an `isAvailable()` probe the WorkbookManager uses to pick + * the native POI backend when present (Android) and the JS backend otherwise. + */ +import { NativeModules } from 'react-native'; +import TurboExcelModule, { Spec } from './NativeExcelModule'; +import { SaveRequestDTO, WorkbookDTO } from './dto'; + +const resolveModule = (): Spec | null => { + if (TurboExcelModule) return TurboExcelModule; + const legacy = (NativeModules as any)?.ExcelModule; + return legacy ?? null; +}; + +const nativeModule = resolveModule(); + +export const ExcelBridge = { + isAvailable(): boolean { + return !!nativeModule; + }, + + async open(base64: string): Promise { + if (!nativeModule) throw new Error('Native Excel module is not available.'); + const json = await nativeModule.open(base64); + return JSON.parse(json) as WorkbookDTO; + }, + + async save(request: SaveRequestDTO): Promise { + if (!nativeModule) throw new Error('Native Excel module is not available.'); + return nativeModule.save(JSON.stringify(request)); + }, + + async close(): Promise { + if (!nativeModule) return; + try { + await nativeModule.close(); + } catch { + /* best-effort */ + } + }, +}; + +export default ExcelBridge; + + diff --git a/src/Spreadsheet/native/NativeExcelModule.ts b/src/Spreadsheet/native/NativeExcelModule.ts new file mode 100644 index 0000000..3111459 --- /dev/null +++ b/src/Spreadsheet/native/NativeExcelModule.ts @@ -0,0 +1,34 @@ +/** + * TurboModule spec for the native Apache POI Excel module. + * + * The payloads are JSON strings (see `dto.ts`) so the contract stays stable + * across the old and new React Native architectures and avoids codegen churn + * over deeply-nested object types. + * + * Uses `TurboModuleRegistry.get` (not `getEnforcing`) so the module resolves to + * `null` when the native side isn't built — `ExcelBridge` then falls back to + * the pure-JS backend rather than throwing. + */ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +export interface Spec extends TurboModule { + /** + * Parse a base64-encoded .xlsx and retain the POI workbook for this session. + * Resolves with a JSON `WorkbookDTO`. + */ + open(base64: string): Promise; + + /** + * Apply a JSON `SaveRequestDTO` (edited values only) to the retained + * workbook and resolve with the base64-encoded .xlsx bytes. + */ + save(saveRequestJson: string): Promise; + + /** Release the retained workbook and native buffers. */ + close(): Promise; +} + +export default TurboModuleRegistry.get('ExcelModule'); + + diff --git a/src/Spreadsheet/native/dto.ts b/src/Spreadsheet/native/dto.ts new file mode 100644 index 0000000..f3da812 --- /dev/null +++ b/src/Spreadsheet/native/dto.ts @@ -0,0 +1,95 @@ +/** + * Wire DTOs exchanged with the native Apache POI module (as JSON strings over + * the bridge). Kept flat and primitive so both the TurboModule and the legacy + * NativeModule paths serialize cleanly. The RN side maps these into the rich + * `WorkbookModel` / `CellModel`. + */ + +export interface StyleDTO { + bold?: boolean; + italic?: boolean; + underline?: boolean; + strike?: boolean; + fontSize?: number; + fontColor?: string; // #RRGGBB + fontFamily?: string; + bgColor?: string; // #RRGGBB + hAlign?: string; + vAlign?: string; + wrap?: boolean; + borderTop?: boolean; + borderRight?: boolean; + borderBottom?: boolean; + borderLeft?: boolean; +} + +export interface ValidationDTO { + kind: 'list' | 'decimal' | 'whole' | 'textLength' | 'date'; + list?: string[]; + min?: number; + max?: number; + operator?: string; + required?: boolean; + errorMessage?: string; +} + +export interface CellDTO { + r: number; + c: number; + /** Literal value; dates are Excel serial numbers. */ + v: string | number | boolean | null; + /** Formula without leading `=`, or null. */ + f: string | null; + t: 'text' | 'number' | 'currency' | 'percentage' | 'date' | 'boolean' | 'dropdown'; + numFmt?: string; + locked: boolean; + comment?: string; + style?: StyleDTO; + dv?: ValidationDTO; +} + +export interface SheetDTO { + name: string; + index: number; + state: 'visible' | 'hidden' | 'veryHidden'; + protected: boolean; + frozenRows: number; + frozenCols: number; + rowCount: number; + colCount: number; + hiddenRows: number[]; + hiddenColumns: number[]; + /** col index -> pixel width. */ + columnWidths: Record; + /** row index -> pixel height. */ + rowHeights: Record; + /** merged ranges as "A1:B2". */ + merges: string[]; + cells: CellDTO[]; +} + +export interface WorkbookDTO { + fileName?: string; + activeSheet: number; + sheets: SheetDTO[]; + warnings?: string[]; +} + +/** A single value/formula change to apply on save. */ +export interface EditDTO { + sheet: number; + r: number; + c: number; + /** New formula (without `=`); mutually exclusive with `v`. */ + f?: string | null; + /** New literal value. */ + v?: string | number | boolean | null; + /** Clear the cell value. */ + clear?: boolean; +} + +export interface SaveRequestDTO { + edits: EditDTO[]; +} + + diff --git a/src/Spreadsheet/polyfills.ts b/src/Spreadsheet/polyfills.ts new file mode 100644 index 0000000..4dfe89d --- /dev/null +++ b/src/Spreadsheet/polyfills.ts @@ -0,0 +1,28 @@ +/** + * Runtime polyfills for the spreadsheet engine. + * + * The JS fallback backend (ExcelJS browser build, resolved by Metro's `browser` + * field) and some transitive helpers expect a global `Buffer`. Hermes / JSC do + * not provide one, so we wire the `buffer` package (a project dependency) onto + * `global` exactly once. Safe to import from multiple modules. + * + * The primary Android path (Apache POI native module) does not need this, but + * importing it is harmless and keeps the JS fallback working everywhere. + */ +import { Buffer } from 'buffer'; + +const g: any = globalThis as any; + +if (typeof g.Buffer === 'undefined') { + g.Buffer = Buffer; +} + +if (typeof g.process === 'undefined') { + g.process = { env: {}, browser: true } as any; +} else if (typeof g.process.env === 'undefined') { + g.process.env = {}; +} + +export {}; + + diff --git a/src/Spreadsheet/types.ts b/src/Spreadsheet/types.ts new file mode 100644 index 0000000..1ad7e86 --- /dev/null +++ b/src/Spreadsheet/types.ts @@ -0,0 +1,199 @@ +/** + * Core domain types for the native BOQ spreadsheet editor. + * + * Decoupled from both the file layer (Apache POI native module / ExcelJS + * fallback) and the calculation layer (HyperFormula). The engine, managers and + * UI speak only in these models so either backend can be swapped freely. + * + * Addresses are ALWAYS zero-based `{ row, col }` internally; A1 conversion + * lives in `utils/address.ts`. + */ + +export interface Address { + row: number; + col: number; +} + +/** Inclusive, normalized rectangular range (start = top-left, end = bottom-right). */ +export interface CellRange { + start: Address; + end: Address; +} + +/** Logical cell type driving formatting, editing affordances and validation. */ +export type CellType = + | 'text' + | 'number' + | 'currency' + | 'percentage' + | 'date' + | 'boolean' + | 'dropdown'; + +/** Raw JS value a cell can hold. Formula cells hold their *computed* result. */ +export type CellValue = string | number | boolean | Date | null; + +export interface CellBorders { + top?: boolean; + right?: boolean; + bottom?: boolean; + left?: boolean; +} + +/** Presentation attributes preserved from / written back to the workbook. */ +export interface CellStyle { + bold?: boolean; + italic?: boolean; + underline?: boolean; + strike?: boolean; + fontSize?: number; + fontColor?: string; // #RRGGBB + fontFamily?: string; + bgColor?: string; // #RRGGBB + hAlign?: 'left' | 'center' | 'right' | 'justify' | 'general'; + vAlign?: 'top' | 'middle' | 'bottom'; + wrap?: boolean; + border?: CellBorders; +} + +export type ValidationOperator = + | 'between' + | 'notBetween' + | 'equal' + | 'notEqual' + | 'greaterThan' + | 'lessThan' + | 'greaterThanOrEqual' + | 'lessThanOrEqual'; + +/** A validation rule attached to a cell. */ +export interface DataValidationRule { + kind: 'list' | 'decimal' | 'whole' | 'textLength' | 'date' | 'custom' | 'none'; + operator?: ValidationOperator; + min?: number; + max?: number; + list?: string[]; + required?: boolean; + noDuplicates?: boolean; + validate?: (value: CellValue, ctx: ValidationContext) => boolean; + errorMessage?: string; + allowBlank?: boolean; +} + +export interface ValidationContext { + address: Address; + sheetIndex: number; + columnValues: CellValue[]; +} + +export interface ValidationError { + address: Address; + sheetIndex: number; + message: string; + ruleKind: DataValidationRule['kind'] | 'required' | 'type' | 'duplicate'; +} + +/** + * Per-cell metadata. The cell's *current value* is owned by the formula engine; + * this model carries everything else. Editability is derived from `formula`, + * `locked` and the sheet's `protected` flag — never guessed. + */ +export interface CellModel { + row: number; + col: number; + /** Literal seed value (dates stored as Excel serial numbers). */ + raw: CellValue; + /** Formula text WITHOUT the leading `=`, or `null` for literal cells. */ + formula: string | null; + type: CellType; + /** Excel number-format string. */ + numFmt?: string; + style?: CellStyle; + comment?: string; + /** + * Excel cell-protection "locked" flag as read from the workbook. In Excel a + * locked cell is only actually protected when its sheet is protected. + */ + locked: boolean; + validation?: DataValidationRule; + dropdownOptions?: string[]; +} + +/** One worksheet plus all structural metadata preserved on round-trip. */ +export interface SheetModel { + id: string; + name: string; + index: number; + hfSheetId: number; + rowCount: number; + colCount: number; + cells: Map; + columnWidths: Map; + rowHeights: Map; + hiddenRows: Set; + hiddenColumns: Set; + merges: CellRange[]; + frozenRows: number; + frozenCols: number; + state: 'visible' | 'hidden' | 'veryHidden'; + /** True when the worksheet is protected — locked cells become read-only. */ + protected: boolean; + defaultColWidth: number; + defaultRowHeight: number; +} + +export interface WorkbookModel { + sheets: SheetModel[]; + activeSheetIndex: number; + fileName: string; +} + +/** A single cell edit (drives undo/redo). */ +export interface CellEdit { + sheetIndex: number; + address: Address; + before: string; + after: string; +} + +export interface EditTransaction { + label: string; + edits: CellEdit[]; +} + +export interface ClipboardData { + contents: string[][]; + rows: number; + cols: number; +} + +/** A search hit within a sheet. */ +export interface SearchMatch { + sheetIndex: number; + address: Address; + preview: string; +} + +export const cellKey = (row: number, col: number): string => `${row}:${col}`; + +export const parseCellKey = (key: string): Address => { + const [row, col] = key.split(':'); + return { row: Number(row), col: Number(col) }; +}; + +/** + * The single source of truth for whether a cell may be edited. + * Formula cells are never editable. Locked cells are read-only only when the + * sheet is protected (Excel semantics). Everything else is editable. + */ +export const isCellEditable = ( + cell: CellModel | undefined, + sheetProtected: boolean, +): boolean => { + if (!cell) return !sheetProtected; // blank cell: editable unless a protected sheet locks it + if (cell.formula) return false; + if (sheetProtected && cell.locked) return false; + return true; +}; + + diff --git a/src/Spreadsheet/utils/address.ts b/src/Spreadsheet/utils/address.ts new file mode 100644 index 0000000..291356c --- /dev/null +++ b/src/Spreadsheet/utils/address.ts @@ -0,0 +1,75 @@ +/** + * Excel A1 <-> zero-based coordinate conversions and range helpers. + * All engine/UI code uses zero-based `{ row, col }`. + */ +import { Address, CellRange } from '../types'; + +/** 0 -> "A", 26 -> "AA". */ +export const columnToLetter = (col: number): string => { + let n = col + 1; + let letter = ''; + while (n > 0) { + const rem = (n - 1) % 26; + letter = String.fromCharCode(65 + rem) + letter; + n = Math.floor((n - 1) / 26); + } + return letter; +}; + +/** "A" -> 0, "AA" -> 26. */ +export const letterToColumn = (letter: string): number => { + let col = 0; + const upper = letter.toUpperCase(); + for (let i = 0; i < upper.length; i++) { + col = col * 26 + (upper.charCodeAt(i) - 64); + } + return col - 1; +}; + +export const addressToA1 = ({ row, col }: Address): string => + `${columnToLetter(col)}${row + 1}`; + +const A1_RE = /^\$?([A-Za-z]+)\$?(\d+)$/; + +export const a1ToAddress = (a1: string): Address | null => { + const m = A1_RE.exec(a1.trim()); + if (!m) return null; + return { col: letterToColumn(m[1]), row: Number(m[2]) - 1 }; +}; + +export const normalizeRange = (a: Address, b: Address): CellRange => ({ + start: { row: Math.min(a.row, b.row), col: Math.min(a.col, b.col) }, + end: { row: Math.max(a.row, b.row), col: Math.max(a.col, b.col) }, +}); + +export const isInRange = (addr: Address, range: CellRange): boolean => + addr.row >= range.start.row && + addr.row <= range.end.row && + addr.col >= range.start.col && + addr.col <= range.end.col; + +export const rangeRows = (r: CellRange): number => r.end.row - r.start.row + 1; +export const rangeCols = (r: CellRange): number => r.end.col - r.start.col + 1; + +export const addressEquals = (a: Address, b: Address): boolean => + a.row === b.row && a.col === b.col; + +export const forEachInRange = (range: CellRange, fn: (addr: Address) => void): void => { + for (let row = range.start.row; row <= range.end.row; row++) { + for (let col = range.start.col; col <= range.end.col; col++) { + fn({ row, col }); + } + } +}; + +/** Parse "A1:B2" (or single "A1") into a normalized range. */ +export const parseRangeA1 = (ref: string): CellRange | null => { + const [a, b] = ref.split(':'); + const start = a1ToAddress(a); + if (!start) return null; + const end = b ? a1ToAddress(b) : start; + if (!end) return null; + return normalizeRange(start, end); +}; + + diff --git a/src/Spreadsheet/utils/cellFormat.ts b/src/Spreadsheet/utils/cellFormat.ts new file mode 100644 index 0000000..3990d4c --- /dev/null +++ b/src/Spreadsheet/utils/cellFormat.ts @@ -0,0 +1,183 @@ +/** + * Value <-> display formatting and input parsing per cell type. + * + * Pure and framework-agnostic (unit tested). Implements the subset of Excel + * number-format handling seen in real BOQ workbooks: grouping, fixed decimals, + * percent, currency prefixes and the common date masks. + */ +import { CellType, CellValue } from '../types'; + +/* ------------------------- Excel serial dates ------------------------- */ + +const EXCEL_EPOCH_UTC = Date.UTC(1899, 11, 30); // accounts for 1900 leap-day bug +const MS_PER_DAY = 86400000; + +export const excelSerialToDate = (serial: number): Date => + new Date(EXCEL_EPOCH_UTC + Math.round(serial * MS_PER_DAY)); + +export const dateToExcelSerial = (date: Date): number => + (date.getTime() - EXCEL_EPOCH_UTC) / MS_PER_DAY; + +/* --------------------- number-format classification ------------------- */ + +const DATE_TOKENS = /[dmyhs]/i; + +const isDateFormat = (fmt?: string): boolean => { + if (!fmt) return false; + const stripped = fmt.replace(/\[[^\]]*\]/g, '').replace(/"[^"]*"/g, ''); + return DATE_TOKENS.test(stripped) && !/[#0]/.test(stripped); +}; + +const isPercentFormat = (fmt?: string): boolean => !!fmt && fmt.includes('%'); + +const currencySymbol = (fmt?: string): string | null => { + if (!fmt) return null; + const bracket = /\[\$([^\]-]+)(?:-[^\]]*)?\]/.exec(fmt); + if (bracket) return bracket[1]; + const quoted = /"([^"]+)"/.exec(fmt); + if (quoted && /[^\d#0.,\s]/.test(quoted[1])) return quoted[1]; + const symbol = /^[^\d#0]*([$€£₹¥])/.exec(fmt); + return symbol ? symbol[1] : null; +}; + +const decimalPlaces = (fmt?: string): number => { + if (!fmt) return -1; + const m = /\.([0#]+)/.exec(fmt); + return m ? m[1].length : 0; +}; + +const hasGrouping = (fmt?: string): boolean => !!fmt && fmt.includes(','); + +/* ------------------------------ formatting ---------------------------- */ + +const groupThousands = (intPart: string): string => + intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + +const formatFixed = (value: number, decimals: number, grouping: boolean): string => { + const negative = value < 0; + const abs = Math.abs(value); + const fixed = decimals >= 0 ? abs.toFixed(decimals) : String(abs); + let [intPart, fracPart] = fixed.split('.'); + if (grouping) intPart = groupThousands(intPart); + const body = fracPart ? `${intPart}.${fracPart}` : intPart; + return negative ? `-${body}` : body; +}; + +const pad = (n: number, len = 2): string => String(n).padStart(len, '0'); +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +const formatDate = (date: Date, fmt?: string): string => { + const d = date.getUTCDate(); + const mo = date.getUTCMonth(); + const y = date.getUTCFullYear(); + const h = date.getUTCHours(); + const mi = date.getUTCMinutes(); + const s = date.getUTCSeconds(); + + if (!fmt) return `${pad(d)}/${pad(mo + 1)}/${y}`; + + return fmt + .replace(/yyyy/gi, String(y)) + .replace(/yy/gi, pad(y % 100)) + .replace(/mmmm/gi, MONTHS[mo]) + .replace(/mmm/gi, MONTHS[mo]) + .replace(/mm/g, pad(mo + 1)) + .replace(/dd/gi, pad(d)) + .replace(/d/gi, String(d)) + .replace(/hh/gi, pad(h)) + .replace(/h/gi, String(h)) + .replace(/ss/gi, pad(s)) + .replace(/\bm\b/g, String(mi)) + .replace(/"([^"]*)"/g, '$1'); +}; + +export const formatCellValue = (value: CellValue, type: CellType, numFmt?: string): string => { + if (value === null || value === undefined || value === '') return ''; + if (value instanceof Date) return formatDate(value, numFmt); + if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE'; + + if (typeof value === 'number') { + if (!isFinite(value)) return String(value); + if (isDateFormat(numFmt) || type === 'date') { + return formatDate(excelSerialToDate(value), numFmt); + } + if (isPercentFormat(numFmt) || type === 'percentage') { + const decimals = decimalPlaces(numFmt); + return `${formatFixed(value * 100, decimals >= 0 ? decimals : 2, hasGrouping(numFmt))}%`; + } + const sym = currencySymbol(numFmt); + if (sym || type === 'currency') { + const decimals = decimalPlaces(numFmt); + return `${sym ?? '₹'}${formatFixed(value, decimals >= 0 ? decimals : 2, true)}`; + } + const decimals = decimalPlaces(numFmt); + return formatFixed(value, decimals, hasGrouping(numFmt)); + } + + return String(value); +}; + +/* ---------------------------- input parsing --------------------------- */ + +export interface ParsedInput { + formula: string | null; + value: CellValue; +} + +const NUMERIC_RE = /^-?[\d,]*\.?\d+$/; + +export const parseInput = (text: string, type: CellType): ParsedInput => { + const trimmed = text.trim(); + if (trimmed.startsWith('=')) return { formula: trimmed.slice(1), value: null }; + if (trimmed === '') return { formula: null, value: null }; + + switch (type) { + case 'number': + case 'currency': { + const cleaned = trimmed.replace(/[₹$€£¥,\s]/g, ''); + const n = Number(cleaned); + return { formula: null, value: isNaN(n) ? trimmed : n }; + } + case 'percentage': { + const cleaned = trimmed.replace(/[%,\s]/g, ''); + const n = Number(cleaned); + return { formula: null, value: isNaN(n) ? trimmed : n / 100 }; + } + case 'boolean': { + const lower = trimmed.toLowerCase(); + if (['true', '1', 'yes'].includes(lower)) return { formula: null, value: true }; + if (['false', '0', 'no'].includes(lower)) return { formula: null, value: false }; + return { formula: null, value: trimmed }; + } + case 'date': { + const ms = Date.parse(trimmed); + if (!isNaN(ms)) return { formula: null, value: dateToExcelSerial(new Date(ms)) }; + return { formula: null, value: trimmed }; + } + default: { + if (NUMERIC_RE.test(trimmed)) { + const n = Number(trimmed.replace(/,/g, '')); + if (!isNaN(n)) return { formula: null, value: n }; + } + return { formula: null, value: trimmed }; + } + } +}; + +/** Text shown when a user starts editing (re-parseable by `parseInput`). */ +export const toEditableText = ( + formula: string | null, + value: CellValue, + type: CellType, +): string => { + if (formula) return `=${formula}`; + if (value === null || value === undefined) return ''; + if (value instanceof Date) { + return `${pad(value.getUTCDate())}/${pad(value.getUTCMonth() + 1)}/${value.getUTCFullYear()}`; + } + if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE'; + if (type === 'percentage' && typeof value === 'number') return String(value * 100); + return String(value); +}; + + diff --git a/src/Spreadsheet/utils/color.ts b/src/Spreadsheet/utils/color.ts new file mode 100644 index 0000000..8df0647 --- /dev/null +++ b/src/Spreadsheet/utils/color.ts @@ -0,0 +1,39 @@ +/** + * Colour helpers bridging ARGB (POI / ExcelJS) and `#RRGGBB` hex for the UI. + */ + +/** `FFRRGGBB` (or `RRGGBB`) -> `#RRGGBB`. */ +export const argbToHex = (argb?: string): string | undefined => { + if (!argb || typeof argb !== 'string') return undefined; + const clean = argb.replace('#', ''); + if (clean.length === 8) return `#${clean.slice(2)}`.toUpperCase(); + if (clean.length === 6) return `#${clean}`.toUpperCase(); + return undefined; +}; + +/** `#RRGGBB` -> `FFRRGGBB`. */ +export const hexToArgb = (hex?: string): string | undefined => { + if (!hex) return undefined; + const clean = hex.replace('#', ''); + if (clean.length === 6) return `FF${clean}`.toUpperCase(); + if (clean.length === 8) return clean.toUpperCase(); + return undefined; +}; + +/** Perceptual luminance (0..1) of `#RRGGBB`. */ +export const luminance = (hex: string): number => { + const clean = hex.replace('#', ''); + const r = parseInt(clean.slice(0, 2), 16) / 255; + const g = parseInt(clean.slice(2, 4), 16) / 255; + const b = parseInt(clean.slice(4, 6), 16) / 255; + const srgb = [r, g, b].map(c => + c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4), + ); + return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2]; +}; + +/** Choose black or white text for legibility over `bgHex`. */ +export const readableTextOn = (bgHex: string): string => + luminance(bgHex) > 0.5 ? '#0f172a' : '#f8fafc'; + + diff --git a/src/Spreadsheet/utils/geometry.ts b/src/Spreadsheet/utils/geometry.ts new file mode 100644 index 0000000..d88774c --- /dev/null +++ b/src/Spreadsheet/utils/geometry.ts @@ -0,0 +1,98 @@ +/** + * Pure grid geometry: column/row sizing (touch-friendly minimums), visible + * line lists (hidden rows/cols removed) and merged-cell resolution for the row + * renderer. + */ +import { CellRange, SheetModel, cellKey } from '../types'; + +export const MIN_COL_WIDTH = 56; +export const MIN_ROW_HEIGHT = 40; +export const ROW_NUMBER_WIDTH = 52; +export const HEADER_HEIGHT = 34; + +export const colWidth = (sheet: SheetModel, col: number): number => + Math.max(MIN_COL_WIDTH, sheet.columnWidths.get(col) ?? sheet.defaultColWidth); + +/** Uniform row height for virtualization (per-row heights preserved on export). */ +export const rowHeight = (sheet: SheetModel): number => + Math.max(MIN_ROW_HEIGHT, sheet.defaultRowHeight); + +export const visibleRows = (sheet: SheetModel): number[] => { + const out: number[] = []; + for (let r = 0; r < sheet.rowCount; r++) if (!sheet.hiddenRows.has(r)) out.push(r); + return out; +}; + +export const visibleColumns = (sheet: SheetModel): number[] => { + const out: number[] = []; + for (let c = 0; c < sheet.colCount; c++) if (!sheet.hiddenColumns.has(c)) out.push(c); + return out; +}; + +export const totalWidth = (sheet: SheetModel, cols: number[]): number => + cols.reduce((sum, c) => sum + colWidth(sheet, c), 0); + +export interface MergeIndex { + coveredBy: Map; +} + +export const buildMergeIndex = (sheet: SheetModel): MergeIndex => { + const coveredBy = new Map(); + for (const range of sheet.merges) { + for (let r = range.start.row; r <= range.end.row; r++) { + for (let c = range.start.col; c <= range.end.col; c++) { + coveredBy.set(cellKey(r, c), range); + } + } + } + return { coveredBy }; +}; + +export interface CellRenderPlan { + col: number; + width: number; + skip: boolean; +} + +/** + * Resolve how each visible column in a row renders, honouring merges: + * - a merge master renders at the summed width of its columns; + * - same-row slaves are skipped (width absorbed by the master); + * - lower rows of a block merge keep column alignment. + */ +export const rowRenderPlan = ( + sheet: SheetModel, + row: number, + cols: number[], + merge: MergeIndex, +): CellRenderPlan[] => { + const spannedWidth = (range: CellRange): number => { + let w = 0; + for (let c = range.start.col; c <= range.end.col; c++) { + if (!sheet.hiddenColumns.has(c)) w += colWidth(sheet, c); + } + return w; + }; + + const plan: CellRenderPlan[] = []; + for (const col of cols) { + const range = merge.coveredBy.get(cellKey(row, col)); + if (!range) { + plan.push({ col, width: colWidth(sheet, col), skip: false }); + continue; + } + const isMaster = range.start.row === row && range.start.col === col; + if (isMaster) { + plan.push({ col, width: spannedWidth(range), skip: false }); + } else if (range.start.row === row) { + plan.push({ col, width: 0, skip: true }); + } else if (range.start.col === col) { + plan.push({ col, width: spannedWidth(range), skip: false }); + } else { + plan.push({ col, width: 0, skip: true }); + } + } + return plan; +}; + + diff --git a/src/Styles/Styles.tsx b/src/Styles/Styles.tsx deleted file mode 100644 index 4d346fb..0000000 --- a/src/Styles/Styles.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { StyleSheet } from 'react-native' -import Colors from './Color'; - -const Styles = StyleSheet.create({ - logo: { - marginTop: 20, - alignItems: "center" - }, - heading: { - fontWeight: 800, - fontSize: 18, - lineHeight: 29, - textAlign: "center", - color: "#1B4B66" - }, - button: { - backgroundColor: Colors.appColourDark, - width: "100%", - paddingVertical: 15, - paddingHorizontal: 28, - borderRadius: 50, - - // marginBottom: 87 - }, - next: { - fontSize: 15, - color: "white", - fontWeight: 700, - textAlign: "center", - letterSpacing: 3 - }, - input: { - backgroundColor: Colors.black20, - padding: 15, - borderRadius: 10, - marginTop: 10, - fontSize: 15, - color: Colors.appColourDark, - elevation: 2 - }, - flexDesign: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - gap: 10 - }, - paddding: { - paddingHorizontal: 5 - }, - description: { - fontSize: 17, - fontWeight: 500, - width: 290, - fontFamily: "Inter-Bold", - color: Colors.appColourDark, - marginTop: 5 - }, - title: { - textAlign: "center", - fontSize: 12, - fontWeight: 800, - fontFamily: "quicksand-SemiBold", - color: Colors.appColourDark, - }, - Button_2: { - width: "100%", - backgroundColor: Colors.appColourDark, - paddingVertical: 10, - borderRadius: 10 - }, - Button_2_Text: { - fontFamily: "Inter-Bold", - textAlign: "center", - fontSize: 15, - fontWeight: 800, - lineHeight: 24 - }, - Button_3: { - backgroundColor: Colors.appColourDark, - width: "70%", - alignItems: "center", - flexDirection: "row", - justifyContent: "center", - paddingVertical: 15, - borderRadius: 8, - gap: 5 - }, - Button_3_Text: { - fontSize: 14, - fontWeight: 800, - lineHeight: 24, - color: "white" - }, - productName: { - fontFamily: "Inter-Bold", - fontSize: 14, - fontWeight: 800, - lineHeight: 16, - color: Colors.black - }, - productDiscription: { - fontSize: 13, - fontFamily: "Inter-Bold", - fontWeight: 800, - lineHeight: 16, - color: Colors.black50, - marginTop: 2, - }, - date: { - fontFamily: "Inter-Bold", - fontWeight: 800, - fontSize: 13, - lineHeight: 16, - color: Colors.black - }, - - cost: { - fontFamily: "Inter-Bold", - fontSize: 14, - fontWeight: 700, - lineHeight: 18, - color: Colors.black - }, - emptyCartHeading: { - fontFamily: "Inter-Bold", - fontSize: 28, - fontWeight: 800, - lineHeight: 26, - color: Colors.black, - marginTop: 30 - }, - emptyCartDiscription: { - fontFamily: "Inter-Bold", - fontSize: 15, - fontWeight: 700, - lineHeight: 18, - color: Colors.black, - marginTop: 16, - width: "60%", - textAlign: "center" - }, - phone: { - fontSize: 19, - fontWeight: 700, - lineHeight: 24, - color: Colors.appColourDark, - marginTop: 9 - }, - itemCount: { - fontFamily: "Inter-Bold", - fontSize: 17, - fontWeight: 800, - lineHeight: 19, - color: Colors.black50, - marginBottom: 15 - }, - qty: { - fontSize: 12, - fontFamily: "Inter-Bold", - fontWeight: 600, - lineHeight: 18, - color: Colors.black50 - }, - heading_2: { - color: Colors.black, - fontSize: 14, - fontWeight: 800, - lineHeight: 18, - marginBottom: 10 - }, - text: { - fontWeight: 600, - fontSize: 14, - lineHeight: 25, - color: "#333333", - textAlign: "justify" - }, - heading_3: { - color: Colors.black50, - fontSize: 16, - fontWeight: 800, - lineHeight: 18, - marginBottom: 8 - }, - address: { - fontFamily: "Inter-Bold", - fontSize: 14, - fontWeight: 700, - lineHeight: 18, - color: Colors.black50 - }, - buttonSize: { - width: 30, - height: 30, - alignItems: 'center' - }, - deletePrice: { - fontFamily: "Inter-Bold", - fontSize: 13, - fontWeight: 700, - color: "#626262", - textDecorationLine: 'line-through' - }, - off: { - fontFamily: "Inter-Bold", - fontSize: 12, - fontWeight: 700, - color: "#E21D1D", - }, - currentPrice: { - fontFamily: "Inter-Bold", - fontSize: 17, - fontWeight: 800, - color: "#171520", - - }, -}) -export default Styles; \ No newline at end of file diff --git a/src/Utils/Types.tsx b/src/Utils/Types.tsx index 23bf7e3..d88a322 100644 --- a/src/Utils/Types.tsx +++ b/src/Utils/Types.tsx @@ -1,10 +1,4 @@ -import NavigationString from "../Constant/NavigationString"; - -export interface RootStackParamList { - [NavigationString.Home]: undefined; -}; - - +// Route types live in src/Navigation/types.ts (RootStackParamList). export interface CommonColors { appColourDark: string; diff --git a/src/api/client.ts b/src/api/client.ts deleted file mode 100644 index 043f99c..0000000 --- a/src/api/client.ts +++ /dev/null @@ -1,36 +0,0 @@ -import axios from 'axios'; -import { store } from '../Redux/Store'; -import { setToken } from '../Redux/Slices/Token'; - -const apiClient = axios.create({ - baseURL: 'http://procurelinc.com/api', // Replace with actual base URL if different - headers: { - 'Content-Type': 'application/json', - }, -}); - -apiClient.interceptors.request.use( - (config) => { - const state = store.getState(); - const token = state.token?.token?.token; - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; - }, - (error) => { - return Promise.reject(error); - } -); - -apiClient.interceptors.response.use( - (response) => response, - (error) => { - if (error.response?.status === 401) { - store.dispatch(setToken({})); - } - return Promise.reject(error); - } -); - -export default apiClient; diff --git a/src/components/AppStatusBar.tsx b/src/components/AppStatusBar.tsx index b7df782..96d9550 100644 --- a/src/components/AppStatusBar.tsx +++ b/src/components/AppStatusBar.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { StatusBar, Platform } from 'react-native'; +import { StatusBar } from 'react-native'; import { useTheme } from '../Hooks/useTheme'; const AppStatusBar: React.FC = () => { diff --git a/src/Component/Counter/CountdownTimer.tsx b/src/components/CountdownTimer.tsx similarity index 95% rename from src/Component/Counter/CountdownTimer.tsx rename to src/components/CountdownTimer.tsx index 02098d6..3799a24 100644 --- a/src/Component/Counter/CountdownTimer.tsx +++ b/src/components/CountdownTimer.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useCallback } from 'react'; import { View, Text, StyleSheet } from 'react-native'; -import colors from '../../Constant/Color'; +import colors from '../Constant/Color'; import moment from 'moment'; import LottieView from 'lottie-react-native'; @@ -47,7 +47,7 @@ const CountdownTimer: React.FC = React.memo(({ endDate, myS return ( void; - onDrawerPress?: () => void; - rightIcon?: React.ReactNode; -} - -const Header: React.FC = ({ - title, - showDrawer = false, - showBack = false, - onBackPress, - onDrawerPress, - rightIcon -}) => { - const insets = useSafeAreaInsets(); - const navigation = useNavigation(); - const { colors, spacing, typography, shadows } = useTheme(); - - const handleBack = () => { - if (onBackPress) { - onBackPress(); - } else { - navigation.goBack(); - } - }; - - return ( - - - - {showDrawer && ( - - - - )} - {showBack && ( - - - - )} - - {title} - - - - - {rightIcon || ( - - )} - - - - ); -}; - -export default Header; - -const styles = StyleSheet.create({ - container: { - width: width, - justifyContent: 'center', - }, - row: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - height: '100%', - }, - left: { - flexDirection: 'row', - alignItems: 'center', - flex: 1, - }, - right: { - flexDirection: 'row', - alignItems: 'center', - }, - iconButton: { - padding: 4, - }, - title: { - // Font family can be added here if defined in theme - }, - logo: { - width: 40, - height: 40, - }, -}); diff --git a/src/Component/Header/InsideHeader.tsx b/src/components/InsideHeader.tsx similarity index 83% rename from src/Component/Header/InsideHeader.tsx rename to src/components/InsideHeader.tsx index d49ef0a..96708e9 100644 --- a/src/Component/Header/InsideHeader.tsx +++ b/src/components/InsideHeader.tsx @@ -2,10 +2,10 @@ import React from 'react'; import { View, Text, TouchableOpacity, Image, StyleSheet, Dimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ArrowLeft } from 'lucide-react-native'; -import { useTheme } from '../../Hooks/useTheme'; -import ImagePath from '../../Constant/ImagePath'; +import { useTheme } from '../Hooks/useTheme'; +import ImagePath from '../Constant/ImagePath'; import { useNavigation } from '@react-navigation/native'; -import NavigationString from '../../Constant/NavigationString'; +import NavigationString from '../Constant/NavigationString'; const { width } = Dimensions.get('window'); @@ -17,7 +17,7 @@ interface HeaderProps { const InsideHeader: React.FC = ({ title, showArrow = false }) => { const navigation: any = useNavigation(); const insets = useSafeAreaInsets(); - const { colors, spacing, typography, shadows } = useTheme(); + const { colors, typography, shadows } = useTheme(); return ( = ({ title, showArrow = false }) => { onPress={() => navigation.goBack()} style={styles.backButton} activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel="Go back" > @@ -53,7 +55,11 @@ const InsideHeader: React.FC = ({ title, showArrow = false }) => { {/* Optional: Add Logo or Right Action */} - { navigation.navigate(NavigationString.Home) }}> + { navigation.navigate(NavigationString.Home) }} + accessibilityRole="button" + accessibilityLabel="Go to home" + > = ({ size = "large", color, style }) => { - const { colors } = useTheme(); +const Loader = ({ size = "large", color = '#000', style }: LoaderType) => { return ( - - + + ); }; export default Loader; - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, -}); diff --git a/src/Component/Dropdown/MyDropdown.tsx b/src/components/MyDropdown.tsx similarity index 98% rename from src/Component/Dropdown/MyDropdown.tsx rename to src/components/MyDropdown.tsx index 3c6e2d1..4008916 100644 --- a/src/Component/Dropdown/MyDropdown.tsx +++ b/src/components/MyDropdown.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { StyleSheet, Text, View, StyleProp, ViewStyle, TextStyle } from 'react-native'; import { Dropdown } from 'react-native-element-dropdown'; import { Shield, ChevronDown } from 'lucide-react-native'; -import { useTheme } from '../../Hooks/useTheme'; +import { useTheme } from '../Hooks/useTheme'; type DropdownItem = { [key: string]: any; diff --git a/src/components/MyInput.tsx b/src/components/MyInput.tsx index e00eff1..c775237 100644 --- a/src/components/MyInput.tsx +++ b/src/components/MyInput.tsx @@ -24,7 +24,7 @@ type MyInputProps = { } & TextInputProps; const MyInput: React.FC = ({ - icon, + icon: _icon, placeholder, value, onChangeText, @@ -44,7 +44,7 @@ const MyInput: React.FC = ({ containerStyle, // This goes to outer container ...textInputProps }) => { - const { colors, spacing, typography, shadows } = useTheme(); + const { colors, spacing, typography } = useTheme(); const [internalFocused, setInternalFocused] = React.useState(false); const isFocused = externalFocused !== undefined ? externalFocused : internalFocused; diff --git a/src/Component/Serach/SeacrhInput.tsx b/src/components/SearchInput.tsx similarity index 94% rename from src/Component/Serach/SeacrhInput.tsx rename to src/components/SearchInput.tsx index f2f05ec..f16f736 100644 --- a/src/Component/Serach/SeacrhInput.tsx +++ b/src/components/SearchInput.tsx @@ -7,7 +7,7 @@ import Animated, { withTiming, interpolateColor, } from "react-native-reanimated"; -import { useTheme } from "../../Hooks/useTheme"; +import { useTheme } from "../Hooks/useTheme"; export interface SearchType { placeholder?: string; @@ -31,7 +31,7 @@ export const SearchInput: React.FC = ({ const { colors, shadows } = useTheme(); const isFocused = useSharedValue(0); const [text, setText] = useState(''); - const debounceRef = useRef(null); + const debounceRef = useRef | null>(null); const inputContainerStyle = useAnimatedStyle(() => { const borderColor = interpolateColor( @@ -62,6 +62,8 @@ export const SearchInput: React.FC = ({ clearTimeout(debounceRef.current); } }; + // Intentionally runs only when text changes; other deps are stable or recreated each render. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [text]); return ( diff --git a/src/components/SidebarModal.tsx b/src/components/SidebarModal.tsx index bb1a519..d4d495c 100644 --- a/src/components/SidebarModal.tsx +++ b/src/components/SidebarModal.tsx @@ -6,8 +6,7 @@ import AppText from './AppText'; import { Moon, Sun, Monitor, X, LogOut, FileText, User } from 'lucide-react-native'; import ImagePath from '../Constant/ImagePath'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useDispatch } from 'react-redux'; -import { removeToken } from '../Redux/Slices/Token'; +import { performLogout } from '../Services/Auth/session'; import NavigationString from '../Constant/NavigationString'; import { useNavigation } from '@react-navigation/native'; @@ -16,17 +15,15 @@ interface SidebarModalProps { onClose: () => void; } -const { width, height } = Dimensions.get('window'); +const { width } = Dimensions.get('window'); const SidebarModal: React.FC = ({ isVisible, onClose }) => { const { colors, themeMode, toggleTheme } = useTheme(); const insets = useSafeAreaInsets(); - const dispatch = useDispatch(); const navigation = useNavigation(); const handleLogout = () => { - dispatch(removeToken()); - // Navigation reset is handled by MainStack/Routes usually, or we can navigate to Login + performLogout(); navigation.reset({ index: 0, routes: [{ name: NavigationString.Login }], diff --git a/src/Component/Skelton/Skelton.tsx b/src/components/Skeleton.tsx similarity index 90% rename from src/Component/Skelton/Skelton.tsx rename to src/components/Skeleton.tsx index 76749eb..3e0b043 100644 --- a/src/Component/Skelton/Skelton.tsx +++ b/src/components/Skeleton.tsx @@ -1,9 +1,16 @@ import React, { useEffect, useRef } from 'react'; -import { View, Animated, StyleSheet, Dimensions } from 'react-native'; -import { useTheme } from '../../Hooks/useTheme'; +import { View, Animated, StyleSheet, DimensionValue, StyleProp, ViewStyle } from 'react-native'; +import { useTheme } from '../Hooks/useTheme'; + +type SkeletonBoneProps = { + width: DimensionValue; + height: DimensionValue; + style?: StyleProp; + borderRadius?: number; +}; // 1. Create a Reusable "Bone" Component -const SkeletonBone = ({ width, height, style, borderRadius = 8 }) => { +const SkeletonBone = ({ width, height, style, borderRadius = 8 }: SkeletonBoneProps) => { const { isDarkMode } = useTheme(); const opacity = useRef(new Animated.Value(0.3)).current; diff --git a/src/Component/Skelton/SkeltonList.tsx b/src/components/SkeletonList.tsx similarity index 90% rename from src/Component/Skelton/SkeltonList.tsx rename to src/components/SkeletonList.tsx index 46a571e..7820653 100644 --- a/src/Component/Skelton/SkeltonList.tsx +++ b/src/components/SkeletonList.tsx @@ -2,7 +2,7 @@ import { View, Dimensions } from 'react-native'; import React from 'react'; import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; -const SkeltonList = () => { +const SkeletonList = () => { const { width, height } = Dimensions.get('screen'); return ( @@ -16,4 +16,4 @@ const SkeltonList = () => { ); }; -export default SkeltonList; +export default SkeletonList; diff --git a/src/Component/StepProgressBar.tsx b/src/components/StepProgressBar.tsx similarity index 95% rename from src/Component/StepProgressBar.tsx rename to src/components/StepProgressBar.tsx index 7630956..89095dc 100644 --- a/src/Component/StepProgressBar.tsx +++ b/src/components/StepProgressBar.tsx @@ -1,13 +1,8 @@ import React, { useMemo } from 'react'; import { View, StyleSheet } from 'react-native'; -import Animated, { - useSharedValue, - useAnimatedStyle, - withSpring, - withTiming, -} from 'react-native-reanimated'; +import Animated from 'react-native-reanimated'; import { useTheme } from '../Hooks/useTheme'; -import AppText from '../components/AppText'; +import AppText from './AppText'; import { Check } from 'lucide-react-native'; type StepProgressBarProps = { @@ -21,7 +16,6 @@ const STEP_LABELS = ['Info', 'Register', 'KYC', 'Address', 'Docs']; const StepProgressBar: React.FC = ({ currentStep, steps, - onStepPress = () => { }, }) => { const { colors, spacing, typography, shadows } = useTheme(); const styles = useMemo(() => makeStyles(colors, spacing), [colors, spacing]); diff --git a/src/Component/TermsAndConditionsModal/TermsAndConditionsModal.tsx b/src/components/TermsAndConditionsModal.tsx similarity index 97% rename from src/Component/TermsAndConditionsModal/TermsAndConditionsModal.tsx rename to src/components/TermsAndConditionsModal.tsx index af93fc3..ca0edb1 100644 --- a/src/Component/TermsAndConditionsModal/TermsAndConditionsModal.tsx +++ b/src/components/TermsAndConditionsModal.tsx @@ -1,9 +1,9 @@ import React, { ReactNode, useMemo } from 'react'; import { View, Modal, ScrollView, TouchableOpacity, StyleSheet } from 'react-native'; import { CheckCircle, X, FileText, Shield, Users, Brain, Lock, RefreshCw, Scale } from 'lucide-react-native'; -import Animated, { FadeInUp, FadeInDown, FadeIn } from 'react-native-reanimated'; -import { useTheme } from '../../Hooks/useTheme'; -import AppText from '../../components/AppText'; +import Animated, { FadeInUp, FadeInDown } from 'react-native-reanimated'; +import { useTheme } from '../Hooks/useTheme'; +import AppText from '../components/AppText'; type ModalProps = { visible: boolean; @@ -202,7 +202,7 @@ type SectionProps = { index: number; }; -const Section: React.FC = ({ title, icon, children, styles, colors, spacing, index }) => ( +const Section: React.FC = ({ title, icon, children, styles, colors, index }) => ( { + state: State = { hasError: false }; + + static getDerivedStateFromError(): State { + return { hasError: true }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo) { + // Message + component stack only — never log props/state, they can hold PII. + logger.error('render', `Unhandled render error: ${error.message}`, { + componentStack: info.componentStack, + }); + } + + private handleRetry = () => this.setState({ hasError: false }); + + render() { + if (this.state.hasError) { + return ( + + Something went wrong + + An unexpected error occurred. Your data is safe — try again. + + + Try Again + + + ); + } + return this.props.children; + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 24, + backgroundColor: '#ffffff', + }, + title: { + fontSize: 18, + fontWeight: '700', + color: '#1f2937', + marginBottom: 8, + }, + subtitle: { + fontSize: 14, + color: '#6b7280', + textAlign: 'center', + marginBottom: 24, + }, + button: { + // Literal on purpose: the crash screen must not depend on the theme + // system. Value matches theme/colors primary. + backgroundColor: '#e39546', + paddingHorizontal: 32, + paddingVertical: 12, + borderRadius: 8, + }, + buttonText: { + color: '#ffffff', + fontWeight: '600', + fontSize: 15, + }, +}); diff --git a/src/core/events/__tests__/appEvents.test.ts b/src/core/events/__tests__/appEvents.test.ts new file mode 100644 index 0000000..69818fd --- /dev/null +++ b/src/core/events/__tests__/appEvents.test.ts @@ -0,0 +1,36 @@ +import { appEvents } from '../appEvents'; + +describe('appEvents', () => { + it('delivers events to subscribers', () => { + const handler = jest.fn(); + const off = appEvents.on('auth/unauthorized', handler); + + appEvents.emit('auth/unauthorized', undefined); + expect(handler).toHaveBeenCalledTimes(1); + + off(); + }); + + it('stops delivering after unsubscribe', () => { + const handler = jest.fn(); + const off = appEvents.on('auth/logout', handler); + off(); + + appEvents.emit('auth/logout', undefined); + expect(handler).not.toHaveBeenCalled(); + }); + + it('supports multiple independent subscribers', () => { + const a = jest.fn(); + const b = jest.fn(); + const offA = appEvents.on('auth/logout', a); + const offB = appEvents.on('auth/logout', b); + + appEvents.emit('auth/logout', undefined); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + + offA(); + offB(); + }); +}); diff --git a/src/core/events/appEvents.ts b/src/core/events/appEvents.ts new file mode 100644 index 0000000..982447a --- /dev/null +++ b/src/core/events/appEvents.ts @@ -0,0 +1,33 @@ +/** + * Minimal typed event bus for cross-layer signals. + * + * Exists so infrastructure (HTTP client, socket) can announce facts + * ("session expired") without importing the store, navigation, or UI — + * dependencies must point inward, never outward. + */ + +type AppEventMap = { + /** Emitted when any request gets a 401 — the session is no longer valid. */ + 'auth/unauthorized': void; + /** Emitted when the user explicitly logs out. */ + 'auth/logout': void; +}; + +type EventKey = keyof AppEventMap; +type Handler = (payload: AppEventMap[K]) => void; + +// Internally untyped; the `on`/`emit` signatures enforce correctness at the edges. +const handlers = new Map void>>(); + +export const appEvents = { + on(event: K, handler: Handler): () => void { + const set = handlers.get(event) ?? new Set<(payload: never) => void>(); + handlers.set(event, set); + set.add(handler); + return () => set.delete(handler); + }, + + emit(event: K, payload: AppEventMap[K]): void { + handlers.get(event)?.forEach(handler => (handler as Handler)(payload)); + }, +}; diff --git a/src/core/http/AppError.ts b/src/core/http/AppError.ts new file mode 100644 index 0000000..c528047 --- /dev/null +++ b/src/core/http/AppError.ts @@ -0,0 +1,61 @@ +import { AxiosError } from 'axios'; + +/** Coarse-grained classification callers can branch on without HTTP knowledge. */ +export type AppErrorKind = + | 'network' + | 'timeout' + | 'unauthorized' + | 'forbidden' + | 'not_found' + | 'validation' + | 'server' + | 'unknown'; + +/** + * The one error type the data layer is allowed to throw. Normalizes axios / + * backend failures into something typed, so UI code never touches transport + * internals and React Query gets real rejections (enabling retry/isError). + */ +export class AppError extends Error { + readonly kind: AppErrorKind; + /** HTTP status when the server responded; undefined for network/timeout. */ + readonly statusCode?: number; + /** Parsed backend error body, when present. */ + readonly data?: unknown; + + constructor(kind: AppErrorKind, message: string, statusCode?: number, data?: unknown) { + super(message); + this.name = 'AppError'; + this.kind = kind; + this.statusCode = statusCode; + this.data = data; + } + + static kindFromStatus(status: number): AppErrorKind { + if (status === 401) {return 'unauthorized';} + if (status === 403) {return 'forbidden';} + if (status === 404) {return 'not_found';} + if (status >= 400 && status < 500) {return 'validation';} + if (status >= 500) {return 'server';} + return 'unknown'; + } + + static fromAxiosError(error: AxiosError): AppError { + if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { + return new AppError('timeout', 'The request timed out. Please try again.'); + } + const response = error.response; + if (!response) { + return new AppError('network', 'No response from server. Check your connection.'); + } + const body = response.data as { message?: string } | undefined; + return new AppError( + AppError.kindFromStatus(response.status), + body?.message || error.message || 'Request failed', + response.status, + response.data, + ); + } +} + +export const isAppError = (error: unknown): error is AppError => error instanceof AppError; diff --git a/src/core/http/__tests__/AppError.test.ts b/src/core/http/__tests__/AppError.test.ts new file mode 100644 index 0000000..107440f --- /dev/null +++ b/src/core/http/__tests__/AppError.test.ts @@ -0,0 +1,44 @@ +import { AxiosError } from 'axios'; +import { AppError, isAppError } from '../AppError'; + +describe('AppError', () => { + it('classifies HTTP statuses', () => { + expect(AppError.kindFromStatus(401)).toBe('unauthorized'); + expect(AppError.kindFromStatus(403)).toBe('forbidden'); + expect(AppError.kindFromStatus(404)).toBe('not_found'); + expect(AppError.kindFromStatus(422)).toBe('validation'); + expect(AppError.kindFromStatus(500)).toBe('server'); + expect(AppError.kindFromStatus(302)).toBe('unknown'); + }); + + it('maps timeouts', () => { + const err = { code: 'ECONNABORTED', message: 'timeout' } as AxiosError; + const appError = AppError.fromAxiosError(err); + expect(appError.kind).toBe('timeout'); + expect(appError.statusCode).toBeUndefined(); + }); + + it('maps network failures (no response)', () => { + const err = { message: 'Network Error' } as AxiosError; + const appError = AppError.fromAxiosError(err); + expect(appError.kind).toBe('network'); + }); + + it('maps server responses with body message', () => { + const err = { + message: 'Request failed with status code 400', + response: { status: 400, data: { message: 'Invalid bid amount', statusCode: 400 } }, + } as AxiosError; + const appError = AppError.fromAxiosError(err); + expect(appError.kind).toBe('validation'); + expect(appError.statusCode).toBe(400); + expect(appError.message).toBe('Invalid bid amount'); + expect(appError.data).toEqual({ message: 'Invalid bid amount', statusCode: 400 }); + }); + + it('isAppError narrows correctly', () => { + expect(isAppError(new AppError('unknown', 'x'))).toBe(true); + expect(isAppError(new Error('x'))).toBe(false); + expect(isAppError(null)).toBe(false); + }); +}); diff --git a/src/core/http/client.ts b/src/core/http/client.ts new file mode 100644 index 0000000..ba013ab --- /dev/null +++ b/src/core/http/client.ts @@ -0,0 +1,73 @@ +/** + * THE canonical HTTP client. Every network call in the app must eventually + * flow through {@link request}. + * + * Contract: + * - Success → resolves with the parsed response body (`response.data`). + * - Failure → THROWS a typed {@link AppError}. Never returns a sentinel. + * This restores TypeScript safety and lets React Query drive + * retry / isError / caching correctly. + * - 401 → emits `auth/unauthorized` on the event bus (the store + * subscribes and clears the session) and still throws. + * + * The legacy `apiCall` wrapper in src/Axios/Axios.ts adapts this client to + * the old `{success:false}` envelope for not-yet-migrated call sites. + */ +import axios, { AxiosRequestConfig } from 'axios'; +import { env } from '../config/env'; +import { appEvents } from '../events/appEvents'; +import { attachDevHttpLogging } from '../observability/httpLogging'; +import { AppError } from './AppError'; + +/** Reads the JWT lazily to avoid a static core → store dependency cycle. */ +type TokenProvider = () => string | null; +let tokenProvider: TokenProvider = () => null; + +export const setTokenProvider = (provider: TokenProvider): void => { + tokenProvider = provider; +}; + +export const httpClient = axios.create({ + baseURL: env.apiBaseUrl, + timeout: 30_000, + headers: { 'Content-Type': 'application/json' }, +}); + +attachDevHttpLogging(httpClient); + +httpClient.interceptors.request.use(config => { + const jwt = tokenProvider(); + if (jwt) { + // The eproc backend reads the non-standard `Authentication` header; the + // standard `Authorization` is sent as well so services behind normal + // gateways keep working. Consolidate on `Authorization` server-side. + config.headers.Authentication = `Bearer ${jwt}`; + config.headers.Authorization = `Bearer ${jwt}`; + } + return config; +}); + +httpClient.interceptors.response.use( + response => response, + error => { + if (error?.response?.status === 401) { + appEvents.emit('auth/unauthorized', undefined); + } + return Promise.reject( + axios.isAxiosError(error) + ? AppError.fromAxiosError(error) + : new AppError('unknown', error?.message ?? 'Unexpected error'), + ); + }, +); + +export type RequestConfig = Pick< + AxiosRequestConfig, + 'method' | 'url' | 'data' | 'params' | 'headers' | 'timeout' | 'responseType' +>; + +/** Typed request returning the response body, throwing AppError on failure. */ +export async function request(config: RequestConfig): Promise { + const response = await httpClient.request(config); + return response.data; +} diff --git a/src/core/observability/__tests__/redact.test.ts b/src/core/observability/__tests__/redact.test.ts new file mode 100644 index 0000000..d6ef14b --- /dev/null +++ b/src/core/observability/__tests__/redact.test.ts @@ -0,0 +1,79 @@ +import { redact, redactUrl } from '../redact'; + +describe('redact', () => { + it('masks credential and PII fields regardless of nesting', () => { + const input = { + emailAdd: 'user@example.com', + password: 'hunter2', + profile: { + aadhaarNumber: '1234 5678 9012', + panNumber: 'ABCDE1234F', + gstNumber: '22AAAAA0000A1Z5', + dob: '1990-01-01', + name: 'A User', + }, + otp: '123456', + token: 'eyJhbGciOi...', + authorization: 'Bearer abc', + authentication: 'Bearer abc', + bidAmount: 100000, + randomKeyGenerated: 'secret-aes-key', + }; + + const result = redact(input) as Record; + + expect(result.emailAdd).toBe('user@example.com'); + expect(result.password).toBe('[REDACTED]'); + expect(result.profile.aadhaarNumber).toBe('[REDACTED]'); + expect(result.profile.panNumber).toBe('[REDACTED]'); + expect(result.profile.gstNumber).toBe('[REDACTED]'); + expect(result.profile.dob).toBe('[REDACTED]'); + expect(result.profile.name).toBe('A User'); + expect(result.otp).toBe('[REDACTED]'); + expect(result.token).toBe('[REDACTED]'); + expect(result.authorization).toBe('[REDACTED]'); + expect(result.authentication).toBe('[REDACTED]'); + expect(result.bidAmount).toBe('[REDACTED]'); + expect(result.randomKeyGenerated).toBe('[REDACTED]'); + }); + + it('handles arrays and primitives', () => { + expect(redact('plain')).toBe('plain'); + expect(redact(42)).toBe(42); + expect(redact(null)).toBeNull(); + expect(redact(undefined)).toBeUndefined(); + const arr = redact([{ otp: '1' }, { safe: 'x' }]) as any[]; + expect(arr[0].otp).toBe('[REDACTED]'); + expect(arr[1].safe).toBe('x'); + }); + + it('does not mutate the input', () => { + const input = { password: 'secret' }; + redact(input); + expect(input.password).toBe('secret'); + }); + + it('summarizes non-plain objects instead of traversing them', () => { + const err = new Error('boom'); + expect(typeof redact(err)).toBe('string'); + }); + + it('stops at max depth', () => { + let deep: any = { value: 1 }; + for (let i = 0; i < 10; i++) {deep = { child: deep };} + expect(JSON.stringify(redact(deep))).toContain('[MaxDepth]'); + }); +}); + +describe('redactUrl', () => { + it('masks sensitive query params', () => { + expect(redactUrl('https://x.y/reset?newPassword=abc&user=1')).toBe( + 'https://x.y/reset?newPassword=[REDACTED]&user=1', + ); + expect(redactUrl('https://x.y/verify?otp=123456')).toBe('https://x.y/verify?otp=[REDACTED]'); + }); + + it('leaves other urls untouched', () => { + expect(redactUrl('https://x.y/list?page=2&size=10')).toBe('https://x.y/list?page=2&size=10'); + }); +}); diff --git a/src/core/observability/httpLogging.ts b/src/core/observability/httpLogging.ts new file mode 100644 index 0000000..bb13fcf --- /dev/null +++ b/src/core/observability/httpLogging.ts @@ -0,0 +1,89 @@ +/** + * DEV-only, PII-redacted HTTP tracing. Attachable to any axios instance + * (interceptors do not propagate from the default instance to created ones). + * Release builds register nothing, and transform-remove-console strips the + * underlying console calls as a second layer of defense. + */ +import type { AxiosInstance } from 'axios'; +import { logger } from './logger'; +import { redactUrl } from './redact'; + +// 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 redactUrl(full); +}; + +const attached = new WeakSet(); +const requestStartedAt = new WeakMap(); + +export const attachDevHttpLogging = (instance: AxiosInstance): void => { + if (!__DEV__ || attached.has(instance)) {return;} + attached.add(instance); + + instance.interceptors.request.use( + config => { + requestStartedAt.set(config, Date.now()); + const endpoint = buildFullUrl(config); + logger.debug('http', `→ ${(config.method || 'GET').toUpperCase()} ${buildFullUrl(config)}`, { + endpoint, + method: (config.method || 'GET').toUpperCase(), + params: config.params, + payload: config.data, + }); + return config; + }, + error => { + logger.error('http', `request setup failed: ${error?.message}`); + return Promise.reject(error); + }, + ); + + instance.interceptors.response.use( + response => { + const endpoint = buildFullUrl(response.config); + const startedAt = requestStartedAt.get(response.config); + logger.debug( + 'http', + `← ${response.status} ${(response.config.method || 'GET').toUpperCase()} ${endpoint}`, + { + endpoint, + method: (response.config.method || 'GET').toUpperCase(), + status: response.status, + durationMs: startedAt ? Date.now() - startedAt : undefined, + response: response.data, + }, + ); + return response; + }, + error => { + const cfg = error?.config || {}; + const endpoint = buildFullUrl(cfg); + const startedAt = requestStartedAt.get(cfg); + logger.warn( + 'http', + `✗ ${error?.response?.status ?? 'NETWORK'} ${(cfg.method || 'GET').toUpperCase()} ${endpoint}`, + { + endpoint, + method: (cfg.method || 'GET').toUpperCase(), + status: error?.response?.status ?? 'NETWORK', + durationMs: startedAt ? Date.now() - startedAt : undefined, + message: error?.response?.data?.message || error?.message, + response: error?.response?.data, + }, + ); + return Promise.reject(error); + }, + ); +}; diff --git a/src/core/observability/logger.ts b/src/core/observability/logger.ts new file mode 100644 index 0000000..c64d3c9 --- /dev/null +++ b/src/core/observability/logger.ts @@ -0,0 +1,66 @@ +/** + * Structured, environment-aware logger — the ONLY sanctioned log sink. + * + * - No-ops entirely outside `__DEV__` (and `transform-remove-console` strips + * the underlying console calls from release bundles as a second layer). + * - Every payload is passed through the PII redactor before printing. + * - Scoped so log lines are attributable: `logger.info('http', 'msg', data)`. + * + * Direct `console.*` usage in app code is an architecture violation; use this. + * A crash-reporting transport (Sentry/Crashlytics) can be added here later + * without touching call sites. + */ +import { redact } from './redact'; + +type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +/** Single switch for application and HTTP console output. + * Production is always forced off; use `logger.setEnabled(false)` in DEV to + * silence logs without changing call sites. */ +const ENABLE_CONSOLE_LOGS = __DEV__; +let loggingEnabled = ENABLE_CONSOLE_LOGS; + +type ConsoleMethod = (...args: unknown[]) => void; +const nativeConsole: Record = { + debug: console.log.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), +}; + +let consoleBridgeInstalled = false; + +const installConsoleBridge = (): void => { + if (!__DEV__ || consoleBridgeInstalled) return; + consoleBridgeInstalled = true; + + (Object.keys(nativeConsole) as LogLevel[]).forEach(level => { + const method = level === 'debug' ? 'log' : level; + (console as any)[method] = (...args: unknown[]) => { + if (!loggingEnabled) return; + nativeConsole[level](...args.map(value => redact(value))); + }; + }); +}; + +installConsoleBridge(); + +const emit = (level: LogLevel, scope: string, message: string, data?: unknown) => { + if (!loggingEnabled) {return;} + const line = `[${scope}] ${message}`; + const payload = data === undefined ? undefined : redact(data); + + const fn = level === 'debug' ? console.log : console[level]; + payload === undefined ? fn(line) : fn(line, payload); +}; + +export const logger = { + setEnabled: (enabled: boolean): void => { + loggingEnabled = __DEV__ && enabled; + }, + isEnabled: (): boolean => loggingEnabled, + debug: (scope: string, message: string, data?: unknown) => emit('debug', scope, message, data), + info: (scope: string, message: string, data?: unknown) => emit('info', scope, message, data), + warn: (scope: string, message: string, data?: unknown) => emit('warn', scope, message, data), + error: (scope: string, message: string, data?: unknown) => emit('error', scope, message, data), +}; diff --git a/src/core/observability/redact.ts b/src/core/observability/redact.ts new file mode 100644 index 0000000..5ca0531 --- /dev/null +++ b/src/core/observability/redact.ts @@ -0,0 +1,71 @@ +/** + * PII / secret redaction for anything that gets logged. + * + * ProcureLinc handles Aadhaar, PAN, GST, OTPs, passwords, JWTs and sealed-bid + * amounts. None of those may ever reach logcat/console — even in DEV builds a + * device log can be captured by other tooling. Every log sink must pass data + * through {@link redact} first. + */ + +const SENSITIVE_KEY_PATTERN = + /pass(word)?|otp|aadha?ar|pan(number|no)?|gst|token|authori[sz]ation|authentication|secret|pin\b|cvv|dob|dateofbirth|account(number|no)|ifsc|randomkey|encrypted|signature|bidamount|bidvalue|fieldvalue|officerpublickey|publickey|option|struser/i; + +const MASK = '[REDACTED]'; +const MAX_DEPTH = 6; + +const isPlainObject = (val: unknown): val is Record => + typeof val === 'object' && + val !== null && + (Object.getPrototypeOf(val) === Object.prototype || Object.getPrototypeOf(val) === null); + +/** + * Returns a deep copy of `value` with every sensitive field masked. + * Non-plain objects (FormData, class instances, errors) are summarized rather + * than traversed so nothing leaks through exotic prototypes. + */ +export function redact(value: unknown, depth: number = 0): unknown { + if (value == null) {return value;} + if (depth > MAX_DEPTH) {return '[MaxDepth]';} + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (Array.isArray(value)) { + return value.map(item => redact(item, depth + 1)); + } + + if (typeof ArrayBuffer !== 'undefined' && value instanceof ArrayBuffer) { + return `[ArrayBuffer bytes: ${value.byteLength}]`; + } + + if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(value)) { + return `[${value.constructor.name} bytes: ${value.byteLength}]`; + } + + if (typeof FormData !== 'undefined' && value instanceof FormData) { + const parts = (value as any)?._parts; + return Array.isArray(parts) + ? `[FormData fields: ${parts.map((p: any) => p?.[0]).join(', ')}]` + : '[FormData]'; + } + + if (isPlainObject(value)) { + const out: Record = {}; + for (const key of Object.keys(value)) { + out[key] = SENSITIVE_KEY_PATTERN.test(key) ? MASK : redact(value[key], depth + 1); + } + return out; + } + + // Errors, axios headers instances, etc. — summarize, never traverse. + return `[${(value as object).constructor?.name ?? typeof value}]`; +} + +/** Masks credentials embedded in a URL query string (e.g. ?newPassword=...). */ +export function redactUrl(url: string): string { + return url.replace( + /([?&])([^=&]*(?:pass(?:word)?|otp|token|aadha?ar|pan|gst|pin)[^=&]*)=([^&]*)/gi, + `$1$2=${MASK}`, + ); +} diff --git a/src/core/platform/polyfills.ts b/src/core/platform/polyfills.ts new file mode 100644 index 0000000..9a91c06 --- /dev/null +++ b/src/core/platform/polyfills.ts @@ -0,0 +1,15 @@ +/** + * Global polyfills required before any other module executes. + * Imported FIRST in index.js — nothing else may depend on load order. + * + * - Buffer: required by the sealed-bid crypto path (crypto-js interop). + * - TextEncoder: required by @stomp/stompjs over SockJS. + */ +import { Buffer } from 'buffer'; +import { TextEncoder, TextDecoder } from 'text-encoding'; + +const g = globalThis as any; + +if (typeof g.Buffer === 'undefined') {g.Buffer = Buffer;} +if (typeof g.TextEncoder === 'undefined') {g.TextEncoder = TextEncoder;} +if (typeof g.TextDecoder === 'undefined') {g.TextDecoder = TextDecoder;} diff --git a/src/core/query/queryClient.ts b/src/core/query/queryClient.ts new file mode 100644 index 0000000..c904ac4 --- /dev/null +++ b/src/core/query/queryClient.ts @@ -0,0 +1,23 @@ +import { QueryClient } from '@tanstack/react-query'; + +/** + * Module-level singleton — constructing a QueryClient inside a component body + * discards the entire cache on every re-render. + * + * Defaults: lists/details tolerate 30s of staleness; live bid state arrives + * over STOMP, not polling, so this kills refetch storms without hurting + * real-time correctness. Override per-query where fresher data matters. + */ +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + gcTime: 5 * 60_000, + retry: 2, + refetchOnWindowFocus: false, + }, + mutations: { + retry: 0, + }, + }, +}); diff --git a/src/core/storage/__tests__/secureStorage.test.ts b/src/core/storage/__tests__/secureStorage.test.ts new file mode 100644 index 0000000..2d4c3f5 --- /dev/null +++ b/src/core/storage/__tests__/secureStorage.test.ts @@ -0,0 +1,120 @@ +import * as Keychain from 'react-native-keychain'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { secureStorage } from '../secureStorage'; + +jest.mock('react-native-keychain', () => ({ + getGenericPassword: jest.fn(), + setGenericPassword: jest.fn(), + resetGenericPassword: jest.fn(), +})); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), +})); + +const kcGet = Keychain.getGenericPassword as jest.Mock; +const kcSet = Keychain.setGenericPassword as jest.Mock; +const kcReset = Keychain.resetGenericPassword as jest.Mock; +const asGet = AsyncStorage.getItem as unknown as jest.Mock; +const asSet = AsyncStorage.setItem as unknown as jest.Mock; + +const SERVICE = { service: 'com.procure.secure.persist:token' }; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('secureStorage.getItem', () => { + it('returns the keychain value and never touches legacy storage when present', async () => { + kcGet.mockResolvedValue({ username: 'persist:token', password: 'keychain-value', service: '' }); + + const result = await secureStorage.getItem('persist:token'); + + expect(result).toBe('keychain-value'); + expect(kcGet).toHaveBeenCalledWith(SERVICE); + expect(asGet).not.toHaveBeenCalled(); // no migration attempted + }); + + it('returns null (not throw) when the keychain read fails', async () => { + kcGet.mockRejectedValue(new Error('keystore unavailable')); + expect(await secureStorage.getItem('persist:token')).toBeNull(); + }); +}); + +describe('secureStorage legacy → keychain migration', () => { + const session = { token: 'jwt-abc', userType: 'tender' }; + + // redux-persist root stores each slice as ONE JSON string. + const legacyRoot = () => + JSON.stringify({ + token: JSON.stringify({ token: session }), + theme: '"system"', + _persist: '{"version":-1,"rehydrated":true}', + }); + + it('lifts the token slice into the keychain in nested-persist shape and scrubs the plaintext copy', async () => { + kcGet.mockResolvedValue(false); // nothing in keychain yet + asGet.mockResolvedValue(legacyRoot()); + + const result = await secureStorage.getItem('persist:token'); + + // Nested persist shape: each field of the slice state individually stringified. + const expectedPayload = JSON.stringify({ token: JSON.stringify(session) }); + expect(result).toBe(expectedPayload); + + // Written into the keychain under the namespaced service. + expect(kcSet).toHaveBeenCalledWith('persist:token', expectedPayload, SERVICE); + + // Plaintext token scrubbed from persist:root; unrelated slices preserved. + expect(asSet).toHaveBeenCalledTimes(1); + const [rootKey, rewritten] = asSet.mock.calls[0]; + expect(rootKey).toBe('persist:root'); + const parsed = JSON.parse(rewritten); + expect(parsed.token).toBeUndefined(); + expect(parsed.theme).toBe('"system"'); + expect(parsed._persist).toBe('{"version":-1,"rehydrated":true}'); + }); + + it('returns null when there is no legacy root to migrate', async () => { + kcGet.mockResolvedValue(false); + asGet.mockResolvedValue(null); + + expect(await secureStorage.getItem('persist:token')).toBeNull(); + expect(kcSet).not.toHaveBeenCalled(); + expect(asSet).not.toHaveBeenCalled(); + }); + + it('returns null when the slice is absent from the legacy root', async () => { + kcGet.mockResolvedValue(false); + asGet.mockResolvedValue(JSON.stringify({ theme: '"dark"' })); + + expect(await secureStorage.getItem('persist:token')).toBeNull(); + expect(kcSet).not.toHaveBeenCalled(); + }); + + it('returns null (not throw) when the legacy root is corrupt JSON', async () => { + kcGet.mockResolvedValue(false); + asGet.mockResolvedValue('{not valid json'); + + expect(await secureStorage.getItem('persist:token')).toBeNull(); + }); +}); + +describe('secureStorage.setItem / removeItem', () => { + it('writes to the namespaced keychain service', async () => { + await secureStorage.setItem('persist:token', 'payload'); + expect(kcSet).toHaveBeenCalledWith('persist:token', 'payload', SERVICE); + }); + + it('resets the namespaced keychain service', async () => { + await secureStorage.removeItem('persist:token'); + expect(kcReset).toHaveBeenCalledWith(SERVICE); + }); + + it('swallows keychain write failures (never throws into redux-persist)', async () => { + kcSet.mockRejectedValue(new Error('boom')); + await expect(secureStorage.setItem('persist:token', 'x')).resolves.toBeUndefined(); + }); +}); diff --git a/src/core/storage/secureStorage.ts b/src/core/storage/secureStorage.ts new file mode 100644 index 0000000..8c87570 --- /dev/null +++ b/src/core/storage/secureStorage.ts @@ -0,0 +1,88 @@ +/** + * Hardware-backed secure storage for the auth session. + * + * Exposes the redux-persist `Storage` interface but writes through + * react-native-keychain (Android Keystore / iOS Keychain) instead of plaintext + * AsyncStorage. Used exclusively by the `token` slice's nested persist config. + * + * Includes a one-time lazy migration: existing installs have the session + * inside the legacy plaintext `persist:root` AsyncStorage entry. On first read + * we lift it into the keychain and scrub it from AsyncStorage so upgrading + * users stay logged in without leaving PII at rest in the clear. + */ +import * as Keychain from 'react-native-keychain'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { logger } from '../observability/logger'; + +const SERVICE_PREFIX = 'com.procure.secure.'; +const LEGACY_ROOT_KEY = 'persist:root'; + +const serviceFor = (key: string) => `${SERVICE_PREFIX}${key}`; + +/** + * Lifts the token slice out of the legacy plaintext root persist entry. + * Returns the value in the shape redux-persist expects for `persist:token`, + * or null when there is nothing to migrate. + */ +const migrateFromLegacyRoot = async (key: string): Promise => { + try { + const rawRoot = await AsyncStorage.getItem(LEGACY_ROOT_KEY); + if (!rawRoot) {return null;} + + const root = JSON.parse(rawRoot); + const sliceName = key.replace(/^persist:/, ''); + const serializedSlice = root?.[sliceName]; + if (typeof serializedSlice !== 'string') {return null;} + + // Re-shape: root stores the whole slice state as one string; the nested + // persist stores each top-level field of the slice state as a string. + const sliceState = JSON.parse(serializedSlice); + const migrated: Record = {}; + for (const field of Object.keys(sliceState)) { + migrated[field] = JSON.stringify(sliceState[field]); + } + const payload = JSON.stringify(migrated); + + await Keychain.setGenericPassword(key, payload, { service: serviceFor(key) }); + + // Scrub the plaintext copy so the token no longer exists outside keychain. + delete root[sliceName]; + await AsyncStorage.setItem(LEGACY_ROOT_KEY, JSON.stringify(root)); + + logger.info('storage', `migrated ${sliceName} from plaintext persist:root to keychain`); + return payload; + } catch (error) { + logger.error('storage', 'legacy session migration failed', { error: String(error) }); + return null; + } +}; + +/** redux-persist compatible storage backed by the device keychain/keystore. */ +export const secureStorage = { + async getItem(key: string): Promise { + try { + const credentials = await Keychain.getGenericPassword({ service: serviceFor(key) }); + if (credentials) {return credentials.password;} + return await migrateFromLegacyRoot(key); + } catch (error) { + logger.error('storage', `secure read failed for ${key}`, { error: String(error) }); + return null; + } + }, + + async setItem(key: string, value: string): Promise { + try { + await Keychain.setGenericPassword(key, value, { service: serviceFor(key) }); + } catch (error) { + logger.error('storage', `secure write failed for ${key}`, { error: String(error) }); + } + }, + + async removeItem(key: string): Promise { + try { + await Keychain.resetGenericPassword({ service: serviceFor(key) }); + } catch (error) { + logger.error('storage', `secure delete failed for ${key}`, { error: String(error) }); + } + }, +}; diff --git a/src/socket/Socket.tsx b/src/socket/Socket.tsx index c755536..99b31ce 100644 --- a/src/socket/Socket.tsx +++ b/src/socket/Socket.tsx @@ -1,14 +1,20 @@ import { useEffect, useReducer, useRef, useCallback } from 'react'; import { Client, IMessage } from '@stomp/stompjs'; import SockJS from 'sockjs-client'; -import axios from 'axios'; -import Variables from '../Constant/Variable'; -import { showErrorAlert, showSuccessAlert } from '../Constant/ShowDailog'; -import { useSelector } from 'react-redux'; - +import { env } from '../core/config/env'; +import { request } from '../core/http/client'; +import { isAppError } from '../core/http/AppError'; +import { logger } from '../core/observability/logger'; type SubscriptionCallback = (message: any) => void; +export type PlaceBidResult = { + data: any; + status: boolean; + /** Backend message for the user; the CALLER decides how to present it. */ + message?: string; +}; + type State = { client: Client | null; subscriptions: Map; @@ -48,11 +54,24 @@ const reducer = (state: State, action: Action): State => { } }; +/** + * STOMP-over-SockJS live-bidding service. + * + * Rules for consumers: + * - `subscribe` MUST be called from a `useEffect`, never the render body. + * - Call `disconnect()` in the effect cleanup — leaked clients reconnect + * every 5s with 1s heartbeats and drain battery/data indefinitely. + * - This service never touches UI (no alerts, no navigation). It returns + * data; presentation is the screen's job. + * + * Auth: REST calls go through core/http, which injects the current JWT per + * request — no more stale token captured at hook-init time. + */ export const useWebSocketService = ( webSocketUrl: string, onConnectCallback: () => void, onErrorCallback: (error: string) => void, - Token: any) => { +) => { const [state, dispatch] = useReducer(reducer, { client: null, subscriptions: new Map(), @@ -61,7 +80,6 @@ export const useWebSocketService = ( const clientRef = useRef(null); const isConnected = useRef(false); - // ✅ QUEUE MUST BE INSIDE HOOK const pendingSubscriptions = useRef< { destination: string; callback: SubscriptionCallback }[] >([]); @@ -72,7 +90,7 @@ export const useWebSocketService = ( // ================= CONNECT ================= const connect = useCallback(() => { - if (state.client || isConnected.current) return; + if (state.client || isConnected.current) {return;} const client = new Client({ webSocketFactory: () => new SockJS(webSocketUrl), @@ -80,13 +98,12 @@ export const useWebSocketService = ( heartbeatIncoming: 1000, heartbeatOutgoing: 1000, - debug: str => console.log('STOMP:', str), + debug: __DEV__ ? str => logger.debug('stomp', str) : () => {}, onConnect: () => { isConnected.current = true; - // console.log('✅ WebSocket connected'); - // 🔁 Flush queued subscriptions + // Flush queued subscriptions pendingSubscriptions.current.forEach(sub => subscribe(sub.destination, sub.callback) ); @@ -96,12 +113,13 @@ export const useWebSocketService = ( }, onStompError: frame => { - onErrorCallback(frame.headers['message'] || 'STOMP Error'); + onErrorCallback(frame.headers.message || 'STOMP Error'); }, }); client.activate(); dispatch({ type: 'SET_CLIENT', payload: client }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.client, webSocketUrl, onConnectCallback, onErrorCallback]); // ================= SUBSCRIBE ================= @@ -109,23 +127,26 @@ export const useWebSocketService = ( (destination: string, callback: SubscriptionCallback) => { const client = clientRef.current; - // 🧠 Queue if not connected + // Queue if not connected — replacing any queued callback for the same + // destination so repeated calls can't register duplicates on connect. if (!client || !isConnected.current) { - // console.log('⏳ Queued subscription:', destination); - pendingSubscriptions.current.push({ destination, callback }); + pendingSubscriptions.current = [ + ...pendingSubscriptions.current.filter(s => s.destination !== destination), + { destination, callback }, + ]; return; } - if (state.subscriptions.has(destination)) return; + if (state.subscriptions.has(destination)) {return;} const subscription = client.subscribe(destination, (message: IMessage) => { - if (!message.body) return; + if (!message.body) {return;} try { const parsed = JSON.parse(message.body); callback(parsed); } catch (e) { - console.error('❌ JSON parse error', e); + logger.error('stomp', 'failed to parse message body', { error: String(e) }); } }); @@ -140,7 +161,7 @@ export const useWebSocketService = ( // ================= SEND ================= const send = useCallback((destination: string, body: Record = {}) => { const client = clientRef.current; - if (!client || !isConnected.current) return; + if (!client || !isConnected.current) {return;} client.publish({ destination, @@ -150,19 +171,16 @@ export const useWebSocketService = ( // ================= ROOM ================= const createRoom = async (roomId: any) => { - if (!roomId) return; + if (!roomId) {return;} try { - const res = await axios.post( - `${Variables.socketUrl}livebidding/createRoom`, - null, - { - params: { roomId }, - headers: { Authentication: `Bearer ${Token}` }, - } - ); + const res = await request({ + method: 'post', + url: `${env.socketBaseUrl}livebidding/createRoom`, + params: { roomId }, + }); - if (res.data.statusCode === 200) { + if (res.statusCode === 200) { connect(); return joinRoom(roomId); } @@ -173,44 +191,47 @@ export const useWebSocketService = ( }; const joinRoom = async (roomId: any) => { - try { - const { data } = await axios.get( - `${Variables.socketUrl}livebidding/joinRoom?roomId=${roomId}`, - { headers: { Authentication: `Bearer ${Token}` } } - ); + const data = await request({ + method: 'get', + url: `${env.socketBaseUrl}livebidding/joinRoom`, + params: { roomId }, + }); - if (data.statusCode === 200) return data.data.bids; + if (data.statusCode === 200) {return data.data.bids;} } catch (e) { - console.error('Join room error:', e); + logger.error('stomp', 'join room failed', { error: String(e) }); } }; // ================= BID ================= - const placeBid = async (BidDTO: any) => { - + const placeBid = async (BidDTO: any): Promise => { try { - const res = await axios.post( - `${Variables.socketUrl}livebidding/placeBid`, - BidDTO, - { headers: { Authentication: `Bearer ${Token}` } } - ); + const res = await request({ + method: 'post', + url: `${env.socketBaseUrl}livebidding/placeBid`, + data: BidDTO, + }); - if (res.data.statusCode === 200) { - showSuccessAlert(res.data.message); - return { data: res.data.data, status: true, message: res.data.message }; + if (res.statusCode === 200) { + return { data: res.data, status: true, message: res.message }; } - } catch (err: any) { - if (err?.response?.data?.statusCode === 400) { - showErrorAlert(err.response.data.message); - } - return { data: {}, status: false }; + return { data: {}, status: false, message: res.message }; + } catch (err) { + return { + data: {}, + status: false, + message: isAppError(err) && err.statusCode === 400 ? err.message : undefined, + }; } }; // ================= UNSUBSCRIBE ================= const unsubscribe = useCallback( (destination: string) => { + pendingSubscriptions.current = pendingSubscriptions.current.filter( + s => s.destination !== destination, + ); const sub = state.subscriptions.get(destination); if (sub) { sub.unsubscribe(); @@ -224,7 +245,10 @@ export const useWebSocketService = ( const disconnect = useCallback(() => { const client = clientRef.current; - if (client && isConnected.current) { + // Deactivate even mid-connect: an activating client left behind keeps + // reconnecting forever. + pendingSubscriptions.current = []; + if (client) { state.subscriptions.forEach(sub => sub.unsubscribe()); client.deactivate(); isConnected.current = false;