Compare commits
1
Commits
main
...
feature-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95d8e0a925 |
@@ -2,7 +2,6 @@ import React from 'react';
|
|||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
import { View } from 'react-native';
|
import { View } from 'react-native';
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { AlertNotificationRoot } from 'react-native-alert-notification';
|
|
||||||
import { PersistGate } from 'redux-persist/integration/react';
|
import { PersistGate } from 'redux-persist/integration/react';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { persistor, store } from './src/Redux/Store';
|
import { persistor, store } from './src/Redux/Store';
|
||||||
@@ -11,29 +10,35 @@ import { NavigationContainer } from '@react-navigation/native';
|
|||||||
import colors from './src/Constant/Color';
|
import colors from './src/Constant/Color';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import AppStatusBar from './src/components/AppStatusBar';
|
import AppStatusBar from './src/components/AppStatusBar';
|
||||||
|
import { ApiToastBridge, AppToastHost } from './src/components/AppToast';
|
||||||
import { ErrorBoundary } from './src/core/errors/ErrorBoundary';
|
import { ErrorBoundary } from './src/core/errors/ErrorBoundary';
|
||||||
import { queryClient } from './src/core/query/queryClient';
|
import { queryClient } from './src/core/query/queryClient';
|
||||||
import AuthSessionGuard from './src/Navigation/AuthSessionGuard';
|
import AuthSessionGuard from './src/Navigation/AuthSessionGuard';
|
||||||
import { navigationRef } from './src/Navigation/NavigationService';
|
import { navigationRef } from './src/Navigation/NavigationService';
|
||||||
|
import { applyGuardForCurrentRoute } from './src/core/security/screenGuard';
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<NavigationContainer ref={navigationRef}>
|
<NavigationContainer
|
||||||
|
ref={navigationRef}
|
||||||
|
onReady={applyGuardForCurrentRoute}
|
||||||
|
onStateChange={applyGuardForCurrentRoute}
|
||||||
|
>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<AlertNotificationRoot>
|
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<PersistGate persistor={persistor}>
|
<PersistGate persistor={persistor}>
|
||||||
<View style={{ flex: 1, backgroundColor: colors.white, paddingBottom: insets.bottom }}>
|
<View style={{ flex: 1, backgroundColor: colors.white, paddingBottom: insets.bottom }}>
|
||||||
<AppStatusBar />
|
<AppStatusBar />
|
||||||
<AuthSessionGuard />
|
<AuthSessionGuard />
|
||||||
<Routes />
|
<Routes />
|
||||||
|
<ApiToastBridge />
|
||||||
|
<AppToastHost />
|
||||||
</View>
|
</View>
|
||||||
</PersistGate>
|
</PersistGate>
|
||||||
</Provider>
|
</Provider>
|
||||||
</AlertNotificationRoot>
|
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
|
|||||||
@@ -11,9 +11,14 @@ class MainActivity : ReactActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
// FLAG_SECURE: the app displays sealed-bid amounts, Aadhaar OTPs and other
|
// FLAG_SECURE fail-closed baseline: the app displays sealed-bid amounts,
|
||||||
// regulated PII. Block screenshots, screen recording and the app-switcher
|
// Aadhaar OTPs and other regulated PII. Release builds start fully secured so
|
||||||
// thumbnail in release builds; debug builds stay capturable for QA.
|
// nothing leaks before JS boots (or if the ScreenGuard bridge fails to load).
|
||||||
|
// Once running, per-screen protection is driven from JS — the central
|
||||||
|
// controller (core/security/screenGuard.ts, wired via NavigationContainer)
|
||||||
|
// CLEARS this flag for non-sensitive screens and re-applies it for the
|
||||||
|
// protected ones. Debug builds start capturable for QA and are likewise
|
||||||
|
// toggled per screen by the same controller.
|
||||||
if (!BuildConfig.DEBUG) {
|
if (!BuildConfig.DEBUG) {
|
||||||
window.setFlags(
|
window.setFlags(
|
||||||
WindowManager.LayoutParams.FLAG_SECURE,
|
WindowManager.LayoutParams.FLAG_SECURE,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.facebook.react.defaults.DefaultReactNativeHost
|
|||||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||||
import com.facebook.soloader.SoLoader
|
import com.facebook.soloader.SoLoader
|
||||||
import com.procure.excel.ExcelPackage
|
import com.procure.excel.ExcelPackage
|
||||||
|
import com.procure.security.ScreenGuardPackage
|
||||||
|
|
||||||
class MainApplication : Application(), ReactApplication {
|
class MainApplication : Application(), ReactApplication {
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ class MainApplication : Application(), ReactApplication {
|
|||||||
override fun getPackages(): List<ReactPackage> =
|
override fun getPackages(): List<ReactPackage> =
|
||||||
PackageList(this).packages.apply {
|
PackageList(this).packages.apply {
|
||||||
add(ExcelPackage())
|
add(ExcelPackage())
|
||||||
|
add(ScreenGuardPackage())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getJSMainModuleName(): String = "index"
|
override fun getJSMainModuleName(): String = "index"
|
||||||
|
|||||||
@@ -317,7 +317,13 @@ class ExcelModule(private val reactContext: ReactApplicationContext) :
|
|||||||
for (i in 0 until edits.length()) {
|
for (i in 0 until edits.length()) {
|
||||||
applyEdit(wb, edits.getJSONObject(i))
|
applyEdit(wb, edits.getJSONObject(i))
|
||||||
}
|
}
|
||||||
// Ask consumers (Excel) to recalculate; HyperFormula already did in-app.
|
// Recompute every formula so its CACHED <v> reflects the new inputs.
|
||||||
|
// Excel/LibreOffice would recalc on open, but cache-only consumers
|
||||||
|
// (the web BOQ viewer reads the stored value and never recalculates)
|
||||||
|
// otherwise see the template's stale result. Per-cell + try/catch so a
|
||||||
|
// single POI-unsupported function can't blank the whole workbook.
|
||||||
|
recalculateFormulas(wb)
|
||||||
|
// Belt-and-suspenders for recalc-capable consumers.
|
||||||
wb.setForceFormulaRecalculation(true)
|
wb.setForceFormulaRecalculation(true)
|
||||||
|
|
||||||
val bos = ByteArrayOutputStream()
|
val bos = ByteArrayOutputStream()
|
||||||
@@ -329,6 +335,29 @@ class ExcelModule(private val reactContext: ReactApplicationContext) :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate all formula cells in-place so their cached results are written to
|
||||||
|
* the file. Runs per-cell in a try/catch: POI cannot evaluate every Excel
|
||||||
|
* function, and a NotImplemented failure on one cell must not abort the save
|
||||||
|
* or clear the other results.
|
||||||
|
*/
|
||||||
|
private fun recalculateFormulas(wb: Workbook) {
|
||||||
|
val evaluator = wb.creationHelper.createFormulaEvaluator()
|
||||||
|
for (sheet in wb) {
|
||||||
|
for (row in sheet) {
|
||||||
|
for (cell in row) {
|
||||||
|
if (cell.cellType == CellType.FORMULA) {
|
||||||
|
try {
|
||||||
|
evaluator.evaluateFormulaCell(cell)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
/* leave this cell's cached value as-is */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun applyEdit(wb: Workbook, edit: JSONObject) {
|
private fun applyEdit(wb: Workbook, edit: JSONObject) {
|
||||||
val sheetIndex = edit.getInt("sheet")
|
val sheetIndex = edit.getInt("sheet")
|
||||||
if (sheetIndex < 0 || sheetIndex >= wb.numberOfSheets) return
|
if (sheetIndex < 0 || sheetIndex >= wb.numberOfSheets) return
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.procure.security
|
||||||
|
|
||||||
|
import android.view.WindowManager
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
|
import com.facebook.react.bridge.ReactMethod
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScreenGuard — runtime FLAG_SECURE toggle.
|
||||||
|
*
|
||||||
|
* FLAG_SECURE blocks screenshots, screen recording and the app-switcher
|
||||||
|
* thumbnail for the whole Activity window. The app is single-Activity, so
|
||||||
|
* flipping the flag as the user navigates gives per-screen protection. WHICH
|
||||||
|
* screens are protected lives entirely in JS (see core/security/screenGuard.ts);
|
||||||
|
* this module only applies the decision. Window flags must be mutated on the UI
|
||||||
|
* thread.
|
||||||
|
*/
|
||||||
|
class ScreenGuardModule(private val reactContext: ReactApplicationContext) :
|
||||||
|
ReactContextBaseJavaModule(reactContext) {
|
||||||
|
|
||||||
|
override fun getName(): String = "ScreenGuard"
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun setSecure(enable: Boolean) {
|
||||||
|
val activity = currentActivity ?: return
|
||||||
|
activity.runOnUiThread {
|
||||||
|
val window = activity.window ?: return@runOnUiThread
|
||||||
|
if (enable) {
|
||||||
|
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
} else {
|
||||||
|
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.procure.security
|
||||||
|
|
||||||
|
import com.facebook.react.ReactPackage
|
||||||
|
import com.facebook.react.bridge.NativeModule
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.uimanager.ViewManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers [ScreenGuardModule] with React Native. Added manually in
|
||||||
|
* MainApplication.getPackages(); accessible from JS via NativeModules.ScreenGuard.
|
||||||
|
*/
|
||||||
|
class ScreenGuardPackage : ReactPackage {
|
||||||
|
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
||||||
|
listOf(ScreenGuardModule(reactContext))
|
||||||
|
|
||||||
|
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
@@ -133,10 +133,11 @@ These look like leftovers but were left deliberately. Read before "fixing".
|
|||||||
- **`ResetPassword`** sends the new password as a URL query param. Migrated to
|
- **`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
|
`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.
|
backend contract — see the `TODO(backend)` at the call site.
|
||||||
- **`PurchaseTenderTile` "details" tap** navigates to `'TenderDetail'`, an
|
- **`PurchaseTenderTile` "details" tap** (in `PurchaseTenderAllList`) now
|
||||||
unregistered route (a pre-existing latent bug). The intended destination is
|
navigates to the registered `LiveTenderDetail` route, passing the flat tender
|
||||||
ambiguous (the tile already renders an in-tile modal), so it awaits a product
|
`item` that screen reads from `route.params` — matching `LiveTenderTile`'s
|
||||||
decision rather than a guessed target.
|
working pattern. It previously targeted an unregistered `'TenderDetail'` route
|
||||||
|
(a latent bug that no-op'd with a dev warning).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Generated
-12
@@ -37,7 +37,6 @@
|
|||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-native": "0.79.0",
|
"react-native": "0.79.0",
|
||||||
"react-native-alert-notification": "0.4.2",
|
|
||||||
"react-native-app-intro-slider": "4.0.4",
|
"react-native-app-intro-slider": "4.0.4",
|
||||||
"react-native-blob-util": "0.24.6",
|
"react-native-blob-util": "0.24.6",
|
||||||
"react-native-bouncy-checkbox": "4.1.2",
|
"react-native-bouncy-checkbox": "4.1.2",
|
||||||
@@ -12831,17 +12830,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-native-alert-notification": {
|
|
||||||
"version": "0.4.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-native-alert-notification/-/react-native-alert-notification-0.4.2.tgz",
|
|
||||||
"integrity": "sha512-98EQ+DtI7thEAHzoXMbN8dZRCFcoGXMRYYIKvooPPdASuuZIuON4oxCbJNDb6r6lebnPc/25JCD1tJPaoPNWnw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "*",
|
|
||||||
"react-native": ">=0.62.0",
|
|
||||||
"react-native-safe-area-context": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-native-animatable": {
|
"node_modules/react-native-animatable": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-animatable/-/react-native-animatable-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-animatable/-/react-native-animatable-1.4.0.tgz",
|
||||||
|
|||||||
+2
-3
@@ -22,16 +22,16 @@
|
|||||||
"@react-navigation/native-stack": "^7.17.3",
|
"@react-navigation/native-stack": "^7.17.3",
|
||||||
"@react-navigation/stack": "7.6.13",
|
"@react-navigation/stack": "7.6.13",
|
||||||
"@reduxjs/toolkit": "2.11.2",
|
"@reduxjs/toolkit": "2.11.2",
|
||||||
|
"@shopify/flash-list": "^1.7.6",
|
||||||
"@stomp/stompjs": "7.2.1",
|
"@stomp/stompjs": "7.2.1",
|
||||||
"@tanstack/react-query": "5.90.12",
|
"@tanstack/react-query": "5.90.12",
|
||||||
"@shopify/flash-list": "^1.7.6",
|
|
||||||
"axios": "1.13.2",
|
"axios": "1.13.2",
|
||||||
"buffer": "6.0.3",
|
"buffer": "6.0.3",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"exceljs": "^3.4.0",
|
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"eslint-plugin-react-native": "5.0.0",
|
"eslint-plugin-react-native": "5.0.0",
|
||||||
|
"exceljs": "^3.4.0",
|
||||||
"formik": "2.4.9",
|
"formik": "2.4.9",
|
||||||
"hyperformula": "^2.7.1",
|
"hyperformula": "^2.7.1",
|
||||||
"jsencrypt": "^3.5.4",
|
"jsencrypt": "^3.5.4",
|
||||||
@@ -40,7 +40,6 @@
|
|||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-native": "0.79.0",
|
"react-native": "0.79.0",
|
||||||
"react-native-alert-notification": "0.4.2",
|
|
||||||
"react-native-app-intro-slider": "4.0.4",
|
"react-native-app-intro-slider": "4.0.4",
|
||||||
"react-native-blob-util": "0.24.6",
|
"react-native-blob-util": "0.24.6",
|
||||||
"react-native-bouncy-checkbox": "4.1.2",
|
"react-native-bouncy-checkbox": "4.1.2",
|
||||||
|
|||||||
@@ -24,5 +24,7 @@ const ImagePath = {
|
|||||||
LightBg: require('../Images/Backgrounds/lightBackground.png'),
|
LightBg: require('../Images/Backgrounds/lightBackground.png'),
|
||||||
DarkBg2: require('../Images/Backgrounds/darkBackground2.png'),
|
DarkBg2: require('../Images/Backgrounds/darkBackground2.png'),
|
||||||
LightBg2: require('../Images/Backgrounds/lightBackground2.png'),
|
LightBg2: require('../Images/Backgrounds/lightBackground2.png'),
|
||||||
|
// Home screen backdrop (cream node-constellation art). Light theme only.
|
||||||
|
HomeBg: require('../Images/Backgrounds/homeBackground.png'),
|
||||||
}
|
}
|
||||||
export default ImagePath;
|
export default ImagePath;
|
||||||
@@ -1,26 +1,13 @@
|
|||||||
import { ALERT_TYPE, Toast } from 'react-native-alert-notification';
|
import { showAppToast } from '../components/AppToast';
|
||||||
|
|
||||||
const showSuccessAlert = (text: string) => {
|
const showSuccessAlert = (text: string) => {
|
||||||
Toast.show({
|
showAppToast({ type: 'success', message: text });
|
||||||
type: ALERT_TYPE.SUCCESS,
|
|
||||||
title: 'Success',
|
|
||||||
textBody: text,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const showErrorAlert = (text: string) => {
|
const showErrorAlert = (text: string) => {
|
||||||
Toast.show({
|
showAppToast({ type: 'error', message: text });
|
||||||
type: ALERT_TYPE.DANGER,
|
|
||||||
title: 'Error',
|
|
||||||
textBody: text,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const showToast = (text: string) => {
|
const showToast = showSuccessAlert;
|
||||||
Toast.show({
|
|
||||||
type: ALERT_TYPE.SUCCESS,
|
export { showErrorAlert, showSuccessAlert, showToast };
|
||||||
title: 'Success',
|
|
||||||
textBody: text,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
export { showErrorAlert, showSuccessAlert, showToast }
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Platform, ToastAndroid } from "react-native";
|
|
||||||
|
|
||||||
export const showToast = (message: string) => {
|
|
||||||
if (Platform.OS === 'android') {
|
|
||||||
ToastAndroid.show(message, ToastAndroid.SHORT);
|
|
||||||
} else {
|
|
||||||
console.log(message); // Fallback for iOS or other platforms
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 MiB |
+60
-178
@@ -1,187 +1,82 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React from 'react';
|
||||||
import {
|
import { View, TouchableOpacity, StyleSheet, Text } from 'react-native';
|
||||||
View,
|
|
||||||
TouchableOpacity,
|
|
||||||
StyleSheet,
|
|
||||||
Dimensions,
|
|
||||||
Platform,
|
|
||||||
} from 'react-native';
|
|
||||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
import * as LucideIcons from 'lucide-react-native';
|
import * as LucideIcons from 'lucide-react-native';
|
||||||
import Animated, {
|
import Animated, { ZoomIn } from 'react-native-reanimated';
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withSpring,
|
|
||||||
withTiming,
|
|
||||||
interpolate,
|
|
||||||
Extrapolation
|
|
||||||
} from 'react-native-reanimated';
|
|
||||||
import Screens from '../Screens';
|
import Screens from '../Screens';
|
||||||
|
|
||||||
import NavigationString from '../Constant/NavigationString';
|
import NavigationString from '../Constant/NavigationString';
|
||||||
import { useTheme } from '../Hooks/useTheme';
|
import { useTheme } from '../Hooks/useTheme';
|
||||||
import { typography } from '../theme/typography';
|
import { typography } from '../theme/typography';
|
||||||
// import EditableExcel from '../Screens/Tender/Component/EnvelopeCExcelPreview';
|
|
||||||
|
|
||||||
const Tab = createBottomTabNavigator();
|
const Tab = createBottomTabNavigator();
|
||||||
|
|
||||||
|
const ICON_BY_ROUTE: Record<string, keyof typeof LucideIcons> = {
|
||||||
|
Home: 'House',
|
||||||
|
Auction: 'Gavel',
|
||||||
|
Tender: 'FileText',
|
||||||
|
Profile: 'User',
|
||||||
|
};
|
||||||
|
|
||||||
const TabButton = ({ route, isFocused, onPress }: any) => {
|
const TabButton = ({ route, isFocused, onPress }: any) => {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const icons: Record<string, { outline: keyof typeof LucideIcons; filled: keyof typeof LucideIcons }> = {
|
const IconName = (LucideIcons[ICON_BY_ROUTE[route.name] || 'HelpCircle'] as React.ElementType);
|
||||||
Home: { outline: "House", filled: "House" },
|
|
||||||
Auction: { outline: "Gavel", filled: "Gavel" },
|
|
||||||
Tender: { outline: "FileText", filled: "FileText" },
|
|
||||||
Profile: { outline: "User", filled: "User" },
|
|
||||||
|
|
||||||
};
|
|
||||||
// Lucide icons don't always have 'filled' variants in the set, so we might use the same icon
|
|
||||||
// or rely on color/strokeWidth to indicate state.
|
|
||||||
const iconKey = icons[route.name]?.outline || "HelpCircle"; // Fallback
|
|
||||||
const IconName = LucideIcons[iconKey] as React.ElementType;
|
|
||||||
|
|
||||||
const scale = useSharedValue(0);
|
|
||||||
const translateY = useSharedValue(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isFocused) {
|
|
||||||
scale.value = withSpring(1, { damping: 12, stiffness: 100 });
|
|
||||||
translateY.value = withSpring(-4, { damping: 12, stiffness: 100 });
|
|
||||||
} else {
|
|
||||||
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]);
|
|
||||||
|
|
||||||
useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
transform: [{ scale: scale.value }],
|
|
||||||
opacity: interpolate(scale.value, [0, 1], [0, 1], Extrapolation.CLAMP),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const animatedIconStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
transform: [{ scale: interpolate(scale.value, [0, 1], [1, 0.9]) }], // Slight shrink on press
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const animatedTextStyle = useAnimatedStyle(() => {
|
|
||||||
const opacity = interpolate(scale.value, [0, 1], [0, 1]);
|
|
||||||
const translateX = interpolate(scale.value, [0, 1], [20, 0]); // Slide in from right
|
|
||||||
return {
|
|
||||||
opacity,
|
|
||||||
transform: [{ translateX }],
|
|
||||||
display: scale.value === 0 ? 'none' : 'flex', // Hide when inactive
|
|
||||||
marginLeft: 4, // Reduced space
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={styles.tabButton}
|
style={styles.tabButton}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.85}
|
||||||
accessibilityRole="tab"
|
accessibilityRole="tab"
|
||||||
accessibilityState={{ selected: isFocused }}
|
accessibilityState={{ selected: isFocused }}
|
||||||
accessibilityLabel={`${route.name} tab`}
|
accessibilityLabel={`${route.name} tab`}
|
||||||
>
|
>
|
||||||
<View style={styles.tabContent}>
|
{isFocused ? (
|
||||||
<Animated.View style={animatedIconStyle}>
|
// Active: a solid brand pill that pops in with icon + label.
|
||||||
{IconName && (
|
<Animated.View
|
||||||
<IconName
|
entering={ZoomIn.duration(220)}
|
||||||
size={24}
|
style={[styles.activePill, { backgroundColor: colors.primary }]}
|
||||||
color={isFocused ? colors.primary : colors.text}
|
>
|
||||||
strokeWidth={isFocused ? 2.5 : 2}
|
{IconName && <IconName size={20} color="#FFFFFF" strokeWidth={2.4} />}
|
||||||
/>
|
<Text
|
||||||
)}
|
style={[styles.activeLabel, { fontFamily: typography.fonts.medium }]}
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{isFocused && (
|
|
||||||
<Animated.Text
|
|
||||||
style={[
|
|
||||||
styles.label,
|
|
||||||
animatedTextStyle,
|
|
||||||
{
|
|
||||||
color: colors.primary,
|
|
||||||
fontFamily: typography.fonts.medium,
|
|
||||||
fontWeight: undefined, // Remove fontWeight as we use fontFamily
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{route.name === 'BrandDay' ? 'Coupons' : route.name}
|
{route.name}
|
||||||
</Animated.Text>
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
) : (
|
||||||
|
// Inactive: muted icon only.
|
||||||
|
IconName && (
|
||||||
|
<IconName size={24} color={colors.textSecondary} strokeWidth={2} />
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CustomTabBar = ({ state, navigation }: any) => {
|
const CustomTabBar = ({ state, navigation }: any) => {
|
||||||
const { colors } = useTheme();
|
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
|
|
||||||
const TAB_WIDTH = (TAB_BAR_WIDTH - TAB_PADDING_H) / state.routes.length;
|
|
||||||
|
|
||||||
const translateX = useSharedValue(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
translateX.value = withSpring(state.index * TAB_WIDTH, {
|
|
||||||
damping: 15,
|
|
||||||
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(() => {
|
|
||||||
return {
|
|
||||||
transform: [{ translateX: translateX.value }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[
|
<View
|
||||||
|
style={[
|
||||||
styles.tabBar,
|
styles.tabBar,
|
||||||
{
|
{
|
||||||
backgroundColor: colors.background, // Use transparent to allow shadow to show
|
backgroundColor: colors.surface,
|
||||||
shadowColor: colors.shadow, // Dynamic shadow color
|
borderColor: colors.border,
|
||||||
shadowOffset: { width: 0, height: 10 },
|
shadowColor: colors.shadow,
|
||||||
shadowOpacity: 0.2, // Consistent opacity
|
|
||||||
shadowRadius: 10,
|
|
||||||
borderColor: colors.border, // Subtle border for definition
|
|
||||||
borderWidth: 3,
|
|
||||||
elevation: 20,
|
|
||||||
}
|
|
||||||
]}>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
styles.slidingIndicator,
|
|
||||||
{
|
|
||||||
width: TAB_WIDTH,
|
|
||||||
backgroundColor: colors.primary,
|
|
||||||
opacity: 0.3,
|
|
||||||
elevation: 10, // Subtle elevation for the indicator
|
|
||||||
},
|
},
|
||||||
animatedIndicatorStyle,
|
|
||||||
]}
|
]}
|
||||||
/>
|
>
|
||||||
{state.routes.map((route: any, index: number) => {
|
{state.routes.map((route: any, index: number) => (
|
||||||
const isFocused = state.index === index;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TabButton
|
<TabButton
|
||||||
key={route.key}
|
key={route.key}
|
||||||
route={route}
|
route={route}
|
||||||
isFocused={isFocused}
|
isFocused={state.index === index}
|
||||||
onPress={() => navigation.navigate(route.name)}
|
onPress={() => navigation.navigate(route.name)}
|
||||||
/>
|
/>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -202,9 +97,7 @@ export default function BottomTab() {
|
|||||||
<Tab.Screen name="Home" component={Screens.Home} />
|
<Tab.Screen name="Home" component={Screens.Home} />
|
||||||
<Tab.Screen name={NavigationString.Auction} component={Screens.Auction} />
|
<Tab.Screen name={NavigationString.Auction} component={Screens.Auction} />
|
||||||
<Tab.Screen name={NavigationString.Tender} component={Screens.Tender} />
|
<Tab.Screen name={NavigationString.Tender} component={Screens.Tender} />
|
||||||
{/* <Tab.Screen name={'EditableExcel'} component={EditableExcel} /> */}
|
|
||||||
<Tab.Screen name={NavigationString.Profile} component={Screens.ProfileTab} />
|
<Tab.Screen name={NavigationString.Profile} component={Screens.ProfileTab} />
|
||||||
|
|
||||||
</Tab.Navigator>
|
</Tab.Navigator>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -213,54 +106,43 @@ export default function BottomTab() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|
||||||
},
|
},
|
||||||
tabBar: {
|
tabBar: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 20,
|
bottom: 24,
|
||||||
left: 20,
|
left: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
height: 70,
|
height: 66,
|
||||||
borderRadius: 35,
|
borderRadius: 33,
|
||||||
elevation: 20, // stronger Android elevation
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
shadowColor: '#000', // iOS shadow color
|
|
||||||
shadowOffset: { width: 0, height: 10 },
|
|
||||||
shadowOpacity: Platform.OS === 'ios' ? 0.25 : 0.2,
|
|
||||||
shadowRadius: 12,
|
|
||||||
overflow: 'visible', // allow shadow to render outside rounded corners
|
|
||||||
zIndex: 9999,
|
|
||||||
paddingBottom: 0,
|
|
||||||
justifyContent: 'space-around',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 5,
|
justifyContent: 'space-around',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
// Soft floating shadow (iOS) + elevation (Android).
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 12,
|
||||||
|
zIndex: 9999,
|
||||||
},
|
},
|
||||||
tabButton: {
|
tabButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
},
|
},
|
||||||
tabContent: {
|
activePill: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
gap: 6,
|
||||||
paddingHorizontal: 4, // Drastically reduced from 12
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 4,
|
paddingVertical: 9,
|
||||||
borderRadius: 20,
|
borderRadius: 22,
|
||||||
},
|
},
|
||||||
slidingIndicator: {
|
activeLabel: {
|
||||||
position: 'absolute',
|
color: '#FFFFFF',
|
||||||
top: 12,
|
fontSize: 13,
|
||||||
left: 5,
|
letterSpacing: 0.2,
|
||||||
height: 40,
|
|
||||||
borderRadius: 30,
|
|
||||||
zIndex: 0,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
fontSize: 11, // Slightly smaller text
|
|
||||||
textAlign: 'left',
|
|
||||||
textTransform: 'capitalize',
|
|
||||||
letterSpacing: 0.2, // Tighter spacing
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ const Auction = () => {
|
|||||||
const { data: liveAuctionData = {} }: any = useFetchLiveAuction();
|
const { data: liveAuctionData = {} }: any = useFetchLiveAuction();
|
||||||
const { data: purchaseAuctionData = {} } = useFetchPurchaseAuction();
|
const { data: purchaseAuctionData = {} } = useFetchPurchaseAuction();
|
||||||
const SelectedPurchaseList = useSelector((state: any) => state.selectPurchaseAuction);
|
const SelectedPurchaseList = useSelector((state: any) => state.selectPurchaseAuction);
|
||||||
useSelector((state: any) => state.token?.token);
|
|
||||||
|
|
||||||
const liveCount = Array.isArray(liveAuctionData?.result)
|
const liveCount = Array.isArray(liveAuctionData?.result)
|
||||||
? liveAuctionData?.pages?.[0]?.totalCount
|
? liveAuctionData?.pages?.[0]?.totalCount
|
||||||
@@ -58,7 +57,10 @@ const Auction = () => {
|
|||||||
|
|
||||||
const cartCount = SelectedPurchaseList.length;
|
const cartCount = SelectedPurchaseList.length;
|
||||||
|
|
||||||
const styles = makeStyles(colors, spacing, shadows, TAB_WIDTH, TAB_HEIGHT);
|
const styles = useMemo(
|
||||||
|
() => makeStyles(colors, spacing, shadows, TAB_WIDTH, TAB_HEIGHT),
|
||||||
|
[colors, spacing, shadows, TAB_WIDTH],
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import Animated, { FadeInDown, FadeOutUp } from 'react-native-reanimated';
|
|||||||
import LiveAuctionTile from './LiveAuctionTile';
|
import LiveAuctionTile from './LiveAuctionTile';
|
||||||
import { useFetchLiveAuction } from '../../Services/ApiCall/Hooks';
|
import { useFetchLiveAuction } from '../../Services/ApiCall/Hooks';
|
||||||
import { SearchInput } from '../../components/SearchInput';
|
import { SearchInput } from '../../components/SearchInput';
|
||||||
import { Skeleton } from '../../components';
|
import { Skeleton, EmptyState } from '../../components';
|
||||||
import SkeletonList from '../../components/SkeletonList';
|
import SkeletonList from '../../components/SkeletonList';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { togglePurchaseAuction } from '../../Redux/Slices/selectPurchaseAuction';
|
import { togglePurchaseAuction } from '../../Redux/Slices/selectPurchaseAuction';
|
||||||
@@ -46,7 +46,7 @@ const LiveAuction = () => {
|
|||||||
data={item}
|
data={item}
|
||||||
index={index}
|
index={index}
|
||||||
isPurchasedByContractor={item.contractorId?.includes(
|
isPurchasedByContractor={item.contractorId?.includes(
|
||||||
String(user.contractorId),
|
String(user?.contractorId),
|
||||||
)}
|
)}
|
||||||
auctionId={item.auctionId}
|
auctionId={item.auctionId}
|
||||||
auctionDispatchDate={item.auctionDispatchDate}
|
auctionDispatchDate={item.auctionDispatchDate}
|
||||||
@@ -56,7 +56,6 @@ const LiveAuction = () => {
|
|||||||
auctionFees={item.auctionFees}
|
auctionFees={item.auctionFees}
|
||||||
auctionNumber={item.auctionNumber}
|
auctionNumber={item.auctionNumber}
|
||||||
onPurchasePress={() => {
|
onPurchasePress={() => {
|
||||||
console.log('[LiveAuction] purchase toggled:', item?.auctionNumber);
|
|
||||||
Dispatch(togglePurchaseAuction(item));
|
Dispatch(togglePurchaseAuction(item));
|
||||||
}}
|
}}
|
||||||
onDetailsPress={() => {}}
|
onDetailsPress={() => {}}
|
||||||
@@ -71,7 +70,6 @@ const LiveAuction = () => {
|
|||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (onEndReachedCalledDuringMomentum.current) return;
|
if (onEndReachedCalledDuringMomentum.current) return;
|
||||||
if (!hasNextPage || isFetchingNextPage) return;
|
if (!hasNextPage || isFetchingNextPage) return;
|
||||||
console.log('[LiveAuction] loadMore: fetching next page');
|
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
onEndReachedCalledDuringMomentum.current = true;
|
onEndReachedCalledDuringMomentum.current = true;
|
||||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
@@ -90,24 +88,13 @@ const LiveAuction = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRefetching) {
|
if (isRefetching) {
|
||||||
console.log('[LiveAuction] refetch started');
|
|
||||||
setShowRefreshBanner(true);
|
setShowRefreshBanner(true);
|
||||||
} else {
|
} else {
|
||||||
console.log('[LiveAuction] refetch completed');
|
|
||||||
const timer = setTimeout(() => setShowRefreshBanner(false), 1000);
|
const timer = setTimeout(() => setShowRefreshBanner(false), 1000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [isRefetching]);
|
}, [isRefetching]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('[LiveAuction] data updated:', {
|
|
||||||
count: data?.result?.length ?? 0,
|
|
||||||
hasNextPage,
|
|
||||||
isFetchingNextPage,
|
|
||||||
isPending,
|
|
||||||
});
|
|
||||||
}, [data, hasNextPage, isFetchingNextPage, isPending]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Skeleton />;
|
return <Skeleton />;
|
||||||
}
|
}
|
||||||
@@ -153,6 +140,12 @@ const LiveAuction = () => {
|
|||||||
onEndReachedCalledDuringMomentum.current = false;
|
onEndReachedCalledDuringMomentum.current = false;
|
||||||
}}
|
}}
|
||||||
ListFooterComponent={renderFooter}
|
ListFooterComponent={renderFooter}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<EmptyState
|
||||||
|
title="No live auctions found"
|
||||||
|
subtitle="New live auctions will appear here as they open."
|
||||||
|
/>
|
||||||
|
}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={isRefetching}
|
refreshing={isRefetching}
|
||||||
@@ -178,6 +171,7 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
listContent: {
|
listContent: {
|
||||||
|
flexGrow: 1,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingBottom: 60,
|
paddingBottom: 60,
|
||||||
paddingTop: 10,
|
paddingTop: 10,
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import ReactNativeBlobUtil from 'react-native-blob-util';
|
|||||||
import AuctionItemDetail from './AuctionItemDetail';
|
import AuctionItemDetail from './AuctionItemDetail';
|
||||||
import { useTheme } from '../../../Hooks/useTheme';
|
import { useTheme } from '../../../Hooks/useTheme';
|
||||||
import AppText from '../../../components/AppText';
|
import AppText from '../../../components/AppText';
|
||||||
|
import { showToast } from '../../../Constant/ShowDailog';
|
||||||
|
import { logger } from '../../../core/observability/logger';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
|
||||||
interface Props { visible: boolean; onClose: () => void; data?: any }
|
interface Props { visible: boolean; onClose: () => void; data?: any }
|
||||||
@@ -36,8 +38,11 @@ const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
|||||||
const [allBoolean, setAllBoolean] = useState({
|
const [allBoolean, setAllBoolean] = useState({
|
||||||
showItemDetail: false,
|
showItemDetail: false,
|
||||||
selectedItem: {},
|
selectedItem: {},
|
||||||
fileLoading: false,
|
|
||||||
});
|
});
|
||||||
|
// Tracks WHICH document is downloading, so only that row shows a spinner.
|
||||||
|
// A shared boolean lit up every row's spinner at once, making a single
|
||||||
|
// per-document download look like it was downloading all of them.
|
||||||
|
const [downloadingId, setDownloadingId] = useState<number | null>(null);
|
||||||
const { data: auctionDocuments } = useGetAucitonDocument({ auctionId: data?.auctionId });
|
const { data: auctionDocuments } = useGetAucitonDocument({ auctionId: data?.auctionId });
|
||||||
const { mutate } = useAuctionItem();
|
const { mutate } = useAuctionItem();
|
||||||
|
|
||||||
@@ -46,13 +51,10 @@ const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
|||||||
return moment(date, 'DD-MM-YYYY HH:mm:ss').format('DD MMM YYYY, hh:mm A');
|
return moment(date, 'DD-MM-YYYY HH:mm:ss').format('DD MMM YYYY, hh:mm A');
|
||||||
};
|
};
|
||||||
|
|
||||||
const isLive = data?.purchaseEnd
|
|
||||||
? moment().isBefore(moment(data.purchaseEnd, 'DD-MM-YYYY HH:mm:ss'))
|
|
||||||
: false;
|
|
||||||
|
|
||||||
const handleDownload = async (id: number, fileName: string) => {
|
const handleDownload = async (id: number, fileName: string) => {
|
||||||
|
if (downloadingId !== null) { return; } // one download at a time
|
||||||
try {
|
try {
|
||||||
setAllBoolean(prev => ({ ...prev, fileLoading: true }));
|
setDownloadingId(id);
|
||||||
const { dirs } = ReactNativeBlobUtil.fs;
|
const { dirs } = ReactNativeBlobUtil.fs;
|
||||||
const path = `${dirs.DownloadDir}/${fileName}.pdf`;
|
const path = `${dirs.DownloadDir}/${fileName}.pdf`;
|
||||||
const downloadUrl = `${AUCTION_DOCUMENT_DOWNLOAD}?documentId=${id}&isAmendment=false`;
|
const downloadUrl = `${AUCTION_DOCUMENT_DOWNLOAD}?documentId=${id}&isAmendment=false`;
|
||||||
@@ -68,10 +70,12 @@ const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
|||||||
mediaScannable: true,
|
mediaScannable: true,
|
||||||
},
|
},
|
||||||
}).fetch('GET', downloadUrl, { Accept: 'application/pdf' });
|
}).fetch('GET', downloadUrl, { Accept: 'application/pdf' });
|
||||||
|
showToast('Download complete');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log('Error:', error?.message || error);
|
logger.error('auction', 'document download failed', { error: String(error?.message || error) });
|
||||||
|
showToast('Download failed. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setAllBoolean(prev => ({ ...prev, fileLoading: false }));
|
setDownloadingId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,12 +124,6 @@ const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
|||||||
</AppText>
|
</AppText>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={[styles.statusChip, { borderColor: isLive ? `${colors.success}50` : colors.border }]}>
|
|
||||||
<View style={[styles.statusDot, { backgroundColor: isLive ? colors.success : colors.textSecondary }]} />
|
|
||||||
<AppText variant="caption" color={isLive ? colors.success : colors.textSecondary} style={styles.statusText as any}>
|
|
||||||
{isLive ? 'LIVE' : 'CLOSED'}
|
|
||||||
</AppText>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* ── Body ── */}
|
{/* ── Body ── */}
|
||||||
@@ -204,7 +202,10 @@ const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
|||||||
index === aucitonDetail.auctionDocuments.length - 1 && { borderBottomWidth: 0 },
|
index === aucitonDetail.auctionDocuments.length - 1 && { borderBottomWidth: 0 },
|
||||||
]}
|
]}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
disabled={downloadingId !== null}
|
||||||
onPress={() => handleDownload(doc.auctionDocumentId, doc.fileName.replace('.pdf', ''))}
|
onPress={() => handleDownload(doc.auctionDocumentId, doc.fileName.replace('.pdf', ''))}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Download ${doc.fileName}`}
|
||||||
>
|
>
|
||||||
<View style={[styles.docIcon, { backgroundColor: `${colors.error}15` }]}>
|
<View style={[styles.docIcon, { backgroundColor: `${colors.error}15` }]}>
|
||||||
<FileText size={16} color={colors.error} strokeWidth={2} />
|
<FileText size={16} color={colors.error} strokeWidth={2} />
|
||||||
@@ -212,7 +213,7 @@ const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
|||||||
<AppText variant="bodySmall" color={colors.text} style={styles.docName as any} numberOfLines={1}>
|
<AppText variant="bodySmall" color={colors.text} style={styles.docName as any} numberOfLines={1}>
|
||||||
{doc.fileName}
|
{doc.fileName}
|
||||||
</AppText>
|
</AppText>
|
||||||
{allBoolean.fileLoading
|
{downloadingId === doc.auctionDocumentId
|
||||||
? <ActivityIndicator size="small" color={colors.primary} />
|
? <ActivityIndicator size="small" color={colors.primary} />
|
||||||
: <Download size={18} color={colors.textSecondary} strokeWidth={2} />
|
: <Download size={18} color={colors.textSecondary} strokeWidth={2} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ const AuctionItemDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
|||||||
<AppText variant="bodySmall" style={styles.descContent}>{data?.briefDescription}</AppText>
|
<AppText variant="bodySmall" style={styles.descContent}>{data?.briefDescription}</AppText>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{itemData?.length && <Section title="View Documents" icon={FileText} styles={styles} colors={colors}>
|
{itemData?.length > 0 && <Section title="View Documents" icon={FileText} styles={styles} colors={colors}>
|
||||||
<View style={styles.docTableHead}>
|
<View style={styles.docTableHead}>
|
||||||
<AppText variant="caption" bold style={styles.tableLabel}>Sr No.</AppText>
|
<AppText variant="caption" bold style={styles.tableLabel}>Sr No.</AppText>
|
||||||
<AppText variant="caption" bold style={[styles.tableLabel, { flex: 2 }]}>Document Name</AppText>
|
<AppText variant="caption" bold style={[styles.tableLabel, { flex: 2 }]}>Document Name</AppText>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import PurchaseAuctionTile from './PurchaseAuctionTile';
|
|||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { setAuction } from '../../Redux/Slices/SelectedAuction';
|
import { setAuction } from '../../Redux/Slices/SelectedAuction';
|
||||||
import { SearchInput } from '../../components/SearchInput';
|
import { SearchInput } from '../../components/SearchInput';
|
||||||
import { Skeleton } from '../../components';
|
import { Skeleton, EmptyState } from '../../components';
|
||||||
import { allBooleanProps } from './types/types';
|
import { allBooleanProps } from './types/types';
|
||||||
import FilterModal from './Modal/FilterModal';
|
import FilterModal from './Modal/FilterModal';
|
||||||
import NavigationString from '../../Constant/NavigationString';
|
import NavigationString from '../../Constant/NavigationString';
|
||||||
@@ -25,8 +25,11 @@ const PurchaseAuction = () => {
|
|||||||
|
|
||||||
function isAuctionActive(item: any): boolean {
|
function isAuctionActive(item: any): boolean {
|
||||||
const now = moment();
|
const now = moment();
|
||||||
const start = moment(item?.keyDates?.auctionBidding?.startDateTime, format);
|
const bidding = item?.auctionStatus === 'Amended'
|
||||||
const end = moment(item?.keyDates?.auctionBidding?.endDateTime, format);
|
? item?.amendedkeyDates?.auctionBidding
|
||||||
|
: item?.keyDates?.auctionBidding;
|
||||||
|
const start = moment(bidding?.startDateTime, format);
|
||||||
|
const end = moment(bidding?.endDateTime, format);
|
||||||
|
|
||||||
if (!start.isValid() || !end.isValid()) return false;
|
if (!start.isValid() || !end.isValid()) return false;
|
||||||
|
|
||||||
@@ -67,7 +70,7 @@ const PurchaseAuction = () => {
|
|||||||
{isPending ? <Skeleton />
|
{isPending ? <Skeleton />
|
||||||
:
|
:
|
||||||
<FlatList
|
<FlatList
|
||||||
data={data.result ?? []}
|
data={data?.result ?? []}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<PurchaseAuctionTile
|
<PurchaseAuctionTile
|
||||||
item={item}
|
item={item}
|
||||||
@@ -82,7 +85,7 @@ const PurchaseAuction = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={{ paddingBottom: 50, paddingTop: 10 }}
|
contentContainerStyle={{ flexGrow: 1, paddingBottom: 50, paddingTop: 10 }}
|
||||||
|
|
||||||
onEndReached={loadMore}
|
onEndReached={loadMore}
|
||||||
onEndReachedThreshold={0.1}
|
onEndReachedThreshold={0.1}
|
||||||
@@ -92,6 +95,12 @@ const PurchaseAuction = () => {
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
ListFooterComponent={renderFooter}
|
ListFooterComponent={renderFooter}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<EmptyState
|
||||||
|
title="No purchased auctions found"
|
||||||
|
subtitle="Auctions you purchase will appear here."
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
@@ -110,7 +119,7 @@ const PurchaseAuction = () => {
|
|||||||
}
|
}
|
||||||
{allBoolean.isShowFilter && <View style={{ flex: 1 }}>
|
{allBoolean.isShowFilter && <View style={{ flex: 1 }}>
|
||||||
<FilterModal visible={allBoolean.isShowFilter} onClose={() => { setAllBoolean((prev: any) => ({ ...prev, isShowFilter: false })) }} setFilter={setFilter} filter={filter} />
|
<FilterModal visible={allBoolean.isShowFilter} onClose={() => { setAllBoolean((prev: any) => ({ ...prev, isShowFilter: false })) }} setFilter={setFilter} filter={filter} />
|
||||||
</View>}.
|
</View>}
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -95,6 +95,21 @@ const PurchaseAuctionList = () => {
|
|||||||
}, 1200);
|
}, 1200);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const goHome = () => {
|
||||||
|
Dispatch(clearPurchaseAuction());
|
||||||
|
Navigation.dispatch(
|
||||||
|
CommonActions.reset({ index: 0, routes: [{ name: NavigationString.Home }] }),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The auction was already purchased — proceedToPurchase then has no cart to
|
||||||
|
// show, so bounce back to Home instead of leaving a blank list. (The backend
|
||||||
|
// message is surfaced by the global API toast.)
|
||||||
|
const isAlreadyPurchased = (res: any): boolean => {
|
||||||
|
const message = String(res?.message ?? '').toLowerCase();
|
||||||
|
return res?.statusCode === 409 || message.includes('already');
|
||||||
|
};
|
||||||
|
|
||||||
const getPurchaseList = () => {
|
const getPurchaseList = () => {
|
||||||
const auctionNumbers: string[] = SelectedPurchaseList.map(
|
const auctionNumbers: string[] = SelectedPurchaseList.map(
|
||||||
(item: { auctionNumber: string }) => item.auctionNumber,
|
(item: { auctionNumber: string }) => item.auctionNumber,
|
||||||
@@ -103,9 +118,19 @@ const PurchaseAuctionList = () => {
|
|||||||
{ auctionNumbers, contractorId: LoginUser.contractorId },
|
{ auctionNumbers, contractorId: LoginUser.contractorId },
|
||||||
{
|
{
|
||||||
onSuccess: res => {
|
onSuccess: res => {
|
||||||
if (res.statusCode === 200) setList(res.data);
|
if (res.statusCode === 200) {
|
||||||
|
setList(res.data);
|
||||||
|
} else if (isAlreadyPurchased(res)) {
|
||||||
|
goHome();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
if (isAlreadyPurchased(error?.response?.data)) {
|
||||||
|
goHome();
|
||||||
|
} else {
|
||||||
|
console.log(error, 'purchase list error');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: error => console.log(error, 'purchase list error'),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,12 +42,15 @@ const AuctionBiddingScreen = () => {
|
|||||||
const { colors, spacing, shadows } = useTheme();
|
const { colors, spacing, shadows } = useTheme();
|
||||||
const SelectedAuction = useSelector((state: any) => state.auction.auction);
|
const SelectedAuction = useSelector((state: any) => state.auction.auction);
|
||||||
const loginUser = useSelector((state: any) => state.token.token);
|
const loginUser = useSelector((state: any) => state.token.token);
|
||||||
|
// The login session stores the contractor id under `id` (see ProfileTab /
|
||||||
|
// LiveTender); the older `contractorId` key is absent, which is why bid
|
||||||
|
// payloads and "me"/LEADING detection were getting undefined.
|
||||||
|
const contractorId = loginUser?.contractorId ?? loginUser?.id;
|
||||||
const Navigation: any = useNavigation();
|
const Navigation: any = useNavigation();
|
||||||
const config = useMemo(
|
const config = useMemo(
|
||||||
() => getBiddingConfig(SelectedAuction?.auctionPattern),
|
() => getBiddingConfig(SelectedAuction?.auctionPattern),
|
||||||
[SelectedAuction?.auctionPattern],
|
[SelectedAuction?.auctionPattern],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [items, setItems] = useState<any>([]);
|
const [items, setItems] = useState<any>([]);
|
||||||
const [bidValues, setBidValues] = useState<Record<number, number>>({});
|
const [bidValues, setBidValues] = useState<Record<number, number>>({});
|
||||||
const [allBoolean, setAllBoolean] = useState({
|
const [allBoolean, setAllBoolean] = useState({
|
||||||
@@ -104,11 +107,11 @@ const AuctionBiddingScreen = () => {
|
|||||||
...item,
|
...item,
|
||||||
bidAmount: bidData?.bidAmount ?? config.startValue(item),
|
bidAmount: bidData?.bidAmount ?? config.startValue(item),
|
||||||
bidAvilable: bidData?.bidAmount ?? 0,
|
bidAvilable: bidData?.bidAmount ?? 0,
|
||||||
me: bidData?.contractorId === loginUser.contractorId,
|
me: bidData?.contractorId === contractorId,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const initialBids: Record<number, number> = {};
|
const initialBids: Record<number, number> = {};
|
||||||
updatedFilteredItems.forEach(item => { initialBids[item.id] = item.bidAmount; });
|
updatedFilteredItems.forEach(item => { initialBids[item.id] = Number(item.bidAmount); });
|
||||||
setBidValues(initialBids);
|
setBidValues(initialBids);
|
||||||
setItems(updatedFilteredItems);
|
setItems(updatedFilteredItems);
|
||||||
} else {
|
} else {
|
||||||
@@ -125,12 +128,12 @@ const AuctionBiddingScreen = () => {
|
|||||||
bidId: bid.bidId,
|
bidId: bid.bidId,
|
||||||
contractorId: bid.contractorId,
|
contractorId: bid.contractorId,
|
||||||
contractorName: bid.contractorName,
|
contractorName: bid.contractorName,
|
||||||
me: bid.contractorId === loginUser.contractorId,
|
me: bid.contractorId === contractorId,
|
||||||
bidTime: bid.bidTime,
|
bidTime: bid.bidTime,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const initialBids: Record<number, number> = {};
|
const initialBids: Record<number, number> = {};
|
||||||
mergedData.forEach(item => { initialBids[item.id] = item.bidAmount; });
|
mergedData.forEach(item => { initialBids[item.id] = Number(item.bidAmount); });
|
||||||
setBidValues(initialBids);
|
setBidValues(initialBids);
|
||||||
setItems(mergedData);
|
setItems(mergedData);
|
||||||
}
|
}
|
||||||
@@ -152,7 +155,7 @@ const AuctionBiddingScreen = () => {
|
|||||||
: item,
|
: item,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
setBidValues(prev => ({ ...prev, [result.auctionItemId]: result.bidAmount }));
|
setBidValues(prev => ({ ...prev, [result.auctionItemId]: Number(result.bidAmount) }));
|
||||||
});
|
});
|
||||||
return () => unsubscribe(destination);
|
return () => unsubscribe(destination);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -170,8 +173,19 @@ const AuctionBiddingScreen = () => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [allBoolean.reload]);
|
}, [allBoolean.reload]);
|
||||||
|
|
||||||
const handleUpdate = (id: number, val: number) => {
|
// Move a bid one step in the given direction. The next value is computed from
|
||||||
setBidValues(prev => ({ ...prev, [id]: prev[id] + val }));
|
// the LIVE `prev` state (no stale closure on rapid taps) and clamped to the
|
||||||
|
// pattern's [floor, ceil] by the config, so the button always moves the value
|
||||||
|
// by a real amount and can never overshoot a bound.
|
||||||
|
const stepBid = (item: any, direction: 'up' | 'down') => {
|
||||||
|
setBidValues(prev => {
|
||||||
|
let current = Number(prev[item.id]);
|
||||||
|
if (!Number.isFinite(current)) { current = config.startValue(item); }
|
||||||
|
const next = direction === 'up'
|
||||||
|
? config.stepUp(current, item)
|
||||||
|
: config.stepDown(current, item);
|
||||||
|
return { ...prev, [item.id]: next };
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitBid = async (item: any) => {
|
const submitBid = async (item: any) => {
|
||||||
@@ -179,7 +193,7 @@ const AuctionBiddingScreen = () => {
|
|||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
bidAmount: bidValues[item.id],
|
bidAmount: bidValues[item.id],
|
||||||
auctionId: SelectedAuction.auctionId,
|
auctionId: SelectedAuction.auctionId,
|
||||||
contractorId: loginUser.contractorId,
|
contractorId,
|
||||||
fullName: loginUser.fullName,
|
fullName: loginUser.fullName,
|
||||||
roomId: SelectedAuction.auctionNumber,
|
roomId: SelectedAuction.auctionNumber,
|
||||||
};
|
};
|
||||||
@@ -212,7 +226,7 @@ const AuctionBiddingScreen = () => {
|
|||||||
}, []),
|
}, []),
|
||||||
);
|
);
|
||||||
|
|
||||||
const styles = makeStyles(colors, spacing, shadows);
|
const styles = useMemo(() => makeStyles(colors, spacing, shadows), [colors, spacing, shadows]);
|
||||||
|
|
||||||
const biddingEndDate =
|
const biddingEndDate =
|
||||||
SelectedAuction?.auctionStatus === 'Amended'
|
SelectedAuction?.auctionStatus === 'Amended'
|
||||||
@@ -280,8 +294,8 @@ const AuctionBiddingScreen = () => {
|
|||||||
<AuctionBiddingCard
|
<AuctionBiddingCard
|
||||||
item={{ ...item, index }}
|
item={{ ...item, index }}
|
||||||
bidValue={bidValues[item.id] || 0}
|
bidValue={bidValues[item.id] || 0}
|
||||||
onIncrease={() => handleUpdate(item.id, config.step(item))}
|
onIncrease={() => stepBid(item, 'up')}
|
||||||
onDecrease={() => handleUpdate(item.id, -config.step(item))}
|
onDecrease={() => stepBid(item, 'down')}
|
||||||
onPlaceBid={() => submitBid(item)}
|
onPlaceBid={() => submitBid(item)}
|
||||||
SelectedAuction={SelectedAuction}
|
SelectedAuction={SelectedAuction}
|
||||||
allBoolean={allBoolean}
|
allBoolean={allBoolean}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ const AuctionBiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid
|
|||||||
const canDecrease = config.canDecrease(bidValue, item);
|
const canDecrease = config.canDecrease(bidValue, item);
|
||||||
const bodyFull = config.isBodyFull(allBoolean, item);
|
const bodyFull = config.isBodyFull(allBoolean, item);
|
||||||
const submitVisible = config.showSubmit(allBoolean, item);
|
const submitVisible = config.showSubmit(allBoolean, item);
|
||||||
|
|
||||||
// 🔒 Persist expand state safely
|
// 🔒 Persist expand state safely
|
||||||
const expandedRef = useRef(false);
|
const expandedRef = useRef(false);
|
||||||
const animation = useSharedValue(0);
|
const animation = useSharedValue(0);
|
||||||
@@ -98,7 +97,8 @@ const AuctionBiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid
|
|||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
bidAmount: Number(value),
|
bidAmount: Number(value),
|
||||||
auctionId: SelectedAuction.auctionId,
|
auctionId: SelectedAuction.auctionId,
|
||||||
contractorId: Token.contractorId,
|
// Session stores the contractor id as `id` (contractorId is absent).
|
||||||
|
contractorId: Token?.contractorId ?? Token?.id,
|
||||||
fullName: Token.fullName,
|
fullName: Token.fullName,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -108,7 +108,6 @@ const Emd = () => {
|
|||||||
|
|
||||||
/* ── Render Item ── */
|
/* ── Render Item ── */
|
||||||
const renderItem = ({ item }: any) => {
|
const renderItem = ({ item }: any) => {
|
||||||
console.log(item, "item");
|
|
||||||
const selected = isSelected(item.id);
|
const selected = isSelected(item.id);
|
||||||
const selectedData = selectedItems.find(i => i.auctionItemId === item.id);
|
const selectedData = selectedItems.find(i => i.auctionItemId === item.id);
|
||||||
|
|
||||||
@@ -352,7 +351,7 @@ const Emd = () => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
useEffect(() => { AlreadyEmdCheck(); }, []);
|
useEffect(() => { AlreadyEmdCheck(); }, []);
|
||||||
|
|
||||||
const styles = makeStyles(colors, spacing, shadows, typography);
|
const styles = useMemo(() => makeStyles(colors, spacing, shadows, typography), [colors, spacing, shadows, typography]);
|
||||||
|
|
||||||
/* ── Policy + Submit footer ── */
|
/* ── Policy + Submit footer ── */
|
||||||
const BottomBar = ({ onSubmit }: { onSubmit: () => void }) => (
|
const BottomBar = ({ onSubmit }: { onSubmit: () => void }) => (
|
||||||
|
|||||||
@@ -53,17 +53,24 @@ describe('forward pattern', () => {
|
|||||||
|
|
||||||
describe('reverse pattern', () => {
|
describe('reverse pattern', () => {
|
||||||
const cfg = getBiddingConfig('Reverse');
|
const cfg = getBiddingConfig('Reverse');
|
||||||
const item = { auctionStartValue: 1000, emdAmount: 50, bidAmount: 800 };
|
const item = { auctionStartValue: 1000, emdAmount: 50, bidAmount: 800, bidVariationValue: 50 };
|
||||||
|
|
||||||
it('reads the reverse start-value and step fields', () => {
|
it('reads the reverse start-value and step fields', () => {
|
||||||
expect(cfg.itemsUrl).toBe(REVERSE_AUCTION_ITEM);
|
expect(cfg.itemsUrl).toBe(REVERSE_AUCTION_ITEM);
|
||||||
expect(cfg.startValue(item)).toBe(1000);
|
expect(cfg.startValue(item)).toBe(1000);
|
||||||
expect(cfg.step(item)).toBe(50);
|
expect(cfg.step(item)).toBe(50); // the configured bid-variation
|
||||||
expect(cfg.stepLabel).toBe('Decrement');
|
expect(cfg.stepLabel).toBe('Decrement');
|
||||||
expect(cfg.stepPrefix).toBe('-');
|
expect(cfg.stepPrefix).toBe('-');
|
||||||
expect(cfg.inlineManualInput).toBe(true);
|
expect(cfg.inlineManualInput).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('decrements by bidVariationValue, not the EMD deposit', () => {
|
||||||
|
// Real payloads send amounts as strings; the decrement is the bid variation.
|
||||||
|
expect(cfg.step({ bidVariationValue: '11', emdAmount: '0' })).toBe(11);
|
||||||
|
// With no bid-variation, it does NOT use the EMD deposit — it steps by ₹1.
|
||||||
|
expect(cfg.step({ emdAmount: '50' })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('caps increases at the current bid and decreases at the EMD floor', () => {
|
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(700, item)).toBe(true); // 700 < 800
|
||||||
expect(cfg.canIncrease(800, item)).toBe(false);
|
expect(cfg.canIncrease(800, item)).toBe(false);
|
||||||
@@ -84,3 +91,44 @@ describe('reverse pattern', () => {
|
|||||||
expect(cfg.validateManualBid(123, item)).toBeNull();
|
expect(cfg.validateManualBid(123, item)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('stepper robustness (both patterns)', () => {
|
||||||
|
const rev = getBiddingConfig('Reverse');
|
||||||
|
const fwd = getBiddingConfig('Forward');
|
||||||
|
|
||||||
|
it('never resolves a zero/NaN step, even when every amount field is missing', () => {
|
||||||
|
// This is the exact "DECREMENT -₹0 / button does nothing" bug.
|
||||||
|
expect(rev.step({ auctionStartValue: '550', bidAmount: '539', emdAmount: '0' })).toBeGreaterThan(0);
|
||||||
|
expect(rev.step({})).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(fwd.step({})).toBeGreaterThanOrEqual(1);
|
||||||
|
// With no configured bid-variation the step is a flat ₹1.
|
||||||
|
expect(rev.step({ auctionStartValue: 1000 })).toBe(1);
|
||||||
|
expect(fwd.step({ auctionStartValueFigure: 200 })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reverse stepDown moves and clamps to the EMD floor without overshooting', () => {
|
||||||
|
const it = { auctionStartValue: 1000, emdAmount: 50, bidAmount: 800, bidVariationValue: 50 };
|
||||||
|
expect(rev.stepDown(800, it)).toBe(750);
|
||||||
|
expect(rev.stepDown(60, it)).toBe(50); // 60-50=10 would undershoot -> clamped to floor
|
||||||
|
expect(rev.stepDown(50, it)).toBe(50); // already at floor
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reverse stepUp clamps to the current bid (ceiling)', () => {
|
||||||
|
const it = { auctionStartValue: 1000, emdAmount: 50, bidAmount: 800, bidVariationValue: 50 };
|
||||||
|
expect(rev.stepUp(760, it)).toBe(800); // 760+50=810 would exceed -> clamped to ceil
|
||||||
|
expect(rev.stepUp(800, it)).toBe(800); // already at ceil
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forward stepUp is unbounded and stepDown floors at the current bid', () => {
|
||||||
|
const it = { auctionStartValueFigure: 100, bidVariationValue: 10, bidAmount: 150 };
|
||||||
|
expect(fwd.stepUp(150, it)).toBe(160);
|
||||||
|
expect(fwd.stepDown(160, it)).toBe(150);
|
||||||
|
expect(fwd.stepDown(150, it)).toBe(150); // can't undercut the current bid
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a reverse item with no variation still decrements (regression for -₹0)', () => {
|
||||||
|
const it = { auctionStartValue: '550', bidAmount: '539', emdAmount: '0' };
|
||||||
|
expect(rev.canDecrease(539, it)).toBe(true);
|
||||||
|
expect(rev.stepDown(539, it)).toBeLessThan(539);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
* Every behavioral difference between them is captured HERE, explicitly, so
|
* Every behavioral difference between them is captured HERE, explicitly, so
|
||||||
* the screen and card contain zero `if (pattern === ...)` logic and a bidding
|
* the screen and card contain zero `if (pattern === ...)` logic and a bidding
|
||||||
* fix can never again land in one pattern but not the other.
|
* fix can never again land in one pattern but not the other.
|
||||||
|
*
|
||||||
|
* The stepper is modelled as a bounded number line per pattern:
|
||||||
|
* - Forward: bid UP. floor = current bid (never undercut it), no ceiling.
|
||||||
|
* - Reverse: bid DOWN. ceiling = current bid (never exceed it), floor = EMD.
|
||||||
|
* `stepUp`/`stepDown` return the next value already clamped to [floor, ceil],
|
||||||
|
* and `canIncrease`/`canDecrease` are derived from them, so the buttons can
|
||||||
|
* never overshoot a bound and can never be "enabled but do nothing".
|
||||||
*/
|
*/
|
||||||
import { FORWARD_AUCTION_ITEM, REVERSE_AUCTION_ITEM } from '../../Services/ApiCall/ApiUrls';
|
import { FORWARD_AUCTION_ITEM, REVERSE_AUCTION_ITEM } from '../../Services/ApiCall/ApiUrls';
|
||||||
|
|
||||||
@@ -21,13 +28,19 @@ export type BiddingPatternConfig = {
|
|||||||
itemsUrl: string;
|
itemsUrl: string;
|
||||||
/** Opening amount — the backend uses a different field per pattern. */
|
/** Opening amount — the backend uses a different field per pattern. */
|
||||||
startValue: (item: any) => number;
|
startValue: (item: any) => number;
|
||||||
/** Stepper step size. */
|
/** Stepper step size — always a positive number (see resolveStep). */
|
||||||
step: (item: any) => number;
|
step: (item: any) => number;
|
||||||
stepLabel: 'Increment' | 'Decrement';
|
stepLabel: 'Increment' | 'Decrement';
|
||||||
stepPrefix: '+' | '-';
|
stepPrefix: '+' | '-';
|
||||||
/** Forward bids may rise without bound; reverse bids may not exceed the current bid. */
|
/** Lowest value the working bid may reach. */
|
||||||
|
floor: (item: any) => number;
|
||||||
|
/** Highest value the working bid may reach. */
|
||||||
|
ceil: (item: any) => number;
|
||||||
|
/** Next value after pressing +/-, already clamped into [floor, ceil]. */
|
||||||
|
stepUp: (bidValue: number, item: any) => number;
|
||||||
|
stepDown: (bidValue: number, item: any) => number;
|
||||||
|
/** True only when the corresponding button would actually move the value. */
|
||||||
canIncrease: (bidValue: number, item: any) => boolean;
|
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;
|
canDecrease: (bidValue: number, item: any) => boolean;
|
||||||
/** Gate applied while typing a manual (one-time/rebid) amount. */
|
/** Gate applied while typing a manual (one-time/rebid) amount. */
|
||||||
acceptManualInput: (value: number, item: any) => boolean;
|
acceptManualInput: (value: number, item: any) => boolean;
|
||||||
@@ -41,18 +54,67 @@ export type BiddingPatternConfig = {
|
|||||||
showSubmit: (flags: BiddingFlags, item: any) => boolean;
|
showSubmit: (flags: BiddingFlags, item: any) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Amounts arrive from the backend as strings (e.g. auctionFees: "400"), so any
|
||||||
|
* bare `>`/`<`/`+` against them silently string-compares or concatenates. Every
|
||||||
|
* numeric read below goes through `num` so steps, bounds and comparisons are
|
||||||
|
* always real arithmetic; a missing/non-numeric field degrades to 0.
|
||||||
|
*/
|
||||||
|
const num = (v: any): number => {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** First positive numeric value among the given keys, else 0. */
|
||||||
|
const firstPositive = (item: any, keys: string[]): number => {
|
||||||
|
for (const k of keys) {
|
||||||
|
const n = num(item?.[k]);
|
||||||
|
if (n > 0) return n;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The bid-variation amount can arrive under any of these keys depending on the
|
||||||
|
// endpoint; whichever is present and positive is the authoritative step.
|
||||||
|
const STEP_KEYS = [
|
||||||
|
'bidVariationValue',
|
||||||
|
'bidVariation',
|
||||||
|
'variationValue',
|
||||||
|
'bidVariationFigure',
|
||||||
|
'incrementValue',
|
||||||
|
'decrementValue',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The +/- step for an item:
|
||||||
|
* - the configured bid-variation amount when the backend provides one,
|
||||||
|
* - otherwise a flat ₹1, so a misconfigured auction (variation 0/absent) can
|
||||||
|
* never leave the stepper frozen and the user can still nudge the bid.
|
||||||
|
*/
|
||||||
|
const resolveStep = (item: any): number => {
|
||||||
|
const configured = firstPositive(item, STEP_KEYS);
|
||||||
|
return configured > 0 ? configured : 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clamp = (v: number, lo: number, hi: number): number => Math.min(Math.max(v, lo), hi);
|
||||||
|
|
||||||
const forward: BiddingPatternConfig = {
|
const forward: BiddingPatternConfig = {
|
||||||
pattern: 'Forward',
|
pattern: 'Forward',
|
||||||
itemsUrl: FORWARD_AUCTION_ITEM,
|
itemsUrl: FORWARD_AUCTION_ITEM,
|
||||||
startValue: item => item?.auctionStartValueFigure,
|
startValue: item => num(item?.auctionStartValueFigure),
|
||||||
step: item => item?.bidVariationValue,
|
step: item => resolveStep(item),
|
||||||
stepLabel: 'Increment',
|
stepLabel: 'Increment',
|
||||||
stepPrefix: '+',
|
stepPrefix: '+',
|
||||||
canIncrease: () => true,
|
// Forward: outbid upward. Never below the current bid; no upper limit.
|
||||||
canDecrease: (bidValue, item) => bidValue > item.bidAmount,
|
floor: item => num(item?.bidAmount),
|
||||||
|
ceil: () => Number.POSITIVE_INFINITY,
|
||||||
|
stepUp: (bidValue, item) => clamp(num(bidValue) + forward.step(item), forward.floor(item), forward.ceil(item)),
|
||||||
|
stepDown: (bidValue, item) => clamp(num(bidValue) - forward.step(item), forward.floor(item), forward.ceil(item)),
|
||||||
|
canIncrease: (bidValue, item) => forward.stepUp(bidValue, item) > num(bidValue),
|
||||||
|
canDecrease: (bidValue, item) => forward.stepDown(bidValue, item) < num(bidValue),
|
||||||
acceptManualInput: () => true,
|
acceptManualInput: () => true,
|
||||||
validateManualBid: (value, item) =>
|
validateManualBid: (value, item) =>
|
||||||
value < Number(item.bidAmount) ? 'Enter a value greater than the current bid value' : null,
|
num(value) < num(item?.bidAmount) ? 'Enter a value greater than the current bid value' : null,
|
||||||
inlineManualInput: false,
|
inlineManualInput: false,
|
||||||
isBodyFull: (flags, item) => !flags.rebid && item.contractorId == undefined,
|
isBodyFull: (flags, item) => !flags.rebid && item.contractorId == undefined,
|
||||||
showSubmit: (flags, item) => !flags.rebid && item.contractorId == undefined,
|
showSubmit: (flags, item) => !flags.rebid && item.contractorId == undefined,
|
||||||
@@ -61,13 +123,18 @@ const forward: BiddingPatternConfig = {
|
|||||||
const reverse: BiddingPatternConfig = {
|
const reverse: BiddingPatternConfig = {
|
||||||
pattern: 'Reverse',
|
pattern: 'Reverse',
|
||||||
itemsUrl: REVERSE_AUCTION_ITEM,
|
itemsUrl: REVERSE_AUCTION_ITEM,
|
||||||
startValue: item => item?.auctionStartValue,
|
startValue: item => num(item?.auctionStartValue),
|
||||||
step: item => item?.emdAmount,
|
step: item => resolveStep(item),
|
||||||
stepLabel: 'Decrement',
|
stepLabel: 'Decrement',
|
||||||
stepPrefix: '-',
|
stepPrefix: '-',
|
||||||
canIncrease: (bidValue, item) => bidValue < item.bidAmount,
|
// Reverse: undercut downward. Never above the current bid; never below EMD.
|
||||||
canDecrease: (bidValue, item) => bidValue > item.emdAmount,
|
floor: item => num(item?.emdAmount),
|
||||||
acceptManualInput: (value, item) => value >= 0 && value <= Number(item?.auctionStartValue),
|
ceil: item => num(item?.bidAmount) || num(item?.auctionStartValue),
|
||||||
|
stepUp: (bidValue, item) => clamp(num(bidValue) + reverse.step(item), reverse.floor(item), reverse.ceil(item)),
|
||||||
|
stepDown: (bidValue, item) => clamp(num(bidValue) - reverse.step(item), reverse.floor(item), reverse.ceil(item)),
|
||||||
|
canIncrease: (bidValue, item) => reverse.stepUp(bidValue, item) > num(bidValue),
|
||||||
|
canDecrease: (bidValue, item) => reverse.stepDown(bidValue, item) < num(bidValue),
|
||||||
|
acceptManualInput: (value, item) => num(value) >= 0 && num(value) <= num(item?.auctionStartValue),
|
||||||
validateManualBid: () => null,
|
validateManualBid: () => null,
|
||||||
inlineManualInput: true,
|
inlineManualInput: true,
|
||||||
isBodyFull: () => true,
|
isBodyFull: () => true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { View, StyleSheet, Dimensions, ActivityIndicator, ScrollView, TouchableOpacity } from 'react-native';
|
import { View, StyleSheet, Dimensions, ActivityIndicator, ScrollView, TouchableOpacity, ImageBackground } from 'react-native';
|
||||||
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
|
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
|
||||||
import { Layers, ShoppingCart, X, FileText, Menu, Radio, Clock, ChevronRight } from 'lucide-react-native';
|
import { Layers, ShoppingCart, X, FileText, Menu, Radio, Clock, ChevronRight } from 'lucide-react-native';
|
||||||
import { useTheme } from '../../Hooks/useTheme';
|
import { useTheme } from '../../Hooks/useTheme';
|
||||||
@@ -9,12 +9,18 @@ import { getToken } from '../../Redux/Slices/Token';
|
|||||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||||
import AppText from '../../components/AppText';
|
import AppText from '../../components/AppText';
|
||||||
import SidebarModal from '../../components/SidebarModal';
|
import SidebarModal from '../../components/SidebarModal';
|
||||||
|
import ImagePath from '../../Constant/ImagePath';
|
||||||
import NavigationString from '../../Constant/NavigationString';
|
import NavigationString from '../../Constant/NavigationString';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
const { colors } = useTheme();
|
const { colors, isDarkMode } = useTheme();
|
||||||
|
|
||||||
|
// Light mode shows the constellation wallpaper behind the content, so the
|
||||||
|
// cards are made more translucent to let the pattern read through. Dark mode
|
||||||
|
// has no wallpaper, so it keeps the standard glass surface.
|
||||||
|
const cardBg = isDarkMode ? colors.glass : 'rgba(255, 255, 255, 0.45)';
|
||||||
|
|
||||||
const [auctionStats, setAuctionStats] = useState<any>({});
|
const [auctionStats, setAuctionStats] = useState<any>({});
|
||||||
const [tenderStats, setTenderStats] = useState<any>({});
|
const [tenderStats, setTenderStats] = useState<any>({});
|
||||||
@@ -54,7 +60,7 @@ const Home = () => {
|
|||||||
styles.glassCard,
|
styles.glassCard,
|
||||||
styles.halfWidthCard,
|
styles.halfWidthCard,
|
||||||
{
|
{
|
||||||
backgroundColor: colors.glass,
|
backgroundColor: cardBg,
|
||||||
borderColor: colors.overlay,
|
borderColor: colors.overlay,
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
@@ -72,8 +78,13 @@ const Home = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
<ImageBackground
|
||||||
|
source={ImagePath.HomeBg}
|
||||||
|
style={[styles.container, { backgroundColor: colors.background }]}
|
||||||
|
// Dark mode has no wallpaper — hide the cream image, keep the solid bg.
|
||||||
|
imageStyle={isDarkMode ? styles.hiddenBg : undefined}
|
||||||
|
resizeMode="cover"
|
||||||
|
>
|
||||||
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||||
|
|
||||||
{/* Top Bar */}
|
{/* Top Bar */}
|
||||||
@@ -145,7 +156,7 @@ const Home = () => {
|
|||||||
{/* Recent Activity */}
|
{/* Recent Activity */}
|
||||||
<View style={styles.sectionContainer}>
|
<View style={styles.sectionContainer}>
|
||||||
<AppText variant="body" color={colors.text} style={{ fontWeight: 'bold', marginBottom: 10 }}>Recent Activity</AppText>
|
<AppText variant="body" color={colors.text} style={{ fontWeight: 'bold', marginBottom: 10 }}>Recent Activity</AppText>
|
||||||
<View style={[styles.activityCard, { backgroundColor: colors.glass, borderColor: colors.overlay }]}>
|
<View style={[styles.activityCard, { backgroundColor: cardBg, borderColor: colors.overlay }]}>
|
||||||
<View style={[styles.activityIcon, { backgroundColor: colors.overlay }]}>
|
<View style={[styles.activityIcon, { backgroundColor: colors.overlay }]}>
|
||||||
<Clock size={20} color={colors.primary} />
|
<Clock size={20} color={colors.primary} />
|
||||||
</View>
|
</View>
|
||||||
@@ -157,7 +168,7 @@ const Home = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View >
|
</ImageBackground>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -167,6 +178,10 @@ const styles = StyleSheet.create({
|
|||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
// Hides the ImageBackground's image layer in dark mode (no wallpaper there).
|
||||||
|
hiddenBg: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
paddingTop: 40,
|
paddingTop: 40,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
|
|||||||
@@ -1,32 +1,42 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { View, ActivityIndicator, StyleSheet, Text } from "react-native";
|
import { View, ActivityIndicator, StyleSheet, Text } from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { WebView } from "react-native-webview";
|
import { WebView, WebViewNavigation } from "react-native-webview";
|
||||||
import { CommonActions } from "@react-navigation/native";
|
import { CommonActions } from "@react-navigation/native";
|
||||||
import colors from "../../Constant/Color";
|
import colors from "../../Constant/Color";
|
||||||
|
|
||||||
const SUCCESS_DELAY_MS = 5000; // 5 seconds
|
const SUCCESS_DELAY_MS = 5000;
|
||||||
|
const COUNTDOWN_SECONDS = SUCCESS_DELAY_MS / 1000;
|
||||||
|
|
||||||
|
/** Hoisted out of the screen so it keeps a stable component identity across
|
||||||
|
* renders (defining it inline remounts the loader on every render). */
|
||||||
|
const LoadingIndicator = () => (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
<Text style={styles.loadingText}>Loading KYC form...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
export const KycWebViewScreen = ({ route, navigation }: any) => {
|
export const KycWebViewScreen = ({ route, navigation }: any) => {
|
||||||
const webRef = useRef<WebView>(null);
|
const params = route.params ?? {};
|
||||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
// Guarded: a missing `res` used to crash the screen on mount (`data.res.data`).
|
||||||
|
const html: string = params?.res?.data ?? "<h3>No content</h3>";
|
||||||
|
|
||||||
const hasCompletedRef = useRef(false);
|
const hasCompletedRef = useRef(false);
|
||||||
|
const countdownIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const data = route.params ?? {};
|
|
||||||
const html = data.res.data;
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [loadError, setLoadError] = useState<boolean>(false);
|
const [loadError, setLoadError] = useState<boolean>(false);
|
||||||
const [showSuccessMessage, setShowSuccessMessage] = useState<boolean>(false);
|
const [showSuccessMessage, setShowSuccessMessage] = useState<boolean>(false);
|
||||||
const [countdown, setCountdown] = useState<number>(5);
|
const [countdown, setCountdown] = useState<number>(COUNTDOWN_SECONDS);
|
||||||
const url = false;
|
|
||||||
|
|
||||||
|
// Clear BOTH timers on unmount — the countdown interval previously leaked and
|
||||||
|
// called setState on an unmounted component if the user left mid-countdown.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (pollTimerRef.current) {
|
if (countdownIntervalRef.current) {
|
||||||
// Intentionally reads the ref at cleanup; the timer id stored on the ref is exactly what must be cleared.
|
clearInterval(countdownIntervalRef.current);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
clearInterval(pollTimerRef.current);
|
|
||||||
}
|
}
|
||||||
if (successTimeoutRef.current) {
|
if (successTimeoutRef.current) {
|
||||||
clearTimeout(successTimeoutRef.current);
|
clearTimeout(successTimeoutRef.current);
|
||||||
@@ -34,118 +44,79 @@ export const KycWebViewScreen = ({ route, navigation }: any) => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleNavigationBack = () => {
|
const handleNavigationBack = useCallback(() => {
|
||||||
if (data.returnRoute) {
|
if (params.returnRoute) {
|
||||||
navigation.replace(data.returnRoute, data.returnParams);
|
navigation.replace(params.returnRoute, params.returnParams);
|
||||||
} else {
|
} else {
|
||||||
navigation.dispatch(
|
navigation.dispatch(
|
||||||
CommonActions.reset({
|
CommonActions.reset({ index: 0, routes: [{ name: "Login" }] }),
|
||||||
index: 0,
|
|
||||||
routes: [{ name: 'Login' }],
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
}, [navigation, params.returnRoute, params.returnParams]);
|
||||||
|
|
||||||
const startSuccessCountdown = () => {
|
const startSuccessCountdown = useCallback(() => {
|
||||||
setShowSuccessMessage(true);
|
setShowSuccessMessage(true);
|
||||||
setCountdown(5);
|
setCountdown(COUNTDOWN_SECONDS);
|
||||||
|
|
||||||
let remainingSeconds = 5;
|
let remaining = COUNTDOWN_SECONDS;
|
||||||
const countdownInterval = setInterval(() => {
|
countdownIntervalRef.current = setInterval(() => {
|
||||||
remainingSeconds -= 1;
|
remaining -= 1;
|
||||||
setCountdown(remainingSeconds);
|
setCountdown(remaining);
|
||||||
|
if (remaining <= 0 && countdownIntervalRef.current) {
|
||||||
if (remainingSeconds <= 0) {
|
clearInterval(countdownIntervalRef.current);
|
||||||
clearInterval(countdownInterval);
|
countdownIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
successTimeoutRef.current = setTimeout(() => {
|
successTimeoutRef.current = setTimeout(() => {
|
||||||
clearInterval(countdownInterval);
|
if (countdownIntervalRef.current) {
|
||||||
|
clearInterval(countdownIntervalRef.current);
|
||||||
|
countdownIntervalRef.current = null;
|
||||||
|
}
|
||||||
handleNavigationBack();
|
handleNavigationBack();
|
||||||
}, SUCCESS_DELAY_MS);
|
}, SUCCESS_DELAY_MS);
|
||||||
};
|
}, [handleNavigationBack]);
|
||||||
|
|
||||||
const handleLoadStart = (_event: any) => {
|
const handleLoadStart = useCallback(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setLoadError(false);
|
setLoadError(false);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleLoadEnd = (_event: any) => {
|
const handleLoadEnd = useCallback(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleError = (_event: any) => {
|
const handleError = useCallback(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setLoadError(true);
|
setLoadError(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleNavigationStateChange = (navState: any) => {
|
const handleNavigationStateChange = useCallback(
|
||||||
|
(navState: WebViewNavigation) => {
|
||||||
if (
|
if (
|
||||||
navState.title &&
|
navState.title?.includes("E-KYC SETU - Verification Success") &&
|
||||||
navState.title.includes('E-KYC SETU - Verification Success') &&
|
|
||||||
!hasCompletedRef.current
|
!hasCompletedRef.current
|
||||||
) {
|
) {
|
||||||
hasCompletedRef.current = true;
|
hasCompletedRef.current = true;
|
||||||
startSuccessCountdown();
|
startSuccessCountdown();
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[startSuccessCountdown],
|
||||||
const LoadingIndicator = () => (
|
|
||||||
<View style={styles.loadingContainer}>
|
|
||||||
<ActivityIndicator size="large" color={colors.primary || '#2563EB'} />
|
|
||||||
<Text style={styles.loadingText}>Loading KYC form...</Text>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.white }}>
|
<SafeAreaView style={styles.root}>
|
||||||
{url ? (
|
|
||||||
<WebView
|
<WebView
|
||||||
ref={webRef}
|
source={{ html }}
|
||||||
source={{
|
|
||||||
uri: url,
|
|
||||||
baseUrl: "http://192.168.1.15:8080/",
|
|
||||||
}}
|
|
||||||
javaScriptEnabled
|
javaScriptEnabled
|
||||||
domStorageEnabled
|
domStorageEnabled
|
||||||
startInLoadingState
|
startInLoadingState
|
||||||
originWhitelist={["*"]}
|
|
||||||
setSupportMultipleWindows={false}
|
|
||||||
onLoadStart={handleLoadStart}
|
onLoadStart={handleLoadStart}
|
||||||
onLoadEnd={handleLoadEnd}
|
onLoadEnd={handleLoadEnd}
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
onNavigationStateChange={handleNavigationStateChange}
|
onNavigationStateChange={handleNavigationStateChange}
|
||||||
renderLoading={() => <LoadingIndicator />}
|
renderLoading={() => <LoadingIndicator />}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<WebView
|
|
||||||
ref={webRef}
|
|
||||||
source={{
|
|
||||||
html: html ?? "<h3>No content</h3>",
|
|
||||||
// baseUrl: `${BASE_URL}ESetuKYC/initiate/`,
|
|
||||||
}}
|
|
||||||
javaScriptEnabled
|
|
||||||
domStorageEnabled
|
|
||||||
startInLoadingState
|
|
||||||
onLoadStart={handleLoadStart}
|
|
||||||
onLoadEnd={handleLoadEnd}
|
|
||||||
onError={handleError}
|
|
||||||
|
|
||||||
onNavigationStateChange={(navState) => {
|
|
||||||
console.log("Current -- URL:", navState.url);
|
|
||||||
handleNavigationStateChange(navState);
|
|
||||||
}}
|
|
||||||
|
|
||||||
onShouldStartLoadWithRequest={(request) => {
|
|
||||||
console.log("Request URL:", request.url);
|
|
||||||
return true;
|
|
||||||
}}
|
|
||||||
|
|
||||||
renderLoading={() => <LoadingIndicator />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading && !showSuccessMessage && <LoadingIndicator />}
|
{isLoading && !showSuccessMessage && <LoadingIndicator />}
|
||||||
|
|
||||||
@@ -162,8 +133,8 @@ export const KycWebViewScreen = ({ route, navigation }: any) => {
|
|||||||
</Text>
|
</Text>
|
||||||
<ActivityIndicator
|
<ActivityIndicator
|
||||||
size="small"
|
size="small"
|
||||||
color={colors.primary || '#2563EB'}
|
color={colors.primary}
|
||||||
style={{ marginTop: 16 }}
|
style={styles.successSpinner}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -182,6 +153,10 @@ export const KycWebViewScreen = ({ route, navigation }: any) => {
|
|||||||
export default KycWebViewScreen;
|
export default KycWebViewScreen;
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.white,
|
||||||
|
},
|
||||||
loadingContainer: {
|
loadingContainer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -248,6 +223,9 @@ const styles = StyleSheet.create({
|
|||||||
color: '#2563EB',
|
color: '#2563EB',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
successSpinner: {
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
errorContainer: {
|
errorContainer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|||||||
+68
-48
@@ -13,7 +13,7 @@ import { useTheme } from '../../Hooks/useTheme';
|
|||||||
import { setToken } from '../../Redux/Slices/Token';
|
import { setToken } from '../../Redux/Slices/Token';
|
||||||
import NavigationString from '../../Constant/NavigationString';
|
import NavigationString from '../../Constant/NavigationString';
|
||||||
import ImagePath from '../../Constant/ImagePath';
|
import ImagePath from '../../Constant/ImagePath';
|
||||||
import { showErrorAlert, showToast } from '../../Constant/ShowDailog';
|
import { showErrorAlert, showSuccessAlert, showToast } from '../../Constant/ShowDailog';
|
||||||
import AppBackground from '../../components/AppBackground';
|
import AppBackground from '../../components/AppBackground';
|
||||||
import FontsFamily from '../../Constant/FontsFamily';
|
import FontsFamily from '../../Constant/FontsFamily';
|
||||||
import { useLogin, useRagistrationState } from '../../Services/ApiCall/Hooks';
|
import { useLogin, useRagistrationState } from '../../Services/ApiCall/Hooks';
|
||||||
@@ -35,6 +35,56 @@ const CAROUSEL_TEXTS = [
|
|||||||
{ title: "Secure & Reliable", subtitle: "Your data is protected with enterprise-grade security." },
|
{ title: "Secure & Reliable", subtitle: "Your data is protected with enterprise-grade security." },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hoisted to module scope. Defining it inside Login gave it a new identity on
|
||||||
|
* every render (i.e. every keystroke via Formik), which remounted the TextInput
|
||||||
|
* and dismissed the keyboard after each character. It only uses props + the
|
||||||
|
* module-level `styles`, so it is safe at module scope.
|
||||||
|
*/
|
||||||
|
const MinimalInput = ({ placeholder, value, onChangeText, onBlur, icon, secureTextEntry, rightIcon, onRightIconPress }: any) => {
|
||||||
|
const focus = useSharedValue(0);
|
||||||
|
const { colors: themeColors, isDarkMode } = useTheme();
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
const borderColor = interpolateColor(focus.value, [0, 1], [isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)', themeColors.primary]);
|
||||||
|
const borderWidth = interpolate(focus.value, [0, 1], [1, 1.5]);
|
||||||
|
const backgroundColor = interpolateColor(focus.value, [0, 1], [isDarkMode ? 'rgba(30, 41, 59, 0.4)' : 'rgba(255, 255, 255, 0.6)', isDarkMode ? 'rgba(30, 41, 59, 0.8)' : 'rgba(255, 255, 255, 0.95)']);
|
||||||
|
|
||||||
|
return {
|
||||||
|
borderColor,
|
||||||
|
borderWidth,
|
||||||
|
backgroundColor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.modernInputWrapper, animatedStyle]}>
|
||||||
|
<View style={styles.inputIcon}>{icon}</View>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.textInput, { color: themeColors.text }]}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor={themeColors.text + '80'} // 50% opacity
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
onBlur={(e) => {
|
||||||
|
focus.value = withTiming(0);
|
||||||
|
onBlur(e);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
focus.value = withTiming(1);
|
||||||
|
}}
|
||||||
|
secureTextEntry={secureTextEntry}
|
||||||
|
cursorColor={themeColors.primary}
|
||||||
|
/>
|
||||||
|
{rightIcon && (
|
||||||
|
<TouchableOpacity onPress={onRightIconPress} style={styles.inputRightIcon}>
|
||||||
|
{rightIcon}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const { colors, isDarkMode } = useTheme();
|
const { colors, isDarkMode } = useTheme();
|
||||||
const navigation = useNavigation<any>();
|
const navigation = useNavigation<any>();
|
||||||
@@ -99,9 +149,10 @@ const Login = () => {
|
|||||||
const handleSuccess = (res: any) => {
|
const handleSuccess = (res: any) => {
|
||||||
dispatch(setToken({ ...res.data, token: res.token, userType: 'tender' }));
|
dispatch(setToken({ ...res.data, token: res.token, userType: 'tender' }));
|
||||||
if (res.statusCode === 200) {
|
if (res.statusCode === 200) {
|
||||||
|
showSuccessAlert(res.message || 'Logged in successfully');
|
||||||
handleGetData()
|
handleGetData()
|
||||||
} else {
|
} else {
|
||||||
showErrorAlert(res.message);
|
showErrorAlert(res.message || 'Login failed');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -121,51 +172,6 @@ const Login = () => {
|
|||||||
// the flicker/glitch the user sees.
|
// the flicker/glitch the user sees.
|
||||||
const isLoading = loginMutation.isPending || isResolvingRoute;
|
const isLoading = loginMutation.isPending || isResolvingRoute;
|
||||||
|
|
||||||
// Minimal Input Component (Enhanced for better aesthetics)
|
|
||||||
const MinimalInput = ({ placeholder, value, onChangeText, onBlur, icon, secureTextEntry, rightIcon, onRightIconPress }: any) => {
|
|
||||||
const focus = useSharedValue(0);
|
|
||||||
const { colors: themeColors, isDarkMode } = useTheme();
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
|
||||||
const borderColor = interpolateColor(focus.value, [0, 1], [isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)', themeColors.primary]);
|
|
||||||
const borderWidth = interpolate(focus.value, [0, 1], [1, 1.5]);
|
|
||||||
const backgroundColor = interpolateColor(focus.value, [0, 1], [isDarkMode ? 'rgba(30, 41, 59, 0.4)' : 'rgba(255, 255, 255, 0.6)', isDarkMode ? 'rgba(30, 41, 59, 0.8)' : 'rgba(255, 255, 255, 0.95)']);
|
|
||||||
|
|
||||||
return {
|
|
||||||
borderColor,
|
|
||||||
borderWidth,
|
|
||||||
backgroundColor,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View style={[styles.modernInputWrapper, animatedStyle]}>
|
|
||||||
<View style={styles.inputIcon}>{icon}</View>
|
|
||||||
<TextInput
|
|
||||||
style={[styles.textInput, { color: themeColors.text }]}
|
|
||||||
placeholder={placeholder}
|
|
||||||
placeholderTextColor={themeColors.text + '80'} // 50% opacity
|
|
||||||
value={value}
|
|
||||||
onChangeText={onChangeText}
|
|
||||||
onBlur={(e) => {
|
|
||||||
focus.value = withTiming(0);
|
|
||||||
onBlur(e);
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
focus.value = withTiming(1);
|
|
||||||
}}
|
|
||||||
secureTextEntry={secureTextEntry}
|
|
||||||
cursorColor={themeColors.primary}
|
|
||||||
/>
|
|
||||||
{rightIcon && (
|
|
||||||
<TouchableOpacity onPress={onRightIconPress} style={styles.inputRightIcon}>
|
|
||||||
{rightIcon}
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -237,13 +243,27 @@ const Login = () => {
|
|||||||
{/* FORM SECTION WITH GRADIENT GLASS CARD - AT BOTTOM */}
|
{/* FORM SECTION WITH GRADIENT GLASS CARD - AT BOTTOM */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<Animated.View style={[styles.glassCard, animatedFormStyle]}>
|
<Animated.View style={[styles.glassCard, animatedFormStyle]}>
|
||||||
{/* Glass Blur Layer */}
|
{/* Glass base layer.
|
||||||
|
iOS gets the real native blur. On Android @react-native-community/blur
|
||||||
|
is unreliable — it can blur/overlay the whole window instead of
|
||||||
|
clipping to this card and leave a persistent frosted veil over the
|
||||||
|
app after the screen unmounts. So Android uses a plain translucent
|
||||||
|
fill (the gradients below still give the glass sheen). */}
|
||||||
|
{Platform.OS === 'ios' ? (
|
||||||
<BlurView
|
<BlurView
|
||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
blurType={isDarkMode ? "dark" : "light"}
|
blurType={isDarkMode ? "dark" : "light"}
|
||||||
blurAmount={20}
|
blurAmount={20}
|
||||||
reducedTransparencyFallbackColor={isDarkMode ? "rgba(15, 23, 42, 0.5)" : "rgba(255, 255, 255, 0.5)"}
|
reducedTransparencyFallbackColor={isDarkMode ? "rgba(15, 23, 42, 0.5)" : "rgba(255, 255, 255, 0.5)"}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
StyleSheet.absoluteFill,
|
||||||
|
{ backgroundColor: isDarkMode ? 'rgba(15, 23, 42, 0.78)' : 'rgba(255, 255, 255, 0.86)' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Gradient Background Layer */}
|
{/* Gradient Background Layer */}
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Animated, { FadeInDown, FadeOutUp } from 'react-native-reanimated';
|
|||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { useFetchLiveTender } from '../../Services/ApiCall/Hooks';
|
import { useFetchLiveTender } from '../../Services/ApiCall/Hooks';
|
||||||
import { SearchInput } from '../../components/SearchInput';
|
import { SearchInput } from '../../components/SearchInput';
|
||||||
import { Skeleton } from '../../components';
|
import { Skeleton, EmptyState } from '../../components';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { togglePurchaseTender } from '../../Redux/Slices/SelectedPurchaseTender';
|
import { togglePurchaseTender } from '../../Redux/Slices/SelectedPurchaseTender';
|
||||||
import LiveTenderTile from './LiveTenderTile';
|
import LiveTenderTile from './LiveTenderTile';
|
||||||
@@ -133,10 +133,16 @@ const LiveTender = () => {
|
|||||||
keyExtractor={(item) => String(item.tenderId)}
|
keyExtractor={(item) => String(item.tenderId)}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={{ paddingBottom: 120, paddingTop: 10 }}
|
contentContainerStyle={{ flexGrow: 1, paddingBottom: 120, paddingTop: 10 }}
|
||||||
onEndReached={loadMore}
|
onEndReached={loadMore}
|
||||||
onEndReachedThreshold={0.2}
|
onEndReachedThreshold={0.2}
|
||||||
ListFooterComponent={renderFooter}
|
ListFooterComponent={renderFooter}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<EmptyState
|
||||||
|
title="No live tenders found"
|
||||||
|
subtitle="New live tenders will appear here as they open."
|
||||||
|
/>
|
||||||
|
}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={isRefetching}
|
refreshing={isRefetching}
|
||||||
|
|||||||
@@ -229,10 +229,6 @@ const LiveTenderDetail = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isLive =
|
|
||||||
tenderData?.tenderStatus?.toLowerCase() === 'live' ||
|
|
||||||
tenderData?.tenderStatus?.toLowerCase() === 'open';
|
|
||||||
|
|
||||||
const formatDateTime = (val?: string) => {
|
const formatDateTime = (val?: string) => {
|
||||||
if (!val) return '-';
|
if (!val) return '-';
|
||||||
return moment(val, 'DD-MM-YYYY HH:mm:ss').isValid()
|
return moment(val, 'DD-MM-YYYY HH:mm:ss').isValid()
|
||||||
@@ -255,15 +251,6 @@ const LiveTenderDetail = () => {
|
|||||||
Ref: {tenderData?.tenderNumber || '-'}
|
Ref: {tenderData?.tenderNumber || '-'}
|
||||||
</AppText>
|
</AppText>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.liveBox}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.dot,
|
|
||||||
{ backgroundColor: isLive ? colors.success : colors.textSecondary },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<AppText variant="bodySmall" style={styles.liveText}>{isLive ? 'LIVE' : 'CLOSED'}</AppText>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
|
ActivityIndicator,
|
||||||
Platform,
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import Modal from 'react-native-modal';
|
import Modal from 'react-native-modal';
|
||||||
@@ -29,23 +30,14 @@ interface Props {
|
|||||||
data?: any;
|
data?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TenderFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
// Hoisted to module scope: defining these inside the component gave them a new
|
||||||
|
// identity every render, remounting the entire detail subtree each time.
|
||||||
|
const formatDate = (date?: string) => {
|
||||||
const inset = useSafeAreaInsets();
|
|
||||||
const { data: tenderResponse }: any = useFetchTenderById(data?.tenderId);
|
|
||||||
const tender = tenderResponse?.data?.[0];
|
|
||||||
|
|
||||||
const formatDate = (date?: string) => {
|
|
||||||
if (!date) return '-';
|
if (!date) return '-';
|
||||||
return moment(date, 'DD-MM-YYYY HH:mm:ss').format('DD MMM YYYY, hh:mm A');
|
return moment(date, 'DD-MM-YYYY HH:mm:ss').format('DD MMM YYYY, hh:mm A');
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!tender) return null;
|
const Section = ({ title, icon: Icon, children }: any) => (
|
||||||
|
|
||||||
const isLive = tender?.liveStatus === true;
|
|
||||||
|
|
||||||
const Section = ({ title, icon: Icon, children }: any) => (
|
|
||||||
<View style={styles.sectionContainer}>
|
<View style={styles.sectionContainer}>
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
<Icon size={20} color={colors.primary} />
|
<Icon size={20} color={colors.primary} />
|
||||||
@@ -53,22 +45,30 @@ const TenderFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
|||||||
</View>
|
</View>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
const InfoBox = ({ label, value, full = false }: any) => (
|
const InfoBox = ({ label, value, full = false }: any) => (
|
||||||
<View style={[styles.infoBox, full && { width: '100%' }]}>
|
<View style={[styles.infoBox, full && { width: '100%' }]}>
|
||||||
<Text style={styles.label}>{label}</Text>
|
<Text style={styles.label}>{label}</Text>
|
||||||
<Text style={styles.value}>{value || '-'}</Text>
|
<Text style={styles.value}>{value || '-'}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const TenderFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||||
|
const inset = useSafeAreaInsets();
|
||||||
|
const { data: tenderResponse }: any = useFetchTenderById(data?.tenderId);
|
||||||
|
const tender = tenderResponse?.data?.[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
isVisible={visible}
|
isVisible={visible}
|
||||||
|
onBackButtonPress={onClose}
|
||||||
|
onBackdropPress={onClose}
|
||||||
style={styles.fullModal}
|
style={styles.fullModal}
|
||||||
animationIn="slideInRight"
|
animationIn="slideInRight"
|
||||||
animationOut="slideOutRight"
|
animationOut="slideOutRight"
|
||||||
useNativeDriver
|
useNativeDriver
|
||||||
|
useNativeDriverForBackdrop
|
||||||
statusBarTranslucent
|
statusBarTranslucent
|
||||||
>
|
>
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<SafeAreaView style={styles.safeArea}>
|
||||||
@@ -80,20 +80,11 @@ const TenderFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
|||||||
|
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={styles.headerTitle}>Tender Overview</Text>
|
<Text style={styles.headerTitle}>Tender Overview</Text>
|
||||||
<Text style={styles.headerSub}>Ref: {tender?.tenderNumber}</Text>
|
<Text style={styles.headerSub}>Ref: {tender?.tenderNumber ?? '—'}</Text>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.liveBox}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.dot,
|
|
||||||
{ backgroundColor: isLive ? '#22c55e' : '#94a3b8' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Text style={styles.liveText}>{isLive ? 'LIVE' : 'CLOSED'}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{tender ? (
|
||||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||||
|
|
||||||
{/* Basic Details */}
|
{/* Basic Details */}
|
||||||
@@ -200,6 +191,11 @@ const TenderFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<View style={styles.loadingState}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
@@ -214,6 +210,12 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
},
|
},
|
||||||
|
loadingState: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import PurchaseTenderTile from './PurchaseTenderTile';
|
|||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { setTender } from '../../Redux/Slices/SelectedTender';
|
import { setTender } from '../../Redux/Slices/SelectedTender';
|
||||||
import { SearchInput } from '../../components/SearchInput';
|
import { SearchInput } from '../../components/SearchInput';
|
||||||
import { Skeleton } from '../../components';
|
import { Skeleton, EmptyState } from '../../components';
|
||||||
import { allBooleanProps } from './types/types';
|
import { allBooleanProps } from './types/types';
|
||||||
import FilterModal from './Modal/FilterModal';
|
import FilterModal from './Modal/FilterModal';
|
||||||
import colors from '../../Constant/Color';
|
import colors from '../../Constant/Color';
|
||||||
@@ -118,12 +118,14 @@ const PurchaseTenderAllList = () => {
|
|||||||
data={data?.result ?? []}
|
data={data?.result ?? []}
|
||||||
keyExtractor={(item) => String(item.tenderId || item.id)}
|
keyExtractor={(item) => String(item.tenderId || item.id)}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={{ paddingBottom: 200, paddingHorizontal: 10 }}
|
contentContainerStyle={{ flexGrow: 1, paddingBottom: 200, paddingHorizontal: 10 }}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<PurchaseTenderTile
|
<PurchaseTenderTile
|
||||||
item={item}
|
item={item}
|
||||||
onPressDetails={() => {
|
onPressDetails={() => {
|
||||||
navigation.navigate('TenderDetail', { item, onAddToPurchase: () => { }, routeFrom: 'PurchasedTender' });
|
// LiveTenderDetail reads a flat tender object from route.params
|
||||||
|
// (route.params.tenderId / .tenderStatus), same as LiveTenderTile.
|
||||||
|
navigation.navigate(NavigationString.LiveTenderDetail, item);
|
||||||
}}
|
}}
|
||||||
onStartBidding={() => {
|
onStartBidding={() => {
|
||||||
Dispatch(setTender(item))
|
Dispatch(setTender(item))
|
||||||
@@ -136,6 +138,12 @@ const PurchaseTenderAllList = () => {
|
|||||||
onEndReached={loadMore}
|
onEndReached={loadMore}
|
||||||
onEndReachedThreshold={0.6}
|
onEndReachedThreshold={0.6}
|
||||||
ListFooterComponent={renderFooter}
|
ListFooterComponent={renderFooter}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<EmptyState
|
||||||
|
title="No purchased tenders found"
|
||||||
|
subtitle="Tenders you purchase will appear here."
|
||||||
|
/>
|
||||||
|
}
|
||||||
refreshing={isRefetching}
|
refreshing={isRefetching}
|
||||||
onRefresh={refetch}
|
onRefresh={refetch}
|
||||||
/>}
|
/>}
|
||||||
|
|||||||
@@ -169,7 +169,6 @@ const TenderItem = ({ item, index, expanded, toggleExpand, colors, styles }: any
|
|||||||
const Dispatch = useDispatch();
|
const Dispatch = useDispatch();
|
||||||
const isOpen = item.tenderStatus === 'Opened';
|
const isOpen = item.tenderStatus === 'Opened';
|
||||||
const chevronStyle = useChevronStyle(expanded);
|
const chevronStyle = useChevronStyle(expanded);
|
||||||
console.log(item, "itemmmmmmm");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ const Tender = () => {
|
|||||||
const isAuctionUser = reduxToken?.userType?.toLowerCase() === 'auction';
|
const isAuctionUser = reduxToken?.userType?.toLowerCase() === 'auction';
|
||||||
const cartCount = SelectedPurchaseList.length;
|
const cartCount = SelectedPurchaseList.length;
|
||||||
|
|
||||||
const styles = makeStyles(colors, spacing, shadows, TAB_WIDTH, TAB_HEIGHT);
|
const styles = useMemo(
|
||||||
|
() => makeStyles(colors, spacing, shadows, TAB_WIDTH, TAB_HEIGHT),
|
||||||
|
[colors, spacing, shadows, TAB_WIDTH],
|
||||||
|
);
|
||||||
|
|
||||||
if (isAuctionUser) {
|
if (isAuctionUser) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import React, { useState } from 'react';
|
import React, { useCallback, useRef, useState } from 'react';
|
||||||
import Animated, { FadeInUp, FadeInDown } from 'react-native-reanimated';
|
import Animated, { FadeInUp, FadeInDown } from 'react-native-reanimated';
|
||||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
import { useFocusEffect, useNavigation, useRoute } from '@react-navigation/native';
|
||||||
import { pick, types } from '@react-native-documents/picker';
|
import { pick, types } from '@react-native-documents/picker';
|
||||||
import { InsideHeader } from '../../../components';
|
import { InsideHeader } from '../../../components';
|
||||||
import { useTheme } from '../../../Hooks/useTheme';
|
import { useTheme } from '../../../Hooks/useTheme';
|
||||||
@@ -43,11 +43,27 @@ const TenderBidding = () => {
|
|||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { colors, spacing, shadows } = useTheme();
|
const { colors, spacing, shadows } = useTheme();
|
||||||
|
|
||||||
const { data: tenderResponse }: any = useFetchTenderById(route.params?.tenderId);
|
const { data: tenderResponse, refetch: refetchTenderById }: any = useFetchTenderById(route.params?.tenderId);
|
||||||
const bidData = tenderResponse?.data;
|
const bidData = tenderResponse?.data;
|
||||||
const { data: tender }: any = useProceedToBid(route.params?.tenderId);
|
const { data: tender, refetch: refetchProceedToBid }: any = useProceedToBid(route.params?.tenderId);
|
||||||
const tenderData = tender?.data;
|
const tenderData = tender?.data;
|
||||||
|
|
||||||
|
// Refresh submission status whenever this screen regains focus — e.g. after
|
||||||
|
// the Envelope-C editor submits and navigates back, the envelope should flip
|
||||||
|
// to "Submitted" with no manual refresh. Skip the first focus (initial
|
||||||
|
// mount) so we don't double-fetch what the queries just loaded.
|
||||||
|
const isFirstFocus = useRef(true);
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
if (isFirstFocus.current) {
|
||||||
|
isFirstFocus.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refetchProceedToBid();
|
||||||
|
refetchTenderById();
|
||||||
|
}, [refetchProceedToBid, refetchTenderById]),
|
||||||
|
);
|
||||||
|
|
||||||
const [activeEnvelope, setActiveEnvelope] = useState<EnvelopeType>('A');
|
const [activeEnvelope, setActiveEnvelope] = useState<EnvelopeType>('A');
|
||||||
const [envelopForm, setEnvelopForm] = useState<EnvelopeFormState>({
|
const [envelopForm, setEnvelopForm] = useState<EnvelopeFormState>({
|
||||||
A: {},
|
A: {},
|
||||||
@@ -174,8 +190,8 @@ const TenderBidding = () => {
|
|||||||
});
|
});
|
||||||
if (res?.statusCode === 200) {
|
if (res?.statusCode === 200) {
|
||||||
logger.info('envelope', `[submit] ===== envelope ${envelopeType} submitted successfully =====`);
|
logger.info('envelope', `[submit] ===== envelope ${envelopeType} submitted successfully =====`);
|
||||||
|
// Success toast is emitted globally by the HTTP client (api/success).
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
ToastAndroid.show(res.message, ToastAndroid.SHORT);
|
|
||||||
} else {
|
} else {
|
||||||
logger.warn('envelope', `[submit] envelope ${envelopeType} submission rejected by server`, {
|
logger.warn('envelope', `[submit] envelope ${envelopeType} submission rejected by server`, {
|
||||||
statusCode: res?.statusCode,
|
statusCode: res?.statusCode,
|
||||||
@@ -228,8 +244,8 @@ const TenderBidding = () => {
|
|||||||
});
|
});
|
||||||
if (res?.statusCode === 200) {
|
if (res?.statusCode === 200) {
|
||||||
logger.info('envelope', `[submit] ===== ${activeEnvelope} submitted successfully =====`);
|
logger.info('envelope', `[submit] ===== ${activeEnvelope} submitted successfully =====`);
|
||||||
|
// Success toast is emitted globally by the HTTP client (api/success).
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
ToastAndroid.show(res.message || `${activeEnvelope} submitted successfully`, ToastAndroid.SHORT);
|
|
||||||
} else {
|
} else {
|
||||||
logger.warn('envelope', `[submit] ${activeEnvelope} submission rejected by server`, {
|
logger.warn('envelope', `[submit] ${activeEnvelope} submission rejected by server`, {
|
||||||
statusCode: res?.statusCode,
|
statusCode: res?.statusCode,
|
||||||
@@ -272,6 +288,18 @@ const TenderBidding = () => {
|
|||||||
const activeEnvelopeList = tenderData?.[`tenderEnvelope${activeEnvelope}List`] || [];
|
const activeEnvelopeList = tenderData?.[`tenderEnvelope${activeEnvelope}List`] || [];
|
||||||
const qcbsQuestions = tenderData?.tenderEnvelopeQcbsList?.questions || [];
|
const qcbsQuestions = tenderData?.tenderEnvelopeQcbsList?.questions || [];
|
||||||
|
|
||||||
|
// Per-envelope submission status (LCS/QCBS share the QCBS envelope).
|
||||||
|
const isEnvelopeSubmitted = (type: EnvelopeType): boolean => {
|
||||||
|
switch (type) {
|
||||||
|
case 'A': return !!tenderData?.envelopeASubmittStatus;
|
||||||
|
case 'B': return !!tenderData?.envelopeBSubmittStatus;
|
||||||
|
case 'C': return !!tenderData?.envelopeCSubmittStatus;
|
||||||
|
case 'LCS':
|
||||||
|
case 'QCBS': return !!tenderData?.envelopeQcbsSubmittStatus;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.root, { backgroundColor: colors.background }]}>
|
<View style={[styles.root, { backgroundColor: colors.background }]}>
|
||||||
<InsideHeader title="Tender Bidding" showArrow />
|
<InsideHeader title="Tender Bidding" showArrow />
|
||||||
@@ -348,6 +376,7 @@ const TenderBidding = () => {
|
|||||||
key={tab.type}
|
key={tab.type}
|
||||||
title={tab.title}
|
title={tab.title}
|
||||||
isActive={activeEnvelope === tab.type}
|
isActive={activeEnvelope === tab.type}
|
||||||
|
isSubmitted={isEnvelopeSubmitted(tab.type)}
|
||||||
onPress={() => setActiveEnvelope(tab.type)}
|
onPress={() => setActiveEnvelope(tab.type)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,33 +1,77 @@
|
|||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||||
|
import Svg, { Path } from 'react-native-svg';
|
||||||
import { useTheme } from '../../../../Hooks/useTheme';
|
import { useTheme } from '../../../../Hooks/useTheme';
|
||||||
import AppText from '../../../../components/AppText';
|
import AppText from '../../../../components/AppText';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string;
|
title: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
isSubmitted?: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EnvelopeButton = ({ title, isActive, onPress }: Props) => {
|
const CheckIcon = ({ color }: { color: string }) => (
|
||||||
|
<Svg width={11} height={11} viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path
|
||||||
|
d="M20 6L9 17l-5-5"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={3.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selectable envelope tile. Doubles as a tab (active = filled primary) and a
|
||||||
|
* status indicator: a submitted envelope carries a green check badge + a
|
||||||
|
* "Submitted" label and a green-tinted surface, so completed envelopes are
|
||||||
|
* distinguishable from pending ones at a glance.
|
||||||
|
*/
|
||||||
|
const EnvelopeButton = ({ title, isActive, isSubmitted = false, onPress }: Props) => {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
const backgroundColor = isActive
|
||||||
|
? colors.primary
|
||||||
|
: isSubmitted
|
||||||
|
? `${colors.success}14`
|
||||||
|
: colors.surface;
|
||||||
|
const borderColor = isActive ? colors.primary : isSubmitted ? colors.success : colors.border;
|
||||||
|
const titleColor = isActive ? colors.buttonText : colors.primary;
|
||||||
|
const statusColor = isActive
|
||||||
|
? colors.buttonText
|
||||||
|
: isSubmitted
|
||||||
|
? colors.success
|
||||||
|
: colors.textSecondary;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={[
|
style={[styles.button, { borderColor, backgroundColor }]}
|
||||||
styles.button,
|
activeOpacity={0.75}
|
||||||
{ borderColor: colors.border, backgroundColor: colors.surface },
|
|
||||||
isActive && { backgroundColor: colors.primary, borderColor: colors.primary },
|
|
||||||
]}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<AppText
|
|
||||||
variant="bodySmall"
|
|
||||||
color={isActive ? colors.buttonText : colors.primary}
|
|
||||||
style={styles.title as any}
|
|
||||||
>
|
>
|
||||||
|
<AppText variant="bodySmall" color={titleColor} style={styles.title as any}>
|
||||||
{title}
|
{title}
|
||||||
</AppText>
|
</AppText>
|
||||||
|
|
||||||
|
<View style={styles.statusRow}>
|
||||||
|
{isSubmitted ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.checkCircle,
|
||||||
|
{ backgroundColor: isActive ? 'rgba(255,255,255,0.25)' : colors.success },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CheckIcon color={isActive ? colors.buttonText : '#FFFFFF'} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.dot, { backgroundColor: statusColor, opacity: isActive ? 0.85 : 0.5 }]} />
|
||||||
|
)}
|
||||||
|
<AppText variant="caption" color={statusColor} style={styles.statusText as any}>
|
||||||
|
{isSubmitted ? 'Submitted' : 'Pending'}
|
||||||
|
</AppText>
|
||||||
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -36,12 +80,31 @@ const styles = StyleSheet.create({
|
|||||||
button: {
|
button: {
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
borderRadius: 20,
|
borderRadius: 16,
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
minWidth: 100,
|
minWidth: 130,
|
||||||
alignItems: 'center',
|
alignItems: 'flex-start',
|
||||||
},
|
},
|
||||||
title: { fontWeight: '700' } as any,
|
title: { fontWeight: '700' } as any,
|
||||||
|
statusRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 5,
|
||||||
|
gap: 5,
|
||||||
|
},
|
||||||
|
checkCircle: {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
statusText: { fontWeight: '600' } as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default memo(EnvelopeButton);
|
export default memo(EnvelopeButton);
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } 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';
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable `select` for the infinite-query lists. Hoisted to module scope so React
|
||||||
|
* Query can memoize its output — an inline `select` arrow is a new reference on
|
||||||
|
* every render, which forces `flatMap` to re-run over every loaded page each
|
||||||
|
* render (cost grows with pages loaded).
|
||||||
|
*/
|
||||||
|
const selectFlattenedResult = (data: any) => ({
|
||||||
|
...data,
|
||||||
|
result: data.pages.flatMap((page: any) => page?.data ?? []),
|
||||||
|
});
|
||||||
|
|
||||||
// User
|
// User
|
||||||
|
|
||||||
export const useLogin = () => {
|
export const useLogin = () => {
|
||||||
@@ -46,12 +57,7 @@ export const useFetchPurchaseTender = () => {
|
|||||||
return nextPage < totalPages ? nextPage : undefined;
|
return nextPage < totalPages ? nextPage : undefined;
|
||||||
},
|
},
|
||||||
|
|
||||||
select: (data) => ({
|
select: selectFlattenedResult,
|
||||||
...data,
|
|
||||||
result: data.pages.flatMap(
|
|
||||||
(page: any) => page?.data ?? []
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,12 +79,7 @@ export const useFetchLiveTender = () => {
|
|||||||
return nextPage < totalPages ? nextPage : undefined;
|
return nextPage < totalPages ? nextPage : undefined;
|
||||||
},
|
},
|
||||||
|
|
||||||
select: (data) => ({
|
select: selectFlattenedResult,
|
||||||
...data,
|
|
||||||
result: data.pages.flatMap(
|
|
||||||
(page: any) => page?.data ?? []
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
export const useContractorProfile = () => {
|
export const useContractorProfile = () => {
|
||||||
@@ -109,21 +110,12 @@ export const useFetchLiveAuctionPage = (payload: any) => {
|
|||||||
|
|
||||||
getNextPageParam: (lastPage, allPages) => {
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
const { size, totalCount } = lastPage;
|
const { size, totalCount } = lastPage;
|
||||||
const totalPages = Math.ceil(totalCount / size);
|
const totalPages = Math.ceil(totalCount / (size || 10));
|
||||||
const nextPage = allPages.length;
|
const nextPage = allPages.length;
|
||||||
return nextPage < totalPages ? nextPage : undefined;
|
return nextPage < totalPages ? nextPage : undefined;
|
||||||
},
|
},
|
||||||
|
|
||||||
select: (data) => {
|
select: selectFlattenedResult,
|
||||||
const mergedResults = data.pages.flatMap(
|
|
||||||
(page: any) => page?.data ?? []
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
result: mergedResults,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -139,21 +131,12 @@ export const useFetchLiveAuction = () => {
|
|||||||
|
|
||||||
getNextPageParam: (lastPage, allPages) => {
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
const { size, totalCount } = lastPage;
|
const { size, totalCount } = lastPage;
|
||||||
const totalPages = Math.ceil(totalCount / size);
|
const totalPages = Math.ceil(totalCount / (size || 10));
|
||||||
const nextPage = allPages.length;
|
const nextPage = allPages.length;
|
||||||
return nextPage < totalPages ? nextPage : undefined;
|
return nextPage < totalPages ? nextPage : undefined;
|
||||||
},
|
},
|
||||||
|
|
||||||
select: (data) => {
|
select: selectFlattenedResult,
|
||||||
const mergedResults = data.pages.flatMap(
|
|
||||||
(page: any) => page?.data ?? []
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
result: mergedResults,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Rendered above the body list inside the horizontal scroller so it pans with
|
* 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.
|
* the columns but never scrolls vertically. Tapping a letter selects the column.
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import { SheetModel } from '../types';
|
import { SheetModel } from '../types';
|
||||||
import { GridTheme } from '../hooks/useGridTheme';
|
import { GridTheme } from '../hooks/useGridTheme';
|
||||||
@@ -21,7 +21,7 @@ export interface ColumnHeaderRowProps {
|
|||||||
onSelectColumn: (col: number) => void;
|
onSelectColumn: (col: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ColumnHeaderRow: React.FC<ColumnHeaderRowProps> = ({
|
const ColumnHeaderRowBase: React.FC<ColumnHeaderRowProps> = ({
|
||||||
sheet, cols, width, gridTheme, activeCol, selStartCol, selEndCol, onSelectColumn,
|
sheet, cols, width, gridTheme, activeCol, selStartCol, selEndCol, onSelectColumn,
|
||||||
}) => (
|
}) => (
|
||||||
<View style={[styles.row, { width, height: HEADER_HEIGHT, backgroundColor: gridTheme.headerBg }]}>
|
<View style={[styles.row, { width, height: HEADER_HEIGHT, backgroundColor: gridTheme.headerBg }]}>
|
||||||
@@ -50,6 +50,13 @@ export const ColumnHeaderRow: React.FC<ColumnHeaderRowProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memoized: the grid re-renders on every cell tap / arrow move / edit / undo,
|
||||||
|
* but the header only changes when column selection moves. All props are stable
|
||||||
|
* references, so shallow compare skips the 200+ column rebuild on row-only renders.
|
||||||
|
*/
|
||||||
|
export const ColumnHeaderRow = memo(ColumnHeaderRowBase);
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
row: { flexDirection: 'row' },
|
row: { flexDirection: 'row' },
|
||||||
cell: {
|
cell: {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* DataRow — renders one worksheet row's visible cells, honouring merged spans.
|
* DataRow — renders one worksheet row's visible cells, honouring merged spans.
|
||||||
* Derives each cell's primitive props and defers to the memoized `CellView`.
|
* Derives each cell's primitive props and defers to the memoized `CellView`.
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { View } from 'react-native';
|
import { View } from 'react-native';
|
||||||
import { Address, CellModel, CellRange, SheetModel, ValidationError, cellKey } from '../types';
|
import { Address, CellModel, CellRange, SheetModel, ValidationError, cellKey } from '../types';
|
||||||
import { GridTheme } from '../hooks/useGridTheme';
|
import { GridTheme } from '../hooks/useGridTheme';
|
||||||
@@ -45,7 +45,12 @@ export const DataRow: React.FC<DataRowProps> = ({
|
|||||||
getDisplayValue, getCellModel, getCellError, getUserContent, canEdit,
|
getDisplayValue, getCellModel, getCellError, getUserContent, canEdit,
|
||||||
onCellPress, onCellLongPress, onCommit, onCancel,
|
onCellPress, onCellLongPress, onCommit, onCancel,
|
||||||
}) => {
|
}) => {
|
||||||
const plan = rowRenderPlan(sheet, row, cols, mergeIndex);
|
// O(cols) per row; the grid re-renders every row on any selection/version
|
||||||
|
// change, so memoize on the inputs that actually affect the plan.
|
||||||
|
const plan = useMemo(
|
||||||
|
() => rowRenderPlan(sheet, row, cols, mergeIndex),
|
||||||
|
[sheet, row, cols, mergeIndex],
|
||||||
|
);
|
||||||
const { active, range } = selection;
|
const { active, range } = selection;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const useGridTheme = (): GridTheme => {
|
|||||||
editorBg: colors.surface,
|
editorBg: colors.surface,
|
||||||
editorBorder: colors.primary,
|
editorBorder: colors.primary,
|
||||||
searchHighlight: isDarkMode ? 'rgba(253,186,116,0.30)' : 'rgba(253,186,116,0.45)',
|
searchHighlight: isDarkMode ? 'rgba(253,186,116,0.30)' : 'rgba(253,186,116,0.45)',
|
||||||
lockIcon: '#000000',
|
lockIcon: isDarkMode ? 'rgba(255,255,255,0.55)' : 'rgba(15,23,42,0.45)',
|
||||||
primary: colors.primary,
|
primary: colors.primary,
|
||||||
surface: colors.surface,
|
surface: colors.surface,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Animated, LayoutChangeEvent, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import Svg, { Circle, Path } from 'react-native-svg';
|
||||||
|
import Reanimated, {
|
||||||
|
Easing,
|
||||||
|
cancelAnimation,
|
||||||
|
useAnimatedProps,
|
||||||
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
|
withTiming,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import colors from '../Constant/Color';
|
||||||
|
import { appEvents } from '../core/events/appEvents';
|
||||||
|
import { useTheme } from '../Hooks/useTheme';
|
||||||
|
|
||||||
|
const AnimatedPath = Reanimated.createAnimatedComponent(Path);
|
||||||
|
|
||||||
|
/** Corner radius of the card's rounded (left) side; shared by card + border. */
|
||||||
|
const CARD_RADIUS = 30;
|
||||||
|
|
||||||
|
export type AppToastType = 'success' | 'error' | 'info';
|
||||||
|
|
||||||
|
export interface AppToastOptions {
|
||||||
|
message: string;
|
||||||
|
title?: string;
|
||||||
|
type?: AppToastType;
|
||||||
|
/** Auto-dismiss delay in ms */
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToastHandler = (options: AppToastOptions) => void;
|
||||||
|
|
||||||
|
let activeHandler: ToastHandler | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imperative entry point usable from anywhere (screens, sagas, axios
|
||||||
|
* interceptors). Requires <AppToastHost /> to be mounted once at the root.
|
||||||
|
*/
|
||||||
|
export const showAppToast = (options: AppToastOptions) => {
|
||||||
|
if (activeHandler) {
|
||||||
|
activeHandler(options);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Info accent — the theme palette has no `info` token, so this stays a fixed
|
||||||
|
* blue that reads on both light and dark surfaces. The success/error accents
|
||||||
|
* are derived from the active theme inside the host.
|
||||||
|
*/
|
||||||
|
const INFO_ACCENT = '#3B82F6';
|
||||||
|
|
||||||
|
const DEFAULT_TITLES: Record<AppToastType, string> = {
|
||||||
|
success: 'Success!',
|
||||||
|
error: 'Error!',
|
||||||
|
info: 'Info',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SLIDE_DISTANCE = 400;
|
||||||
|
const DEFAULT_DURATION = 3200;
|
||||||
|
|
||||||
|
const ToastIcon = ({ type, accent }: { type: AppToastType; accent: string }) => (
|
||||||
|
<View style={[styles.iconCircle, { backgroundColor: accent }]}>
|
||||||
|
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||||
|
{type === 'success' && (
|
||||||
|
<Path
|
||||||
|
d="M20 6L9 17l-5-5"
|
||||||
|
stroke={colors.white}
|
||||||
|
strokeWidth={3.2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{type === 'error' && (
|
||||||
|
<Path
|
||||||
|
d="M7 7l10 10M17 7L7 17"
|
||||||
|
stroke={colors.white}
|
||||||
|
strokeWidth={3}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{type === 'info' && (
|
||||||
|
<>
|
||||||
|
<Circle cx={12} cy={6} r={1.8} fill={colors.white} />
|
||||||
|
<Path
|
||||||
|
d="M12 10.5V19"
|
||||||
|
stroke={colors.white}
|
||||||
|
strokeWidth={3}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Svg>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single bright "beam" of light that flows continuously around the card's
|
||||||
|
* border. Implemented as an SVG rounded-rect stroke (left corners rounded,
|
||||||
|
* right edge square, matching the card) whose dash offset is animated on the UI
|
||||||
|
* thread — the beam is one dash the length of ~30% of the perimeter, travelling
|
||||||
|
* the full perimeter each loop for a seamless flow. A dim static stroke keeps
|
||||||
|
* the whole border faintly visible between passes.
|
||||||
|
*/
|
||||||
|
const FlowingBorder = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
color: string;
|
||||||
|
}) => {
|
||||||
|
const inset = 5;
|
||||||
|
const sw = 2.5;
|
||||||
|
const r = Math.max(0, CARD_RADIUS - inset);
|
||||||
|
const x0 = inset;
|
||||||
|
const y0 = inset;
|
||||||
|
const x1 = width - inset;
|
||||||
|
const y1 = height - inset;
|
||||||
|
const w = x1 - x0;
|
||||||
|
const h = y1 - y0;
|
||||||
|
// Perimeter of a rect with two rounded (left) and two square (right) corners.
|
||||||
|
const perimeter = 2 * (w + h) - 4 * r + Math.PI * r;
|
||||||
|
const beam = perimeter * 0.3;
|
||||||
|
const d =
|
||||||
|
`M ${x0 + r} ${y0} L ${x1} ${y0} L ${x1} ${y1} L ${x0 + r} ${y1} ` +
|
||||||
|
`Q ${x0} ${y1} ${x0} ${y1 - r} L ${x0} ${y0 + r} Q ${x0} ${y0} ${x0 + r} ${y0} Z`;
|
||||||
|
|
||||||
|
const progress = useSharedValue(0);
|
||||||
|
useEffect(() => {
|
||||||
|
progress.value = withRepeat(withTiming(1, { duration: 2200, easing: Easing.linear }), -1, false);
|
||||||
|
return () => cancelAnimation(progress);
|
||||||
|
}, [progress]);
|
||||||
|
|
||||||
|
const animatedProps = useAnimatedProps(() => ({
|
||||||
|
strokeDashoffset: -perimeter * progress.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Svg width={width} height={height} style={styles.svgOverlay} pointerEvents="none">
|
||||||
|
<Path d={d} stroke={`${color}30`} strokeWidth={sw} fill="none" />
|
||||||
|
{/* Wide translucent halo trailing the beam. */}
|
||||||
|
<AnimatedPath
|
||||||
|
d={d}
|
||||||
|
stroke={color}
|
||||||
|
strokeOpacity={0.35}
|
||||||
|
strokeWidth={7}
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={[beam, perimeter - beam]}
|
||||||
|
animatedProps={animatedProps}
|
||||||
|
/>
|
||||||
|
{/* Crisp bright core of the beam. */}
|
||||||
|
<AnimatedPath
|
||||||
|
d={d}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={sw}
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={[beam, perimeter - beam]}
|
||||||
|
animatedProps={animatedProps}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the floating toast card. Mount exactly once at the app root,
|
||||||
|
* above navigation, so it overlays every screen from the top-right corner.
|
||||||
|
*/
|
||||||
|
export const AppToastHost = () => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
// Re-renders on theme change (useTheme reads the Redux themeMode + system
|
||||||
|
// scheme), so an on-screen toast re-themes live and new toasts pick up the
|
||||||
|
// active mode instead of the old hardcoded dark card.
|
||||||
|
const { colors: theme } = useTheme();
|
||||||
|
const [toast, setToast] = useState<Required<AppToastOptions> | null>(null);
|
||||||
|
const translateX = useRef(new Animated.Value(SLIDE_DISTANCE)).current;
|
||||||
|
const opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
// Collapses duplicate toasts fired for the same event (e.g. a screen's
|
||||||
|
// explicit toast + the global API-result toast carrying the same message).
|
||||||
|
const lastShown = useRef<{ key: string; at: number } | null>(null);
|
||||||
|
// Card size, measured on layout, so the flowing border path matches exactly.
|
||||||
|
const [cardSize, setCardSize] = useState<{ width: number; height: number } | null>(null);
|
||||||
|
|
||||||
|
const clearHideTimer = () => {
|
||||||
|
if (hideTimer.current) {
|
||||||
|
clearTimeout(hideTimer.current);
|
||||||
|
hideTimer.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismiss = useCallback(() => {
|
||||||
|
clearHideTimer();
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(translateX, {
|
||||||
|
toValue: SLIDE_DISTANCE,
|
||||||
|
duration: 220,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(opacity, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 220,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start(({ finished }) => {
|
||||||
|
if (finished) {
|
||||||
|
setToast(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [opacity, translateX]);
|
||||||
|
|
||||||
|
const show = useCallback(
|
||||||
|
(options: AppToastOptions) => {
|
||||||
|
const type = options.type ?? 'success';
|
||||||
|
const key = `${type}:${options.message}`;
|
||||||
|
const now = Date.now();
|
||||||
|
if (lastShown.current && lastShown.current.key === key && now - lastShown.current.at < 2500) {
|
||||||
|
return; // identical toast just shown — skip the duplicate
|
||||||
|
}
|
||||||
|
lastShown.current = { key, at: now };
|
||||||
|
clearHideTimer();
|
||||||
|
setToast({
|
||||||
|
message: options.message,
|
||||||
|
title: options.title ?? DEFAULT_TITLES[type],
|
||||||
|
type,
|
||||||
|
duration: options.duration ?? DEFAULT_DURATION,
|
||||||
|
});
|
||||||
|
translateX.setValue(SLIDE_DISTANCE);
|
||||||
|
opacity.setValue(0);
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.spring(translateX, {
|
||||||
|
toValue: 0,
|
||||||
|
friction: 9,
|
||||||
|
tension: 70,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(opacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 180,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
hideTimer.current = setTimeout(dismiss, options.duration ?? DEFAULT_DURATION);
|
||||||
|
},
|
||||||
|
[dismiss, opacity, translateX],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeHandler = show;
|
||||||
|
return () => {
|
||||||
|
if (activeHandler === show) {
|
||||||
|
activeHandler = null;
|
||||||
|
}
|
||||||
|
clearHideTimer();
|
||||||
|
};
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
|
if (!toast) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accents: Record<AppToastType, string> = {
|
||||||
|
success: theme.primary,
|
||||||
|
error: theme.error,
|
||||||
|
info: INFO_ACCENT,
|
||||||
|
};
|
||||||
|
const accent = accents[toast.type];
|
||||||
|
const onCardLayout = (e: LayoutChangeEvent) => {
|
||||||
|
const { width, height } = e.nativeEvent.layout;
|
||||||
|
setCardSize(prev =>
|
||||||
|
prev && prev.width === width && prev.height === height ? prev : { width, height },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
pointerEvents="box-none"
|
||||||
|
style={[styles.overlay, { top: insets.top + 8 }]}
|
||||||
|
>
|
||||||
|
<Animated.View style={{ transform: [{ translateX }], opacity }}>
|
||||||
|
<View style={styles.cardWrap}>
|
||||||
|
<Pressable
|
||||||
|
onPress={dismiss}
|
||||||
|
onLayout={onCardLayout}
|
||||||
|
style={[styles.card, { backgroundColor: theme.surface, shadowColor: theme.shadow }]}
|
||||||
|
>
|
||||||
|
<ToastIcon type={toast.type} accent={accent} />
|
||||||
|
<View style={styles.textWrap}>
|
||||||
|
<Text style={[styles.title, { color: theme.text }]}>{toast.title}</Text>
|
||||||
|
<Text style={[styles.message, { color: theme.textSecondary }]} numberOfLines={2}>
|
||||||
|
{toast.message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
{cardSize && (
|
||||||
|
<FlowingBorder width={cardSize.width} height={cardSize.height} color={accent} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridges HTTP-layer `api/success` / `api/error` events to the toast, so every
|
||||||
|
* API result surfaces the new toast app-wide without per-screen wiring. Mount
|
||||||
|
* once at the root, alongside <AppToastHost />.
|
||||||
|
*/
|
||||||
|
export const ApiToastBridge = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
const offSuccess = appEvents.on('api/success', ({ message }) =>
|
||||||
|
showAppToast({ type: 'success', message }),
|
||||||
|
);
|
||||||
|
const offError = appEvents.on('api/error', ({ message }) =>
|
||||||
|
showAppToast({ type: 'error', message }),
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
offSuccess();
|
||||||
|
offError();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
left: 12,
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
zIndex: 9999,
|
||||||
|
},
|
||||||
|
cardWrap: {
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
svgOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
maxWidth: 360,
|
||||||
|
// backgroundColor + shadowColor are applied inline from the active theme.
|
||||||
|
// Flush against the screen's right edge: round the left corners only.
|
||||||
|
borderTopLeftRadius: CARD_RADIUS,
|
||||||
|
borderBottomLeftRadius: CARD_RADIUS,
|
||||||
|
borderTopRightRadius: 0,
|
||||||
|
borderBottomRightRadius: 0,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingLeft: 12,
|
||||||
|
paddingRight: 20,
|
||||||
|
shadowOpacity: 0.32,
|
||||||
|
shadowRadius: 11,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
elevation: 9,
|
||||||
|
},
|
||||||
|
iconCircle: {
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
borderRadius: 19,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
textWrap: {
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
// color applied inline from the active theme.
|
||||||
|
fontSize: 15.5,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
// color applied inline from the active theme.
|
||||||
|
fontSize: 13,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, StyleSheet, ViewStyle } from 'react-native';
|
||||||
|
import { Inbox } from 'lucide-react-native';
|
||||||
|
import AppText from './AppText';
|
||||||
|
import { useTheme } from '../Hooks/useTheme';
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
/** Primary line, e.g. "No live tenders found". */
|
||||||
|
title: string;
|
||||||
|
/** Optional supporting line under the title. */
|
||||||
|
subtitle?: string;
|
||||||
|
/** Override the default icon (sized/coloured by the caller). */
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
style?: ViewStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centered, theme-aware empty placeholder for lists. Designed to be dropped
|
||||||
|
* into a FlatList's `ListEmptyComponent`; pair it with `flexGrow: 1` on the
|
||||||
|
* list's `contentContainerStyle` so it centers in the available space and the
|
||||||
|
* pull-to-refresh gesture still works while empty.
|
||||||
|
*/
|
||||||
|
const EmptyState: React.FC<EmptyStateProps> = ({ title, subtitle, icon, style }) => {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, style]}>
|
||||||
|
<View style={[styles.iconWrap, { backgroundColor: colors.overlay }]}>
|
||||||
|
{icon ?? <Inbox size={34} color={colors.primary} strokeWidth={1.8} />}
|
||||||
|
</View>
|
||||||
|
<AppText variant="h4" centered color={colors.text} style={styles.title}>
|
||||||
|
{title}
|
||||||
|
</AppText>
|
||||||
|
{!!subtitle && (
|
||||||
|
<AppText variant="bodySmall" centered color={colors.textSecondary} style={styles.subtitle}>
|
||||||
|
{subtitle}
|
||||||
|
</AppText>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
paddingVertical: 48,
|
||||||
|
},
|
||||||
|
iconWrap: {
|
||||||
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
borderRadius: 44,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 18,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default EmptyState;
|
||||||
+190
-47
@@ -1,14 +1,16 @@
|
|||||||
import React from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { View, StyleSheet, TouchableOpacity, Image, Dimensions } from 'react-native';
|
import { View, StyleSheet, TouchableOpacity, Image, Dimensions } from 'react-native';
|
||||||
import Modal from 'react-native-modal';
|
import Modal from 'react-native-modal';
|
||||||
import { useTheme } from '../Hooks/useTheme';
|
import { useTheme } from '../Hooks/useTheme';
|
||||||
import AppText from './AppText';
|
import AppText from './AppText';
|
||||||
import { Moon, Sun, Monitor, X, LogOut, FileText, User } from 'lucide-react-native';
|
import { Moon, Sun, Monitor, X, LogOut, FileText, User, ChevronRight } from 'lucide-react-native';
|
||||||
import ImagePath from '../Constant/ImagePath';
|
import ImagePath from '../Constant/ImagePath';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { performLogout } from '../Services/Auth/session';
|
import { performLogout } from '../Services/Auth/session';
|
||||||
import NavigationString from '../Constant/NavigationString';
|
import NavigationString from '../Constant/NavigationString';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { getToken } from '../Redux/Slices/Token';
|
||||||
|
|
||||||
interface SidebarModalProps {
|
interface SidebarModalProps {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
@@ -17,17 +19,55 @@ interface SidebarModalProps {
|
|||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backend returns names in ALL CAPS ("KISHORE SHARMA"). Normalize to title
|
||||||
|
* case ("Kishore Sharma") so the drawer header reads cleanly instead of shouting.
|
||||||
|
*/
|
||||||
|
const toTitleCase = (value: string): string =>
|
||||||
|
value
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
const SidebarModal: React.FC<SidebarModalProps> = ({ isVisible, onClose }) => {
|
const SidebarModal: React.FC<SidebarModalProps> = ({ isVisible, onClose }) => {
|
||||||
const { colors, themeMode, toggleTheme } = useTheme();
|
const { colors, themeMode, toggleTheme } = useTheme();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const navigation = useNavigation<any>();
|
const navigation = useNavigation<any>();
|
||||||
|
const userData = useSelector(getToken);
|
||||||
|
// Prefer the backend's full name; fall back to the first name, then a
|
||||||
|
// neutral placeholder so the row never renders empty.
|
||||||
|
const displayName = toTitleCase(userData?.fullName || userData?.firstName || 'User');
|
||||||
|
const initial = displayName.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
// react-native-modal renders in its OWN native window. Navigating or logging
|
||||||
|
// out while it is still visible tears the host (Home) down mid-animation and
|
||||||
|
// strands a ghost backdrop window over the whole app — the "faded screen"
|
||||||
|
// that survives navigation and is still there after a re-login. So we always
|
||||||
|
// dismiss first and run the real action from onModalHide, once the modal
|
||||||
|
// window has been fully torn down.
|
||||||
|
const pendingAction = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
const dismissThen = (action: () => void) => {
|
||||||
|
pendingAction.current = action;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalHide = () => {
|
||||||
|
const action = pendingAction.current;
|
||||||
|
pendingAction.current = null;
|
||||||
|
action?.();
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
|
dismissThen(() => {
|
||||||
performLogout();
|
performLogout();
|
||||||
navigation.reset({
|
navigation.reset({
|
||||||
index: 0,
|
index: 0,
|
||||||
routes: [{ name: NavigationString.Login }],
|
routes: [{ name: NavigationString.Login }],
|
||||||
});
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderThemeOption = (mode: 'light' | 'dark' | 'system', label: string, Icon: any) => {
|
const renderThemeOption = (mode: 'light' | 'dark' | 'system', label: string, Icon: any) => {
|
||||||
@@ -48,12 +88,19 @@ const SidebarModal: React.FC<SidebarModalProps> = ({ isVisible, onClose }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderMenuItem = (label: string, Icon: any, onPress: () => void) => (
|
const renderMenuCard = (label: string, Icon: any, onPress: () => void) => (
|
||||||
<TouchableOpacity style={styles.menuItem} onPress={onPress}>
|
<TouchableOpacity
|
||||||
<Icon size={22} color={colors.text} />
|
style={[styles.menuCard, { backgroundColor: colors.surface, borderColor: colors.border }]}
|
||||||
<AppText variant="bodySmall" size={16} style={{ marginLeft: 16, color: colors.text }}>
|
onPress={onPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={[styles.menuIconWrap, { backgroundColor: colors.primary + '15' }]}>
|
||||||
|
<Icon size={20} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<AppText variant="bodySmall" size={15} style={[styles.menuLabel, { color: colors.text }]}>
|
||||||
{label}
|
{label}
|
||||||
</AppText>
|
</AppText>
|
||||||
|
<ChevronRight size={18} color={colors.textSecondary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -66,39 +113,64 @@ const SidebarModal: React.FC<SidebarModalProps> = ({ isVisible, onClose }) => {
|
|||||||
animationOut="slideOutLeft"
|
animationOut="slideOutLeft"
|
||||||
swipeDirection="left"
|
swipeDirection="left"
|
||||||
onSwipeComplete={onClose}
|
onSwipeComplete={onClose}
|
||||||
useNativeDriver={false}
|
onModalHide={handleModalHide}
|
||||||
|
// Run the slide + backdrop fade on the UI thread. The theme toggle
|
||||||
|
// below dispatches a Redux update that synchronously re-renders
|
||||||
|
// every useTheme() consumer; a JS-driven backdrop fade gets starved
|
||||||
|
// by that burst and can freeze half-dismissed, leaving a stuck grey
|
||||||
|
// scrim over the whole app.
|
||||||
|
useNativeDriver
|
||||||
|
useNativeDriverForBackdrop
|
||||||
hideModalContentWhileAnimating
|
hideModalContentWhileAnimating
|
||||||
style={styles.modal}
|
style={styles.modal}
|
||||||
>
|
>
|
||||||
<View style={[styles.container, { backgroundColor: colors.background, paddingTop: insets.top }]}>
|
<View style={[styles.container, { backgroundColor: colors.background, paddingTop: insets.top }]}>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Top bar — close only */}
|
||||||
<View style={[styles.header, { borderBottomColor: colors.border }]}>
|
<View style={styles.topBar}>
|
||||||
<View style={styles.profileSection}>
|
<TouchableOpacity
|
||||||
<Image source={ImagePath.Icon.Logo} style={styles.logo} resizeMode="contain" />
|
onPress={onClose}
|
||||||
<View>
|
style={styles.closeButton}
|
||||||
<AppText variant="h3" size={18} style={{ color: colors.text }}>ProcureLinc</AppText>
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||||
<AppText variant="caption" size={12} style={{ color: colors.textSecondary }}>v1.0.0</AppText>
|
>
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
|
||||||
<X size={24} color={colors.textSecondary} />
|
<X size={24} color={colors.textSecondary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Menu Items */}
|
{/* Profile hero */}
|
||||||
<View style={styles.content}>
|
<View style={styles.hero}>
|
||||||
{renderMenuItem('Profile', User, () => {
|
<View style={[styles.heroAvatar, { backgroundColor: colors.primary }]}>
|
||||||
onClose();
|
<AppText semiBold color="#FFF" style={styles.heroInitial}>
|
||||||
navigation.navigate(NavigationString.Profile);
|
{initial}
|
||||||
})}
|
</AppText>
|
||||||
{renderMenuItem('Terms & Conditions', FileText, () => {
|
</View>
|
||||||
|
<AppText semiBold style={[styles.heroName, { color: colors.text }]} numberOfLines={1}>
|
||||||
|
{displayName}
|
||||||
|
</AppText>
|
||||||
|
{!!userData?.userType && (
|
||||||
|
<View style={[styles.rolePill, { backgroundColor: colors.primary + '18' }]}>
|
||||||
|
<AppText style={[styles.roleText, { color: colors.primary }]} numberOfLines={1}>
|
||||||
|
{String(userData.userType)}
|
||||||
|
</AppText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Menu cards */}
|
||||||
|
<View style={styles.menuList}>
|
||||||
|
{renderMenuCard('Profile', User, () =>
|
||||||
|
dismissThen(() => navigation.navigate(NavigationString.Profile)),
|
||||||
|
)}
|
||||||
|
{renderMenuCard('Terms & Conditions', FileText, () => {
|
||||||
onClose();
|
onClose();
|
||||||
// Navigate to Terms
|
// Navigate to Terms
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Bottom Section: Theme & Logout */}
|
{/* Pushes the bottom section down */}
|
||||||
|
<View style={styles.spacer} />
|
||||||
|
|
||||||
|
{/* Bottom Section: Theme, Logout & app identity */}
|
||||||
<View style={[styles.footer, { borderTopColor: colors.border }]}>
|
<View style={[styles.footer, { borderTopColor: colors.border }]}>
|
||||||
<AppText variant="h4" style={{ marginBottom: 16 }}>Appearance</AppText>
|
<AppText variant="h4" style={{ marginBottom: 16 }}>Appearance</AppText>
|
||||||
<View style={styles.themeRow}>
|
<View style={styles.themeRow}>
|
||||||
@@ -114,6 +186,14 @@ const SidebarModal: React.FC<SidebarModalProps> = ({ isVisible, onClose }) => {
|
|||||||
<LogOut size={20} color={colors.error} />
|
<LogOut size={20} color={colors.error} />
|
||||||
<AppText variant="button" style={{ marginLeft: 10, color: colors.error }}>Log Out</AppText>
|
<AppText variant="button" style={{ marginLeft: 10, color: colors.error }}>Log Out</AppText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* App identity moved to the very bottom */}
|
||||||
|
<View style={styles.brandFooter}>
|
||||||
|
<Image source={ImagePath.Icon.Logo} style={styles.brandLogo} resizeMode="contain" />
|
||||||
|
<AppText variant="caption" style={{ color: colors.textSecondary }}>
|
||||||
|
ProcureLinc · v1.0.0
|
||||||
|
</AppText>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
@@ -135,39 +215,90 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 3.84,
|
shadowRadius: 3.84,
|
||||||
elevation: 5,
|
elevation: 5,
|
||||||
},
|
},
|
||||||
header: {
|
|
||||||
|
/* Top bar (close button only) */
|
||||||
|
topBar: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'flex-end',
|
||||||
alignItems: 'center',
|
paddingHorizontal: 12,
|
||||||
padding: 20,
|
paddingTop: 8,
|
||||||
borderBottomWidth: 1,
|
|
||||||
},
|
|
||||||
profileSection: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
logo: {
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
marginRight: 12,
|
|
||||||
borderRadius: 20,
|
|
||||||
},
|
},
|
||||||
closeButton: {
|
closeButton: {
|
||||||
padding: 8,
|
padding: 8,
|
||||||
},
|
},
|
||||||
content: {
|
|
||||||
flex: 1,
|
/* Profile hero */
|
||||||
padding: 20,
|
hero: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 4,
|
||||||
|
paddingBottom: 24,
|
||||||
},
|
},
|
||||||
menuItem: {
|
heroAvatar: {
|
||||||
|
width: 76,
|
||||||
|
height: 76,
|
||||||
|
borderRadius: 38,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
heroInitial: {
|
||||||
|
fontSize: 30,
|
||||||
|
lineHeight: 36,
|
||||||
|
},
|
||||||
|
heroName: {
|
||||||
|
fontSize: 19,
|
||||||
|
lineHeight: 24,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
rolePill: {
|
||||||
|
marginTop: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
roleText: {
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Menu cards */
|
||||||
|
menuList: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
menuCard: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: 16,
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
|
menuIconWrap: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 14,
|
||||||
|
},
|
||||||
|
menuLabel: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
spacer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Bottom section */
|
||||||
footer: {
|
footer: {
|
||||||
padding: 20,
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 20,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
paddingBottom: 40,
|
paddingBottom: 32,
|
||||||
},
|
},
|
||||||
themeRow: {
|
themeRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -188,6 +319,18 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
marginTop: 10,
|
marginTop: 10,
|
||||||
},
|
},
|
||||||
|
brandFooter: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
brandLogo: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default SidebarModal;
|
export default SidebarModal;
|
||||||
|
|||||||
@@ -2,18 +2,20 @@ import { View, Dimensions } from 'react-native';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import SkeletonPlaceholder from 'react-native-skeleton-placeholder';
|
import SkeletonPlaceholder from 'react-native-skeleton-placeholder';
|
||||||
|
|
||||||
const SkeletonList = () => {
|
const { width, height } = Dimensions.get('screen');
|
||||||
const { width, height } = Dimensions.get('screen');
|
|
||||||
|
|
||||||
return (
|
const SkeletonList = () => (
|
||||||
|
// One SkeletonPlaceholder (a single shimmer animation) wrapping all rows,
|
||||||
|
// instead of 10 separate placeholders each running their own animation.
|
||||||
<View style={{ width: width - 30, height }}>
|
<View style={{ width: width - 30, height }}>
|
||||||
|
<SkeletonPlaceholder borderRadius={5}>
|
||||||
|
<View>
|
||||||
{[...Array(10)].map((_, index) => (
|
{[...Array(10)].map((_, index) => (
|
||||||
<SkeletonPlaceholder key={index} borderRadius={5}>
|
<SkeletonPlaceholder.Item key={index} width={"100%"} height={50} marginBottom={12} />
|
||||||
<SkeletonPlaceholder.Item width={"100%"} height={50} marginBottom={12} />
|
|
||||||
</SkeletonPlaceholder>
|
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
);
|
</SkeletonPlaceholder>
|
||||||
};
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
export default SkeletonList;
|
export default SkeletonList;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import InsideHeader from './InsideHeader';
|
import InsideHeader from './InsideHeader';
|
||||||
import Loader from './Loader';
|
import Loader from './Loader';
|
||||||
import Skeleton from './Skeleton';
|
import Skeleton from './Skeleton';
|
||||||
|
import EmptyState from './EmptyState';
|
||||||
|
|
||||||
export { Skeleton, Loader, InsideHeader };
|
export { Skeleton, Loader, InsideHeader, EmptyState };
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ type AppEventMap = {
|
|||||||
'auth/unauthorized': void;
|
'auth/unauthorized': void;
|
||||||
/** Emitted when the user explicitly logs out. */
|
/** Emitted when the user explicitly logs out. */
|
||||||
'auth/logout': void;
|
'auth/logout': void;
|
||||||
|
/** A successful mutation carrying a user-facing message (→ success toast). */
|
||||||
|
'api/success': { message: string };
|
||||||
|
/** An API/network failure carrying a user-facing message (→ error toast). */
|
||||||
|
'api/error': { message: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
type EventKey = keyof AppEventMap;
|
type EventKey = keyof AppEventMap;
|
||||||
|
|||||||
+42
-2
@@ -47,11 +47,51 @@ httpClient.interceptors.request.use(config => {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const MUTATION_METHODS = new Set(['post', 'put', 'patch', 'delete']);
|
||||||
|
|
||||||
httpClient.interceptors.response.use(
|
httpClient.interceptors.response.use(
|
||||||
response => response,
|
response => {
|
||||||
|
// Surface API results as app-wide toasts (a UI bridge subscribes to these
|
||||||
|
// events). The backend reports business status in the BODY (statusCode /
|
||||||
|
// status) — usually with HTTP 200 — so decide success vs error from there,
|
||||||
|
// not from the transport status code.
|
||||||
|
try {
|
||||||
|
const cfg: any = response.config || {};
|
||||||
|
const body: any = response.data;
|
||||||
|
const method = String(cfg.method ?? 'get').toLowerCase();
|
||||||
|
const bodyStatus = typeof body?.statusCode === 'number' ? body.statusCode : undefined;
|
||||||
|
const bodyState = typeof body?.status === 'string' ? body.status.toLowerCase() : undefined;
|
||||||
|
const message = typeof body?.message === 'string' ? body.message.trim() : '';
|
||||||
|
const contentType = String(
|
||||||
|
cfg.headers?.['Content-Type'] ?? cfg.headers?.['content-type'] ?? '',
|
||||||
|
);
|
||||||
|
const isMultipart = contentType.includes('multipart/form-data');
|
||||||
|
const isBodyError =
|
||||||
|
(bodyStatus !== undefined && bodyStatus >= 400) ||
|
||||||
|
bodyState === 'failed' ||
|
||||||
|
bodyState === 'error';
|
||||||
|
|
||||||
|
if (isBodyError) {
|
||||||
|
appEvents.emit('api/error', { message: message || 'Something went wrong' });
|
||||||
|
} else if (message && MUTATION_METHODS.has(method) && !isMultipart) {
|
||||||
|
// Success toast only for user-facing mutations; GETs and intermediate
|
||||||
|
// multipart uploads stay silent so the toast never becomes noise.
|
||||||
|
appEvents.emit('api/success', { message });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* toast emission must never interfere with the response path */
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
error => {
|
error => {
|
||||||
if (error?.response?.status === 401) {
|
const status = error?.response?.status;
|
||||||
|
if (status === 401) {
|
||||||
appEvents.emit('auth/unauthorized', undefined);
|
appEvents.emit('auth/unauthorized', undefined);
|
||||||
|
appEvents.emit('api/error', { message: 'Session expired. Please log in again.' });
|
||||||
|
} else {
|
||||||
|
const message =
|
||||||
|
error?.response?.data?.message || error?.message || 'Network error. Please try again.';
|
||||||
|
appEvents.emit('api/error', { message });
|
||||||
}
|
}
|
||||||
return Promise.reject(
|
return Promise.reject(
|
||||||
axios.isAxiosError(error)
|
axios.isAxiosError(error)
|
||||||
|
|||||||
@@ -8,11 +8,19 @@ import type { AxiosInstance } from 'axios';
|
|||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
import { redactUrl } from './redact';
|
import { redactUrl } from './redact';
|
||||||
|
|
||||||
// Build the full URL exactly as it goes on the wire: baseURL + url + ?query
|
// Matches axios's own isAbsoluteURL: an absolute url ignores baseURL.
|
||||||
|
const isAbsoluteUrl = (url: string): boolean => /^([a-z][a-z\d+\-.]*:)?\/\//i.test(url);
|
||||||
|
|
||||||
|
// Build the full URL exactly as it goes on the wire: baseURL + url + ?query.
|
||||||
|
// When url is already absolute, axios drops baseURL — mirror that here so the
|
||||||
|
// log shows the real wire URL instead of a doubled base.
|
||||||
const buildFullUrl = (config: any = {}): string => {
|
const buildFullUrl = (config: any = {}): string => {
|
||||||
const base = config.baseURL || '';
|
const base = config.baseURL || '';
|
||||||
const path = config.url || '';
|
const path = config.url || '';
|
||||||
let full = base ? `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}` : path;
|
let full =
|
||||||
|
base && !isAbsoluteUrl(path)
|
||||||
|
? `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
|
||||||
|
: path;
|
||||||
|
|
||||||
const params = config.params;
|
const params = config.params;
|
||||||
if (params && typeof params === 'object') {
|
if (params && typeof params === 'object') {
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Screenshot / screen-recording guard — the single source of truth for which
|
||||||
|
* screens block capture.
|
||||||
|
*
|
||||||
|
* The app is single-Activity, so Android's window-level FLAG_SECURE is flipped
|
||||||
|
* on as the user enters a protected route and off as they leave. The controller
|
||||||
|
* is driven centrally from the NavigationContainer's `onStateChange`/`onReady`
|
||||||
|
* in App.tsx — no per-screen wiring required. To change what is protected, edit
|
||||||
|
* SECURE_SCREENS below; to kill the whole feature, flip SCREEN_GUARD_ENABLED.
|
||||||
|
*
|
||||||
|
* Android only. iOS has no equivalent window flag, so the native module is
|
||||||
|
* absent there and every call no-ops (see useSecureScreen for the iOS story).
|
||||||
|
*
|
||||||
|
* Fail-closed: release builds start secured in MainActivity, so sensitive
|
||||||
|
* content is never exposed before JS boots or if this bridge fails to load. The
|
||||||
|
* controller then clears the flag for the non-protected screens.
|
||||||
|
*/
|
||||||
|
import { NativeModules, Platform } from 'react-native';
|
||||||
|
import { logger } from '../observability/logger';
|
||||||
|
import { navigationRef } from '../../Navigation/NavigationService';
|
||||||
|
import NavigationString from '../../Constant/NavigationString';
|
||||||
|
|
||||||
|
type ScreenGuardNative = { setSecure(enable: boolean): void };
|
||||||
|
|
||||||
|
const native: ScreenGuardNative | undefined = NativeModules.ScreenGuard;
|
||||||
|
|
||||||
|
/** Master switch. Set to false to disable screenshot protection app-wide. */
|
||||||
|
export const SCREEN_GUARD_ENABLED = false; // TODO: flip to true for production builds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes that must block screenshots / screen recording. Values are the route
|
||||||
|
* `name`s registered in MainStack (via NavigationString). Add or remove a line
|
||||||
|
* to change a screen's protection — this set is the only place to manage it.
|
||||||
|
*/
|
||||||
|
export const SECURE_SCREENS: ReadonlySet<string> = new Set<string>([
|
||||||
|
NavigationString.Emd, // EMD payment details
|
||||||
|
NavigationString.StartBidding, // sealed bid amount
|
||||||
|
NavigationString.StartReverseBidding, // sealed reverse-bid amount
|
||||||
|
NavigationString.TenderBidding, // sealed bid amount
|
||||||
|
NavigationString.ProceedToPurTender, // bid amounts
|
||||||
|
NavigationString.ProceedToBidDetail, // bid amounts
|
||||||
|
NavigationString.LiveTenderDetail, // live bid data
|
||||||
|
NavigationString.EnvelopeCSpreadsheet, // sealed envelope amounts
|
||||||
|
NavigationString.KycWebViewScreen, // Aadhaar / KYC
|
||||||
|
NavigationString.StageThreeScreen, // KYC OTP
|
||||||
|
NavigationString.DeviceBinding, // device-binding secrets
|
||||||
|
NavigationString.DscBinding, // DSC binding
|
||||||
|
// NavigationString.Login, // uncomment to also protect login
|
||||||
|
// NavigationString.ResetPassword, // uncomment to also protect reset-password
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Android + native module present. iOS and misconfigured builds no-op. */
|
||||||
|
const canGuard = Platform.OS === 'android' && !!native;
|
||||||
|
|
||||||
|
/** Last flag we pushed to native, to skip redundant UI-thread hops. */
|
||||||
|
let lastApplied: boolean | undefined;
|
||||||
|
|
||||||
|
let warnedMissing = false;
|
||||||
|
const warnUnavailableOnce = (): void => {
|
||||||
|
if (warnedMissing) {return;}
|
||||||
|
warnedMissing = true;
|
||||||
|
// Only meaningful on Android; on iOS the absence is expected.
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
logger.warn('security', 'ScreenGuard native module unavailable; screenshot guard inactive');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Directly set FLAG_SECURE. Prefer applyGuardForRoute for navigation-driven use. */
|
||||||
|
export const setSecure = (enable: boolean): void => {
|
||||||
|
const target = SCREEN_GUARD_ENABLED ? enable : false;
|
||||||
|
if (!canGuard) {
|
||||||
|
warnUnavailableOnce();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target === lastApplied) {return;}
|
||||||
|
lastApplied = target;
|
||||||
|
try {
|
||||||
|
native!.setSecure(target);
|
||||||
|
logger.debug('security', `FLAG_SECURE ${target ? 'set' : 'cleared'}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('security', 'failed to toggle FLAG_SECURE', { error: String(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Whether a given route name should block capture. */
|
||||||
|
export const isScreenProtected = (routeName?: string): boolean =>
|
||||||
|
SCREEN_GUARD_ENABLED && !!routeName && SECURE_SCREENS.has(routeName);
|
||||||
|
|
||||||
|
/** Apply the guard for an explicit route name. */
|
||||||
|
export const applyGuardForRoute = (routeName?: string): void => {
|
||||||
|
setSecure(isScreenProtected(routeName));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Read the active route from the navigation ref and apply the guard. */
|
||||||
|
export const applyGuardForCurrentRoute = (): void => {
|
||||||
|
const routeName = navigationRef.isReady()
|
||||||
|
? navigationRef.getCurrentRoute()?.name
|
||||||
|
: undefined;
|
||||||
|
applyGuardForRoute(routeName);
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* useSecureScreen — protect the CURRENT screen while it is focused.
|
||||||
|
*
|
||||||
|
* The central navigation controller (screenGuard + App.tsx) already covers every
|
||||||
|
* route listed in SECURE_SCREENS. Reach for this hook only when a screen's
|
||||||
|
* sensitivity is conditional at runtime — e.g. protect a page ONLY while a
|
||||||
|
* sealed amount is revealed:
|
||||||
|
*
|
||||||
|
* useSecureScreen(isAmountRevealed);
|
||||||
|
*
|
||||||
|
* On blur it clears the flag; the navigation controller's onStateChange then
|
||||||
|
* re-applies the correct state for the next route.
|
||||||
|
*/
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { setSecure } from './screenGuard';
|
||||||
|
|
||||||
|
export const useSecureScreen = (active: boolean = true): void => {
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
setSecure(active);
|
||||||
|
return () => setSecure(false);
|
||||||
|
}, [active]),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -212,7 +212,6 @@ export const useWebSocketService = (
|
|||||||
url: `${env.socketBaseUrl}livebidding/placeBid`,
|
url: `${env.socketBaseUrl}livebidding/placeBid`,
|
||||||
data: BidDTO,
|
data: BidDTO,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.statusCode === 200) {
|
if (res.statusCode === 200) {
|
||||||
return { data: res.data, status: true, message: res.message };
|
return { data: res.data, status: true, message: res.message };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user