1 Commits
Author SHA1 Message Date
Abin ToppoandClaude Opus 4.8 95d8e0a925 perf: project-wide optimization pass + UI/theme fixes
Optimization (audited across src/, typecheck green):
- Crash guards: PurchaseAuction data?.result + stray text-node, AuctionItemDetail
  length>0, LiveAuction user?.contractorId, KYC params guard
- Hoisted render-defined components: Login MinimalInput (keyboard-focus bug),
  TenderFullDetail Section/InfoBox (subtree remount)
- Memoized hot paths: Spreadsheet ColumnHeaderRow/DataRow, list makeStyles,
  shared infinite-query select (stops per-render flatMap)
- Removed hot-path/in-list console logs; SkeletonList 10->1 shimmer

UI/theme: themed AppToast, redesigned sidebar drawer, list empty states,
KYC WebView cleanup

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 11:14:23 +05:30
51 changed files with 1659 additions and 679 deletions
+9 -4
View File
@@ -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()
}
+5 -4
View File
@@ -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).
--- ---
-12
View File
@@ -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
View File
@@ -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",
+2
View File
@@ -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;
+6 -19
View File
@@ -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 }
-9
View File
@@ -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
View File
@@ -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
}, },
}); });
+4 -2
View File
@@ -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],
);
+9 -15
View File
@@ -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,
+16 -15
View File
@@ -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>
+15 -6
View File
@@ -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>
); );
+27 -2
View File
@@ -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'),
}, },
); );
}; };
+26 -12
View File
@@ -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,
}; };
+2 -3
View File
@@ -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);
});
});
+80 -13
View File
@@ -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,
+22 -7
View File
@@ -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,
+64 -86
View File
@@ -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
View File
@@ -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
+8 -2
View File
@@ -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}
-13
View File
@@ -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}>
+30 -28
View File
@@ -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',
+11 -3
View File
@@ -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
+4 -1
View File
@@ -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);
+17 -34
View File
@@ -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: {
+7 -2
View File
@@ -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 (
+1 -1
View File
@@ -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,
}), }),
+383
View File
@@ -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,
},
});
+67
View File
@@ -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
View File
@@ -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;
+10 -8
View File
@@ -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;
+2 -1
View File
@@ -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 };
+4
View File
@@ -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
View File
@@ -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)
+10 -2
View File
@@ -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') {
+100
View File
@@ -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);
};
+25
View File
@@ -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]),
);
};
-1
View File
@@ -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 };
} }