Compare commits
1
Commits
main
...
feature-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95d8e0a925 |
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { View } from 'react-native';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AlertNotificationRoot } from 'react-native-alert-notification';
|
||||
import { PersistGate } from 'redux-persist/integration/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { persistor, store } from './src/Redux/Store';
|
||||
@@ -11,29 +10,35 @@ import { NavigationContainer } from '@react-navigation/native';
|
||||
import colors from './src/Constant/Color';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import AppStatusBar from './src/components/AppStatusBar';
|
||||
import { ApiToastBridge, AppToastHost } from './src/components/AppToast';
|
||||
import { ErrorBoundary } from './src/core/errors/ErrorBoundary';
|
||||
import { queryClient } from './src/core/query/queryClient';
|
||||
import AuthSessionGuard from './src/Navigation/AuthSessionGuard';
|
||||
import { navigationRef } from './src/Navigation/NavigationService';
|
||||
import { applyGuardForCurrentRoute } from './src/core/security/screenGuard';
|
||||
|
||||
const App = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<NavigationContainer ref={navigationRef}>
|
||||
<NavigationContainer
|
||||
ref={navigationRef}
|
||||
onReady={applyGuardForCurrentRoute}
|
||||
onStateChange={applyGuardForCurrentRoute}
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<AlertNotificationRoot>
|
||||
<Provider store={store}>
|
||||
<PersistGate persistor={persistor}>
|
||||
<View style={{ flex: 1, backgroundColor: colors.white, paddingBottom: insets.bottom }}>
|
||||
<AppStatusBar />
|
||||
<AuthSessionGuard />
|
||||
<Routes />
|
||||
</View>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
</AlertNotificationRoot>
|
||||
<Provider store={store}>
|
||||
<PersistGate persistor={persistor}>
|
||||
<View style={{ flex: 1, backgroundColor: colors.white, paddingBottom: insets.bottom }}>
|
||||
<AppStatusBar />
|
||||
<AuthSessionGuard />
|
||||
<Routes />
|
||||
<ApiToastBridge />
|
||||
<AppToastHost />
|
||||
</View>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
</GestureHandlerRootView>
|
||||
</QueryClientProvider>
|
||||
</NavigationContainer>
|
||||
|
||||
@@ -11,9 +11,14 @@ class MainActivity : ReactActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// FLAG_SECURE: the app displays sealed-bid amounts, Aadhaar OTPs and other
|
||||
// regulated PII. Block screenshots, screen recording and the app-switcher
|
||||
// thumbnail in release builds; debug builds stay capturable for QA.
|
||||
// FLAG_SECURE fail-closed baseline: the app displays sealed-bid amounts,
|
||||
// Aadhaar OTPs and other regulated PII. Release builds start fully secured so
|
||||
// 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) {
|
||||
window.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.facebook.react.defaults.DefaultReactNativeHost
|
||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||
import com.facebook.soloader.SoLoader
|
||||
import com.procure.excel.ExcelPackage
|
||||
import com.procure.security.ScreenGuardPackage
|
||||
|
||||
class MainApplication : Application(), ReactApplication {
|
||||
|
||||
@@ -20,6 +21,7 @@ class MainApplication : Application(), ReactApplication {
|
||||
override fun getPackages(): List<ReactPackage> =
|
||||
PackageList(this).packages.apply {
|
||||
add(ExcelPackage())
|
||||
add(ScreenGuardPackage())
|
||||
}
|
||||
|
||||
override fun getJSMainModuleName(): String = "index"
|
||||
|
||||
@@ -317,7 +317,13 @@ class ExcelModule(private val reactContext: ReactApplicationContext) :
|
||||
for (i in 0 until edits.length()) {
|
||||
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)
|
||||
|
||||
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) {
|
||||
val sheetIndex = edit.getInt("sheet")
|
||||
if (sheetIndex < 0 || sheetIndex >= wb.numberOfSheets) return
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.procure.security
|
||||
|
||||
import android.view.WindowManager
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
|
||||
/**
|
||||
* ScreenGuard — runtime FLAG_SECURE toggle.
|
||||
*
|
||||
* FLAG_SECURE blocks screenshots, screen recording and the app-switcher
|
||||
* thumbnail for the whole Activity window. The app is single-Activity, so
|
||||
* flipping the flag as the user navigates gives per-screen protection. WHICH
|
||||
* screens are protected lives entirely in JS (see core/security/screenGuard.ts);
|
||||
* this module only applies the decision. Window flags must be mutated on the UI
|
||||
* thread.
|
||||
*/
|
||||
class ScreenGuardModule(private val reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
override fun getName(): String = "ScreenGuard"
|
||||
|
||||
@ReactMethod
|
||||
fun setSecure(enable: Boolean) {
|
||||
val activity = currentActivity ?: return
|
||||
activity.runOnUiThread {
|
||||
val window = activity.window ?: return@runOnUiThread
|
||||
if (enable) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.procure.security
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
/**
|
||||
* Registers [ScreenGuardModule] with React Native. Added manually in
|
||||
* MainApplication.getPackages(); accessible from JS via NativeModules.ScreenGuard.
|
||||
*/
|
||||
class ScreenGuardPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
||||
listOf(ScreenGuardModule(reactContext))
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
||||
emptyList()
|
||||
}
|
||||
@@ -133,10 +133,11 @@ These look like leftovers but were left deliberately. Read before "fixing".
|
||||
- **`ResetPassword`** sends the new password as a URL query param. Migrated to
|
||||
`core/http` (so it's redacted in logs) but the query-param placement is a
|
||||
backend contract — see the `TODO(backend)` at the call site.
|
||||
- **`PurchaseTenderTile` "details" tap** navigates to `'TenderDetail'`, an
|
||||
unregistered route (a pre-existing latent bug). The intended destination is
|
||||
ambiguous (the tile already renders an in-tile modal), so it awaits a product
|
||||
decision rather than a guessed target.
|
||||
- **`PurchaseTenderTile` "details" tap** (in `PurchaseTenderAllList`) now
|
||||
navigates to the registered `LiveTenderDetail` route, passing the flat tender
|
||||
`item` that screen reads from `route.params` — matching `LiveTenderTile`'s
|
||||
working pattern. It previously targeted an unregistered `'TenderDetail'` route
|
||||
(a latent bug that no-op'd with a dev warning).
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
-12
@@ -37,7 +37,6 @@
|
||||
"moment": "2.30.1",
|
||||
"react": "19.0.0",
|
||||
"react-native": "0.79.0",
|
||||
"react-native-alert-notification": "0.4.2",
|
||||
"react-native-app-intro-slider": "4.0.4",
|
||||
"react-native-blob-util": "0.24.6",
|
||||
"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": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-animatable/-/react-native-animatable-1.4.0.tgz",
|
||||
|
||||
+2
-3
@@ -22,16 +22,16 @@
|
||||
"@react-navigation/native-stack": "^7.17.3",
|
||||
"@react-navigation/stack": "7.6.13",
|
||||
"@reduxjs/toolkit": "2.11.2",
|
||||
"@shopify/flash-list": "^1.7.6",
|
||||
"@stomp/stompjs": "7.2.1",
|
||||
"@tanstack/react-query": "5.90.12",
|
||||
"@shopify/flash-list": "^1.7.6",
|
||||
"axios": "1.13.2",
|
||||
"buffer": "6.0.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"exceljs": "^3.4.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-native": "5.0.0",
|
||||
"exceljs": "^3.4.0",
|
||||
"formik": "2.4.9",
|
||||
"hyperformula": "^2.7.1",
|
||||
"jsencrypt": "^3.5.4",
|
||||
@@ -40,7 +40,6 @@
|
||||
"moment": "2.30.1",
|
||||
"react": "19.0.0",
|
||||
"react-native": "0.79.0",
|
||||
"react-native-alert-notification": "0.4.2",
|
||||
"react-native-app-intro-slider": "4.0.4",
|
||||
"react-native-blob-util": "0.24.6",
|
||||
"react-native-bouncy-checkbox": "4.1.2",
|
||||
|
||||
@@ -24,5 +24,7 @@ const ImagePath = {
|
||||
LightBg: require('../Images/Backgrounds/lightBackground.png'),
|
||||
DarkBg2: require('../Images/Backgrounds/darkBackground2.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;
|
||||
@@ -1,26 +1,13 @@
|
||||
import { ALERT_TYPE, Toast } from 'react-native-alert-notification';
|
||||
import { showAppToast } from '../components/AppToast';
|
||||
|
||||
const showSuccessAlert = (text: string) => {
|
||||
Toast.show({
|
||||
type: ALERT_TYPE.SUCCESS,
|
||||
title: 'Success',
|
||||
textBody: text,
|
||||
});
|
||||
showAppToast({ type: 'success', message: text });
|
||||
};
|
||||
|
||||
const showErrorAlert = (text: string) => {
|
||||
Toast.show({
|
||||
type: ALERT_TYPE.DANGER,
|
||||
title: 'Error',
|
||||
textBody: text,
|
||||
});
|
||||
showAppToast({ type: 'error', message: text });
|
||||
};
|
||||
|
||||
const showToast = (text: string) => {
|
||||
Toast.show({
|
||||
type: ALERT_TYPE.SUCCESS,
|
||||
title: 'Success',
|
||||
textBody: text,
|
||||
});
|
||||
}
|
||||
export { showErrorAlert, showSuccessAlert, showToast }
|
||||
const showToast = showSuccessAlert;
|
||||
|
||||
export { showErrorAlert, showSuccessAlert, showToast };
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Platform, ToastAndroid } from "react-native";
|
||||
|
||||
export const showToast = (message: string) => {
|
||||
if (Platform.OS === 'android') {
|
||||
ToastAndroid.show(message, ToastAndroid.SHORT);
|
||||
} else {
|
||||
console.log(message); // Fallback for iOS or other platforms
|
||||
}
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 MiB |
+70
-188
@@ -1,187 +1,82 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Text } from 'react-native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import * as LucideIcons from 'lucide-react-native';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
interpolate,
|
||||
Extrapolation
|
||||
} from 'react-native-reanimated';
|
||||
import Animated, { ZoomIn } from 'react-native-reanimated';
|
||||
import Screens from '../Screens';
|
||||
|
||||
import NavigationString from '../Constant/NavigationString';
|
||||
import { useTheme } from '../Hooks/useTheme';
|
||||
import { typography } from '../theme/typography';
|
||||
// import EditableExcel from '../Screens/Tender/Component/EnvelopeCExcelPreview';
|
||||
|
||||
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 { colors } = useTheme();
|
||||
const icons: Record<string, { outline: keyof typeof LucideIcons; filled: keyof typeof LucideIcons }> = {
|
||||
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
|
||||
};
|
||||
});
|
||||
const IconName = (LucideIcons[ICON_BY_ROUTE[route.name] || 'HelpCircle'] as React.ElementType);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={styles.tabButton}
|
||||
activeOpacity={0.8}
|
||||
activeOpacity={0.85}
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected: isFocused }}
|
||||
accessibilityLabel={`${route.name} tab`}
|
||||
>
|
||||
<View style={styles.tabContent}>
|
||||
<Animated.View style={animatedIconStyle}>
|
||||
{IconName && (
|
||||
<IconName
|
||||
size={24}
|
||||
color={isFocused ? colors.primary : colors.text}
|
||||
strokeWidth={isFocused ? 2.5 : 2}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{isFocused && (
|
||||
<Animated.Text
|
||||
style={[
|
||||
styles.label,
|
||||
animatedTextStyle,
|
||||
{
|
||||
color: colors.primary,
|
||||
fontFamily: typography.fonts.medium,
|
||||
fontWeight: undefined, // Remove fontWeight as we use fontFamily
|
||||
},
|
||||
]}
|
||||
{isFocused ? (
|
||||
// Active: a solid brand pill that pops in with icon + label.
|
||||
<Animated.View
|
||||
entering={ZoomIn.duration(220)}
|
||||
style={[styles.activePill, { backgroundColor: colors.primary }]}
|
||||
>
|
||||
{IconName && <IconName size={20} color="#FFFFFF" strokeWidth={2.4} />}
|
||||
<Text
|
||||
style={[styles.activeLabel, { fontFamily: typography.fonts.medium }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{route.name === 'BrandDay' ? 'Coupons' : route.name}
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
{route.name}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
) : (
|
||||
// Inactive: muted icon only.
|
||||
IconName && (
|
||||
<IconName size={24} color={colors.textSecondary} strokeWidth={2} />
|
||||
)
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomTabBar = ({ state, navigation }: any) => {
|
||||
const { colors } = useTheme();
|
||||
const { width: windowWidth } = Dimensions.get('window');
|
||||
const TAB_BAR_WIDTH = windowWidth - 40; // 20 margin each side
|
||||
const TAB_PADDING_H = 10; // 5 padding left + 5 padding right
|
||||
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 (
|
||||
<View style={[
|
||||
styles.tabBar,
|
||||
{
|
||||
backgroundColor: colors.background, // Use transparent to allow shadow to show
|
||||
shadowColor: colors.shadow, // Dynamic shadow color
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
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) => {
|
||||
const isFocused = state.index === index;
|
||||
|
||||
return (
|
||||
<TabButton
|
||||
key={route.key}
|
||||
route={route}
|
||||
isFocused={isFocused}
|
||||
onPress={() => navigation.navigate(route.name)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<View
|
||||
style={[
|
||||
styles.tabBar,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
shadowColor: colors.shadow,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{state.routes.map((route: any, index: number) => (
|
||||
<TabButton
|
||||
key={route.key}
|
||||
route={route}
|
||||
isFocused={state.index === index}
|
||||
onPress={() => navigation.navigate(route.name)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -202,9 +97,7 @@ export default function BottomTab() {
|
||||
<Tab.Screen name="Home" component={Screens.Home} />
|
||||
<Tab.Screen name={NavigationString.Auction} component={Screens.Auction} />
|
||||
<Tab.Screen name={NavigationString.Tender} component={Screens.Tender} />
|
||||
{/* <Tab.Screen name={'EditableExcel'} component={EditableExcel} /> */}
|
||||
<Tab.Screen name={NavigationString.Profile} component={Screens.ProfileTab} />
|
||||
|
||||
</Tab.Navigator>
|
||||
</View>
|
||||
);
|
||||
@@ -213,54 +106,43 @@ export default function BottomTab() {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
|
||||
},
|
||||
tabBar: {
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
bottom: 24,
|
||||
left: 20,
|
||||
right: 20,
|
||||
flexDirection: 'row',
|
||||
height: 70,
|
||||
borderRadius: 35,
|
||||
elevation: 20, // stronger Android elevation
|
||||
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',
|
||||
height: 66,
|
||||
borderRadius: 33,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
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: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
},
|
||||
tabContent: {
|
||||
activePill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 4, // Drastically reduced from 12
|
||||
paddingVertical: 4,
|
||||
borderRadius: 20,
|
||||
gap: 6,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 9,
|
||||
borderRadius: 22,
|
||||
},
|
||||
slidingIndicator: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 5,
|
||||
height: 40,
|
||||
borderRadius: 30,
|
||||
zIndex: 0,
|
||||
},
|
||||
label: {
|
||||
fontSize: 11, // Slightly smaller text
|
||||
textAlign: 'left',
|
||||
textTransform: 'capitalize',
|
||||
letterSpacing: 0.2, // Tighter spacing
|
||||
activeLabel: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 13,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -28,7 +28,6 @@ const Auction = () => {
|
||||
const { data: liveAuctionData = {} }: any = useFetchLiveAuction();
|
||||
const { data: purchaseAuctionData = {} } = useFetchPurchaseAuction();
|
||||
const SelectedPurchaseList = useSelector((state: any) => state.selectPurchaseAuction);
|
||||
useSelector((state: any) => state.token?.token);
|
||||
|
||||
const liveCount = Array.isArray(liveAuctionData?.result)
|
||||
? liveAuctionData?.pages?.[0]?.totalCount
|
||||
@@ -58,7 +57,10 @@ const Auction = () => {
|
||||
|
||||
const cartCount = SelectedPurchaseList.length;
|
||||
|
||||
const styles = makeStyles(colors, spacing, shadows, TAB_WIDTH, TAB_HEIGHT);
|
||||
const styles = useMemo(
|
||||
() => makeStyles(colors, spacing, shadows, TAB_WIDTH, TAB_HEIGHT),
|
||||
[colors, spacing, shadows, TAB_WIDTH],
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import Animated, { FadeInDown, FadeOutUp } from 'react-native-reanimated';
|
||||
import LiveAuctionTile from './LiveAuctionTile';
|
||||
import { useFetchLiveAuction } from '../../Services/ApiCall/Hooks';
|
||||
import { SearchInput } from '../../components/SearchInput';
|
||||
import { Skeleton } from '../../components';
|
||||
import { Skeleton, EmptyState } from '../../components';
|
||||
import SkeletonList from '../../components/SkeletonList';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { togglePurchaseAuction } from '../../Redux/Slices/selectPurchaseAuction';
|
||||
@@ -46,7 +46,7 @@ const LiveAuction = () => {
|
||||
data={item}
|
||||
index={index}
|
||||
isPurchasedByContractor={item.contractorId?.includes(
|
||||
String(user.contractorId),
|
||||
String(user?.contractorId),
|
||||
)}
|
||||
auctionId={item.auctionId}
|
||||
auctionDispatchDate={item.auctionDispatchDate}
|
||||
@@ -56,7 +56,6 @@ const LiveAuction = () => {
|
||||
auctionFees={item.auctionFees}
|
||||
auctionNumber={item.auctionNumber}
|
||||
onPurchasePress={() => {
|
||||
console.log('[LiveAuction] purchase toggled:', item?.auctionNumber);
|
||||
Dispatch(togglePurchaseAuction(item));
|
||||
}}
|
||||
onDetailsPress={() => {}}
|
||||
@@ -71,7 +70,6 @@ const LiveAuction = () => {
|
||||
const loadMore = useCallback(() => {
|
||||
if (onEndReachedCalledDuringMomentum.current) return;
|
||||
if (!hasNextPage || isFetchingNextPage) return;
|
||||
console.log('[LiveAuction] loadMore: fetching next page');
|
||||
fetchNextPage();
|
||||
onEndReachedCalledDuringMomentum.current = true;
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
@@ -90,24 +88,13 @@ const LiveAuction = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (isRefetching) {
|
||||
console.log('[LiveAuction] refetch started');
|
||||
setShowRefreshBanner(true);
|
||||
} else {
|
||||
console.log('[LiveAuction] refetch completed');
|
||||
const timer = setTimeout(() => setShowRefreshBanner(false), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isRefetching]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[LiveAuction] data updated:', {
|
||||
count: data?.result?.length ?? 0,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isPending,
|
||||
});
|
||||
}, [data, hasNextPage, isFetchingNextPage, isPending]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
@@ -153,6 +140,12 @@ const LiveAuction = () => {
|
||||
onEndReachedCalledDuringMomentum.current = false;
|
||||
}}
|
||||
ListFooterComponent={renderFooter}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
title="No live auctions found"
|
||||
subtitle="New live auctions will appear here as they open."
|
||||
/>
|
||||
}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefetching}
|
||||
@@ -178,6 +171,7 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 60,
|
||||
paddingTop: 10,
|
||||
|
||||
@@ -23,6 +23,8 @@ import ReactNativeBlobUtil from 'react-native-blob-util';
|
||||
import AuctionItemDetail from './AuctionItemDetail';
|
||||
import { useTheme } from '../../../Hooks/useTheme';
|
||||
import AppText from '../../../components/AppText';
|
||||
import { showToast } from '../../../Constant/ShowDailog';
|
||||
import { logger } from '../../../core/observability/logger';
|
||||
import moment from 'moment';
|
||||
|
||||
interface Props { visible: boolean; onClose: () => void; data?: any }
|
||||
@@ -36,8 +38,11 @@ const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
const [allBoolean, setAllBoolean] = useState({
|
||||
showItemDetail: false,
|
||||
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 { 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');
|
||||
};
|
||||
|
||||
const isLive = data?.purchaseEnd
|
||||
? moment().isBefore(moment(data.purchaseEnd, 'DD-MM-YYYY HH:mm:ss'))
|
||||
: false;
|
||||
|
||||
const handleDownload = async (id: number, fileName: string) => {
|
||||
if (downloadingId !== null) { return; } // one download at a time
|
||||
try {
|
||||
setAllBoolean(prev => ({ ...prev, fileLoading: true }));
|
||||
setDownloadingId(id);
|
||||
const { dirs } = ReactNativeBlobUtil.fs;
|
||||
const path = `${dirs.DownloadDir}/${fileName}.pdf`;
|
||||
const downloadUrl = `${AUCTION_DOCUMENT_DOWNLOAD}?documentId=${id}&isAmendment=false`;
|
||||
@@ -68,10 +70,12 @@ const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
mediaScannable: true,
|
||||
},
|
||||
}).fetch('GET', downloadUrl, { Accept: 'application/pdf' });
|
||||
showToast('Download complete');
|
||||
} 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 {
|
||||
setAllBoolean(prev => ({ ...prev, fileLoading: false }));
|
||||
setDownloadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -120,12 +124,6 @@ const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
</AppText>
|
||||
</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>
|
||||
|
||||
{/* ── Body ── */}
|
||||
@@ -204,7 +202,10 @@ const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
index === aucitonDetail.auctionDocuments.length - 1 && { borderBottomWidth: 0 },
|
||||
]}
|
||||
activeOpacity={0.7}
|
||||
disabled={downloadingId !== null}
|
||||
onPress={() => handleDownload(doc.auctionDocumentId, doc.fileName.replace('.pdf', ''))}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Download ${doc.fileName}`}
|
||||
>
|
||||
<View style={[styles.docIcon, { backgroundColor: `${colors.error}15` }]}>
|
||||
<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}>
|
||||
{doc.fileName}
|
||||
</AppText>
|
||||
{allBoolean.fileLoading
|
||||
{downloadingId === doc.auctionDocumentId
|
||||
? <ActivityIndicator size="small" color={colors.primary} />
|
||||
: <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>
|
||||
</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}>
|
||||
<AppText variant="caption" bold style={styles.tableLabel}>Sr No.</AppText>
|
||||
<AppText variant="caption" bold style={[styles.tableLabel, { flex: 2 }]}>Document Name</AppText>
|
||||
|
||||
@@ -7,7 +7,7 @@ import PurchaseAuctionTile from './PurchaseAuctionTile';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { setAuction } from '../../Redux/Slices/SelectedAuction';
|
||||
import { SearchInput } from '../../components/SearchInput';
|
||||
import { Skeleton } from '../../components';
|
||||
import { Skeleton, EmptyState } from '../../components';
|
||||
import { allBooleanProps } from './types/types';
|
||||
import FilterModal from './Modal/FilterModal';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
@@ -25,8 +25,11 @@ const PurchaseAuction = () => {
|
||||
|
||||
function isAuctionActive(item: any): boolean {
|
||||
const now = moment();
|
||||
const start = moment(item?.keyDates?.auctionBidding?.startDateTime, format);
|
||||
const end = moment(item?.keyDates?.auctionBidding?.endDateTime, format);
|
||||
const bidding = item?.auctionStatus === 'Amended'
|
||||
? 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;
|
||||
|
||||
@@ -67,7 +70,7 @@ const PurchaseAuction = () => {
|
||||
{isPending ? <Skeleton />
|
||||
:
|
||||
<FlatList
|
||||
data={data.result ?? []}
|
||||
data={data?.result ?? []}
|
||||
renderItem={({ item }) => (
|
||||
<PurchaseAuctionTile
|
||||
item={item}
|
||||
@@ -82,7 +85,7 @@ const PurchaseAuction = () => {
|
||||
/>
|
||||
)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 50, paddingTop: 10 }}
|
||||
contentContainerStyle={{ flexGrow: 1, paddingBottom: 50, paddingTop: 10 }}
|
||||
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.1}
|
||||
@@ -92,6 +95,12 @@ const PurchaseAuction = () => {
|
||||
}}
|
||||
|
||||
ListFooterComponent={renderFooter}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
title="No purchased auctions found"
|
||||
subtitle="Auctions you purchase will appear here."
|
||||
/>
|
||||
}
|
||||
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
@@ -110,7 +119,7 @@ const PurchaseAuction = () => {
|
||||
}
|
||||
{allBoolean.isShowFilter && <View style={{ flex: 1 }}>
|
||||
<FilterModal visible={allBoolean.isShowFilter} onClose={() => { setAllBoolean((prev: any) => ({ ...prev, isShowFilter: false })) }} setFilter={setFilter} filter={filter} />
|
||||
</View>}.
|
||||
</View>}
|
||||
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -95,6 +95,21 @@ const PurchaseAuctionList = () => {
|
||||
}, 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 auctionNumbers: string[] = SelectedPurchaseList.map(
|
||||
(item: { auctionNumber: string }) => item.auctionNumber,
|
||||
@@ -103,9 +118,19 @@ const PurchaseAuctionList = () => {
|
||||
{ auctionNumbers, contractorId: LoginUser.contractorId },
|
||||
{
|
||||
onSuccess: res => {
|
||||
if (res.statusCode === 200) setList(res.data);
|
||||
if (res.statusCode === 200) {
|
||||
setList(res.data);
|
||||
} else if (isAlreadyPurchased(res)) {
|
||||
goHome();
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (isAlreadyPurchased(error?.response?.data)) {
|
||||
goHome();
|
||||
} else {
|
||||
console.log(error, 'purchase list error');
|
||||
}
|
||||
},
|
||||
onError: error => console.log(error, 'purchase list error'),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -42,12 +42,15 @@ const AuctionBiddingScreen = () => {
|
||||
const { colors, spacing, shadows } = useTheme();
|
||||
const SelectedAuction = useSelector((state: any) => state.auction.auction);
|
||||
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 config = useMemo(
|
||||
() => getBiddingConfig(SelectedAuction?.auctionPattern),
|
||||
[SelectedAuction?.auctionPattern],
|
||||
);
|
||||
|
||||
const [items, setItems] = useState<any>([]);
|
||||
const [bidValues, setBidValues] = useState<Record<number, number>>({});
|
||||
const [allBoolean, setAllBoolean] = useState({
|
||||
@@ -104,11 +107,11 @@ const AuctionBiddingScreen = () => {
|
||||
...item,
|
||||
bidAmount: bidData?.bidAmount ?? config.startValue(item),
|
||||
bidAvilable: bidData?.bidAmount ?? 0,
|
||||
me: bidData?.contractorId === loginUser.contractorId,
|
||||
me: bidData?.contractorId === contractorId,
|
||||
};
|
||||
});
|
||||
const initialBids: Record<number, number> = {};
|
||||
updatedFilteredItems.forEach(item => { initialBids[item.id] = item.bidAmount; });
|
||||
updatedFilteredItems.forEach(item => { initialBids[item.id] = Number(item.bidAmount); });
|
||||
setBidValues(initialBids);
|
||||
setItems(updatedFilteredItems);
|
||||
} else {
|
||||
@@ -125,12 +128,12 @@ const AuctionBiddingScreen = () => {
|
||||
bidId: bid.bidId,
|
||||
contractorId: bid.contractorId,
|
||||
contractorName: bid.contractorName,
|
||||
me: bid.contractorId === loginUser.contractorId,
|
||||
me: bid.contractorId === contractorId,
|
||||
bidTime: bid.bidTime,
|
||||
};
|
||||
});
|
||||
const initialBids: Record<number, number> = {};
|
||||
mergedData.forEach(item => { initialBids[item.id] = item.bidAmount; });
|
||||
mergedData.forEach(item => { initialBids[item.id] = Number(item.bidAmount); });
|
||||
setBidValues(initialBids);
|
||||
setItems(mergedData);
|
||||
}
|
||||
@@ -152,7 +155,7 @@ const AuctionBiddingScreen = () => {
|
||||
: item,
|
||||
),
|
||||
);
|
||||
setBidValues(prev => ({ ...prev, [result.auctionItemId]: result.bidAmount }));
|
||||
setBidValues(prev => ({ ...prev, [result.auctionItemId]: Number(result.bidAmount) }));
|
||||
});
|
||||
return () => unsubscribe(destination);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -170,8 +173,19 @@ const AuctionBiddingScreen = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allBoolean.reload]);
|
||||
|
||||
const handleUpdate = (id: number, val: number) => {
|
||||
setBidValues(prev => ({ ...prev, [id]: prev[id] + val }));
|
||||
// Move a bid one step in the given direction. The next value is computed from
|
||||
// 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) => {
|
||||
@@ -179,7 +193,7 @@ const AuctionBiddingScreen = () => {
|
||||
itemId: item.id,
|
||||
bidAmount: bidValues[item.id],
|
||||
auctionId: SelectedAuction.auctionId,
|
||||
contractorId: loginUser.contractorId,
|
||||
contractorId,
|
||||
fullName: loginUser.fullName,
|
||||
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 =
|
||||
SelectedAuction?.auctionStatus === 'Amended'
|
||||
@@ -280,8 +294,8 @@ const AuctionBiddingScreen = () => {
|
||||
<AuctionBiddingCard
|
||||
item={{ ...item, index }}
|
||||
bidValue={bidValues[item.id] || 0}
|
||||
onIncrease={() => handleUpdate(item.id, config.step(item))}
|
||||
onDecrease={() => handleUpdate(item.id, -config.step(item))}
|
||||
onIncrease={() => stepBid(item, 'up')}
|
||||
onDecrease={() => stepBid(item, 'down')}
|
||||
onPlaceBid={() => submitBid(item)}
|
||||
SelectedAuction={SelectedAuction}
|
||||
allBoolean={allBoolean}
|
||||
|
||||
@@ -43,7 +43,6 @@ const AuctionBiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid
|
||||
const canDecrease = config.canDecrease(bidValue, item);
|
||||
const bodyFull = config.isBodyFull(allBoolean, item);
|
||||
const submitVisible = config.showSubmit(allBoolean, item);
|
||||
|
||||
// 🔒 Persist expand state safely
|
||||
const expandedRef = useRef(false);
|
||||
const animation = useSharedValue(0);
|
||||
@@ -98,7 +97,8 @@ const AuctionBiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid
|
||||
itemId: item.id,
|
||||
bidAmount: Number(value),
|
||||
auctionId: SelectedAuction.auctionId,
|
||||
contractorId: Token.contractorId,
|
||||
// Session stores the contractor id as `id` (contractorId is absent).
|
||||
contractorId: Token?.contractorId ?? Token?.id,
|
||||
fullName: Token.fullName,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -108,7 +108,6 @@ const Emd = () => {
|
||||
|
||||
/* ── Render Item ── */
|
||||
const renderItem = ({ item }: any) => {
|
||||
console.log(item, "item");
|
||||
const selected = isSelected(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
|
||||
useEffect(() => { AlreadyEmdCheck(); }, []);
|
||||
|
||||
const styles = makeStyles(colors, spacing, shadows, typography);
|
||||
const styles = useMemo(() => makeStyles(colors, spacing, shadows, typography), [colors, spacing, shadows, typography]);
|
||||
|
||||
/* ── Policy + Submit footer ── */
|
||||
const BottomBar = ({ onSubmit }: { onSubmit: () => void }) => (
|
||||
|
||||
@@ -53,17 +53,24 @@ describe('forward pattern', () => {
|
||||
|
||||
describe('reverse pattern', () => {
|
||||
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', () => {
|
||||
expect(cfg.itemsUrl).toBe(REVERSE_AUCTION_ITEM);
|
||||
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.stepPrefix).toBe('-');
|
||||
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', () => {
|
||||
expect(cfg.canIncrease(700, item)).toBe(true); // 700 < 800
|
||||
expect(cfg.canIncrease(800, item)).toBe(false);
|
||||
@@ -84,3 +91,44 @@ describe('reverse pattern', () => {
|
||||
expect(cfg.validateManualBid(123, item)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stepper robustness (both patterns)', () => {
|
||||
const rev = getBiddingConfig('Reverse');
|
||||
const fwd = getBiddingConfig('Forward');
|
||||
|
||||
it('never resolves a zero/NaN step, even when every amount field is missing', () => {
|
||||
// This is the exact "DECREMENT -₹0 / button does nothing" bug.
|
||||
expect(rev.step({ auctionStartValue: '550', bidAmount: '539', emdAmount: '0' })).toBeGreaterThan(0);
|
||||
expect(rev.step({})).toBeGreaterThanOrEqual(1);
|
||||
expect(fwd.step({})).toBeGreaterThanOrEqual(1);
|
||||
// With no configured bid-variation the step is a flat ₹1.
|
||||
expect(rev.step({ auctionStartValue: 1000 })).toBe(1);
|
||||
expect(fwd.step({ auctionStartValueFigure: 200 })).toBe(1);
|
||||
});
|
||||
|
||||
it('reverse stepDown moves and clamps to the EMD floor without overshooting', () => {
|
||||
const it = { auctionStartValue: 1000, emdAmount: 50, bidAmount: 800, bidVariationValue: 50 };
|
||||
expect(rev.stepDown(800, it)).toBe(750);
|
||||
expect(rev.stepDown(60, it)).toBe(50); // 60-50=10 would undershoot -> clamped to floor
|
||||
expect(rev.stepDown(50, it)).toBe(50); // already at floor
|
||||
});
|
||||
|
||||
it('reverse stepUp clamps to the current bid (ceiling)', () => {
|
||||
const it = { auctionStartValue: 1000, emdAmount: 50, bidAmount: 800, bidVariationValue: 50 };
|
||||
expect(rev.stepUp(760, it)).toBe(800); // 760+50=810 would exceed -> clamped to ceil
|
||||
expect(rev.stepUp(800, it)).toBe(800); // already at ceil
|
||||
});
|
||||
|
||||
it('forward stepUp is unbounded and stepDown floors at the current bid', () => {
|
||||
const it = { auctionStartValueFigure: 100, bidVariationValue: 10, bidAmount: 150 };
|
||||
expect(fwd.stepUp(150, it)).toBe(160);
|
||||
expect(fwd.stepDown(160, it)).toBe(150);
|
||||
expect(fwd.stepDown(150, it)).toBe(150); // can't undercut the current bid
|
||||
});
|
||||
|
||||
it('a reverse item with no variation still decrements (regression for -₹0)', () => {
|
||||
const it = { auctionStartValue: '550', bidAmount: '539', emdAmount: '0' };
|
||||
expect(rev.canDecrease(539, it)).toBe(true);
|
||||
expect(rev.stepDown(539, it)).toBeLessThan(539);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
* Every behavioral difference between them is captured HERE, explicitly, so
|
||||
* the screen and card contain zero `if (pattern === ...)` logic and a bidding
|
||||
* 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';
|
||||
|
||||
@@ -21,13 +28,19 @@ export type BiddingPatternConfig = {
|
||||
itemsUrl: string;
|
||||
/** Opening amount — the backend uses a different field per pattern. */
|
||||
startValue: (item: any) => number;
|
||||
/** Stepper step size. */
|
||||
/** Stepper step size — always a positive number (see resolveStep). */
|
||||
step: (item: any) => number;
|
||||
stepLabel: 'Increment' | 'Decrement';
|
||||
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;
|
||||
/** Forward bids may not drop below the current bid; reverse not below the EMD amount. */
|
||||
canDecrease: (bidValue: number, item: any) => boolean;
|
||||
/** Gate applied while typing a manual (one-time/rebid) amount. */
|
||||
acceptManualInput: (value: number, item: any) => boolean;
|
||||
@@ -41,18 +54,67 @@ export type BiddingPatternConfig = {
|
||||
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 = {
|
||||
pattern: 'Forward',
|
||||
itemsUrl: FORWARD_AUCTION_ITEM,
|
||||
startValue: item => item?.auctionStartValueFigure,
|
||||
step: item => item?.bidVariationValue,
|
||||
startValue: item => num(item?.auctionStartValueFigure),
|
||||
step: item => resolveStep(item),
|
||||
stepLabel: 'Increment',
|
||||
stepPrefix: '+',
|
||||
canIncrease: () => true,
|
||||
canDecrease: (bidValue, item) => bidValue > item.bidAmount,
|
||||
// Forward: outbid upward. Never below the current bid; no upper limit.
|
||||
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,
|
||||
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,
|
||||
isBodyFull: (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 = {
|
||||
pattern: 'Reverse',
|
||||
itemsUrl: REVERSE_AUCTION_ITEM,
|
||||
startValue: item => item?.auctionStartValue,
|
||||
step: item => item?.emdAmount,
|
||||
startValue: item => num(item?.auctionStartValue),
|
||||
step: item => resolveStep(item),
|
||||
stepLabel: 'Decrement',
|
||||
stepPrefix: '-',
|
||||
canIncrease: (bidValue, item) => bidValue < item.bidAmount,
|
||||
canDecrease: (bidValue, item) => bidValue > item.emdAmount,
|
||||
acceptManualInput: (value, item) => value >= 0 && value <= Number(item?.auctionStartValue),
|
||||
// Reverse: undercut downward. Never above the current bid; never below EMD.
|
||||
floor: item => num(item?.emdAmount),
|
||||
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,
|
||||
inlineManualInput: true,
|
||||
isBodyFull: () => true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Layers, ShoppingCart, X, FileText, Menu, Radio, Clock, ChevronRight } from 'lucide-react-native';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
@@ -9,12 +9,18 @@ import { getToken } from '../../Redux/Slices/Token';
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import AppText from '../../components/AppText';
|
||||
import SidebarModal from '../../components/SidebarModal';
|
||||
import ImagePath from '../../Constant/ImagePath';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
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 [tenderStats, setTenderStats] = useState<any>({});
|
||||
@@ -54,7 +60,7 @@ const Home = () => {
|
||||
styles.glassCard,
|
||||
styles.halfWidthCard,
|
||||
{
|
||||
backgroundColor: colors.glass,
|
||||
backgroundColor: cardBg,
|
||||
borderColor: colors.overlay,
|
||||
}
|
||||
]}
|
||||
@@ -72,8 +78,13 @@ const Home = () => {
|
||||
);
|
||||
|
||||
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}>
|
||||
|
||||
{/* Top Bar */}
|
||||
@@ -145,7 +156,7 @@ const Home = () => {
|
||||
{/* Recent Activity */}
|
||||
<View style={styles.sectionContainer}>
|
||||
<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 }]}>
|
||||
<Clock size={20} color={colors.primary} />
|
||||
</View>
|
||||
@@ -157,7 +168,7 @@ const Home = () => {
|
||||
</View>
|
||||
|
||||
</ScrollView>
|
||||
</View >
|
||||
</ImageBackground>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -167,6 +178,10 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
// Hides the ImageBackground's image layer in dark mode (no wallpaper there).
|
||||
hiddenBg: {
|
||||
opacity: 0,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingTop: 40,
|
||||
paddingHorizontal: 16,
|
||||
|
||||
@@ -1,32 +1,42 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { View, ActivityIndicator, StyleSheet, Text } from "react-native";
|
||||
import { 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 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) => {
|
||||
const webRef = useRef<WebView>(null);
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const params = route.params ?? {};
|
||||
// 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 countdownIntervalRef = useRef<ReturnType<typeof setInterval> | 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 [loadError, setLoadError] = useState<boolean>(false);
|
||||
const [showSuccessMessage, setShowSuccessMessage] = useState<boolean>(false);
|
||||
const [countdown, setCountdown] = useState<number>(5);
|
||||
const url = false;
|
||||
const [countdown, setCountdown] = useState<number>(COUNTDOWN_SECONDS);
|
||||
|
||||
// Clear BOTH timers on unmount — the countdown interval previously leaked and
|
||||
// called setState on an unmounted component if the user left mid-countdown.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollTimerRef.current) {
|
||||
// Intentionally reads the ref at cleanup; the timer id stored on the ref is exactly what must be cleared.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
clearInterval(pollTimerRef.current);
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
}
|
||||
if (successTimeoutRef.current) {
|
||||
clearTimeout(successTimeoutRef.current);
|
||||
@@ -34,118 +44,79 @@ export const KycWebViewScreen = ({ route, navigation }: any) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleNavigationBack = () => {
|
||||
if (data.returnRoute) {
|
||||
navigation.replace(data.returnRoute, data.returnParams);
|
||||
const handleNavigationBack = useCallback(() => {
|
||||
if (params.returnRoute) {
|
||||
navigation.replace(params.returnRoute, params.returnParams);
|
||||
} else {
|
||||
navigation.dispatch(
|
||||
CommonActions.reset({
|
||||
index: 0,
|
||||
routes: [{ name: 'Login' }],
|
||||
})
|
||||
CommonActions.reset({ index: 0, routes: [{ name: "Login" }] }),
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [navigation, params.returnRoute, params.returnParams]);
|
||||
|
||||
const startSuccessCountdown = () => {
|
||||
const startSuccessCountdown = useCallback(() => {
|
||||
setShowSuccessMessage(true);
|
||||
setCountdown(5);
|
||||
setCountdown(COUNTDOWN_SECONDS);
|
||||
|
||||
let remainingSeconds = 5;
|
||||
const countdownInterval = setInterval(() => {
|
||||
remainingSeconds -= 1;
|
||||
setCountdown(remainingSeconds);
|
||||
|
||||
if (remainingSeconds <= 0) {
|
||||
clearInterval(countdownInterval);
|
||||
let remaining = COUNTDOWN_SECONDS;
|
||||
countdownIntervalRef.current = setInterval(() => {
|
||||
remaining -= 1;
|
||||
setCountdown(remaining);
|
||||
if (remaining <= 0 && countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
countdownIntervalRef.current = null;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
successTimeoutRef.current = setTimeout(() => {
|
||||
clearInterval(countdownInterval);
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
countdownIntervalRef.current = null;
|
||||
}
|
||||
handleNavigationBack();
|
||||
}, SUCCESS_DELAY_MS);
|
||||
};
|
||||
}, [handleNavigationBack]);
|
||||
|
||||
const handleLoadStart = (_event: any) => {
|
||||
const handleLoadStart = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
setLoadError(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLoadEnd = (_event: any) => {
|
||||
const handleLoadEnd = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleError = (_event: any) => {
|
||||
const handleError = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
setLoadError(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleNavigationStateChange = (navState: any) => {
|
||||
if (
|
||||
navState.title &&
|
||||
navState.title.includes('E-KYC SETU - Verification Success') &&
|
||||
!hasCompletedRef.current
|
||||
) {
|
||||
hasCompletedRef.current = true;
|
||||
startSuccessCountdown();
|
||||
}
|
||||
};
|
||||
|
||||
const LoadingIndicator = () => (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary || '#2563EB'} />
|
||||
<Text style={styles.loadingText}>Loading KYC form...</Text>
|
||||
</View>
|
||||
const handleNavigationStateChange = useCallback(
|
||||
(navState: WebViewNavigation) => {
|
||||
if (
|
||||
navState.title?.includes("E-KYC SETU - Verification Success") &&
|
||||
!hasCompletedRef.current
|
||||
) {
|
||||
hasCompletedRef.current = true;
|
||||
startSuccessCountdown();
|
||||
}
|
||||
},
|
||||
[startSuccessCountdown],
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.white }}>
|
||||
{url ? (
|
||||
<WebView
|
||||
ref={webRef}
|
||||
source={{
|
||||
uri: url,
|
||||
baseUrl: "http://192.168.1.15:8080/",
|
||||
}}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled
|
||||
startInLoadingState
|
||||
originWhitelist={["*"]}
|
||||
setSupportMultipleWindows={false}
|
||||
onLoadStart={handleLoadStart}
|
||||
onLoadEnd={handleLoadEnd}
|
||||
onError={handleError}
|
||||
onNavigationStateChange={handleNavigationStateChange}
|
||||
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 />}
|
||||
/>
|
||||
)}
|
||||
<SafeAreaView style={styles.root}>
|
||||
<WebView
|
||||
source={{ html }}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled
|
||||
startInLoadingState
|
||||
onLoadStart={handleLoadStart}
|
||||
onLoadEnd={handleLoadEnd}
|
||||
onError={handleError}
|
||||
onNavigationStateChange={handleNavigationStateChange}
|
||||
renderLoading={() => <LoadingIndicator />}
|
||||
/>
|
||||
|
||||
{isLoading && !showSuccessMessage && <LoadingIndicator />}
|
||||
|
||||
@@ -162,8 +133,8 @@ export const KycWebViewScreen = ({ route, navigation }: any) => {
|
||||
</Text>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={colors.primary || '#2563EB'}
|
||||
style={{ marginTop: 16 }}
|
||||
color={colors.primary}
|
||||
style={styles.successSpinner}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -182,6 +153,10 @@ export const KycWebViewScreen = ({ route, navigation }: any) => {
|
||||
export default KycWebViewScreen;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.white,
|
||||
},
|
||||
loadingContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
@@ -248,6 +223,9 @@ const styles = StyleSheet.create({
|
||||
color: '#2563EB',
|
||||
textAlign: 'center',
|
||||
},
|
||||
successSpinner: {
|
||||
marginTop: 16,
|
||||
},
|
||||
errorContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
|
||||
+74
-54
@@ -13,7 +13,7 @@ import { useTheme } from '../../Hooks/useTheme';
|
||||
import { setToken } from '../../Redux/Slices/Token';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import ImagePath from '../../Constant/ImagePath';
|
||||
import { showErrorAlert, showToast } from '../../Constant/ShowDailog';
|
||||
import { showErrorAlert, showSuccessAlert, showToast } from '../../Constant/ShowDailog';
|
||||
import AppBackground from '../../components/AppBackground';
|
||||
import FontsFamily from '../../Constant/FontsFamily';
|
||||
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." },
|
||||
];
|
||||
|
||||
/**
|
||||
* 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 { colors, isDarkMode } = useTheme();
|
||||
const navigation = useNavigation<any>();
|
||||
@@ -99,9 +149,10 @@ const Login = () => {
|
||||
const handleSuccess = (res: any) => {
|
||||
dispatch(setToken({ ...res.data, token: res.token, userType: 'tender' }));
|
||||
if (res.statusCode === 200) {
|
||||
showSuccessAlert(res.message || 'Logged in successfully');
|
||||
handleGetData()
|
||||
} else {
|
||||
showErrorAlert(res.message);
|
||||
showErrorAlert(res.message || 'Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -121,51 +172,6 @@ const Login = () => {
|
||||
// the flicker/glitch the user sees.
|
||||
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 (
|
||||
@@ -237,13 +243,27 @@ const Login = () => {
|
||||
{/* FORM SECTION WITH GRADIENT GLASS CARD - AT BOTTOM */}
|
||||
{showForm && (
|
||||
<Animated.View style={[styles.glassCard, animatedFormStyle]}>
|
||||
{/* Glass Blur Layer */}
|
||||
<BlurView
|
||||
style={StyleSheet.absoluteFill}
|
||||
blurType={isDarkMode ? "dark" : "light"}
|
||||
blurAmount={20}
|
||||
reducedTransparencyFallbackColor={isDarkMode ? "rgba(15, 23, 42, 0.5)" : "rgba(255, 255, 255, 0.5)"}
|
||||
/>
|
||||
{/* 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
|
||||
style={StyleSheet.absoluteFill}
|
||||
blurType={isDarkMode ? "dark" : "light"}
|
||||
blurAmount={20}
|
||||
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 */}
|
||||
<LinearGradient
|
||||
|
||||
@@ -4,7 +4,7 @@ import Animated, { FadeInDown, FadeOutUp } from 'react-native-reanimated';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useFetchLiveTender } from '../../Services/ApiCall/Hooks';
|
||||
import { SearchInput } from '../../components/SearchInput';
|
||||
import { Skeleton } from '../../components';
|
||||
import { Skeleton, EmptyState } from '../../components';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { togglePurchaseTender } from '../../Redux/Slices/SelectedPurchaseTender';
|
||||
import LiveTenderTile from './LiveTenderTile';
|
||||
@@ -133,10 +133,16 @@ const LiveTender = () => {
|
||||
keyExtractor={(item) => String(item.tenderId)}
|
||||
renderItem={renderItem}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 120, paddingTop: 10 }}
|
||||
contentContainerStyle={{ flexGrow: 1, paddingBottom: 120, paddingTop: 10 }}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.2}
|
||||
ListFooterComponent={renderFooter}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
title="No live tenders found"
|
||||
subtitle="New live tenders will appear here as they open."
|
||||
/>
|
||||
}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefetching}
|
||||
|
||||
@@ -229,10 +229,6 @@ const LiveTenderDetail = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const isLive =
|
||||
tenderData?.tenderStatus?.toLowerCase() === 'live' ||
|
||||
tenderData?.tenderStatus?.toLowerCase() === 'open';
|
||||
|
||||
const formatDateTime = (val?: string) => {
|
||||
if (!val) return '-';
|
||||
return moment(val, 'DD-MM-YYYY HH:mm:ss').isValid()
|
||||
@@ -255,15 +251,6 @@ const LiveTenderDetail = () => {
|
||||
Ref: {tenderData?.tenderNumber || '-'}
|
||||
</AppText>
|
||||
</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>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
SafeAreaView,
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import Modal from 'react-native-modal';
|
||||
@@ -29,46 +30,45 @@ interface Props {
|
||||
data?: any;
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
if (!date) return '-';
|
||||
return moment(date, 'DD-MM-YYYY HH:mm:ss').format('DD MMM YYYY, hh:mm A');
|
||||
};
|
||||
|
||||
const Section = ({ title, icon: Icon, children }: any) => (
|
||||
<View style={styles.sectionContainer}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Icon size={20} color={colors.primary} />
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
|
||||
const InfoBox = ({ label, value, full = false }: any) => (
|
||||
<View style={[styles.infoBox, full && { width: '100%' }]}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Text style={styles.value}>{value || '-'}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const TenderFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
|
||||
|
||||
const inset = useSafeAreaInsets();
|
||||
const { data: tenderResponse }: any = useFetchTenderById(data?.tenderId);
|
||||
const tender = tenderResponse?.data?.[0];
|
||||
|
||||
const formatDate = (date?: string) => {
|
||||
if (!date) return '-';
|
||||
return moment(date, 'DD-MM-YYYY HH:mm:ss').format('DD MMM YYYY, hh:mm A');
|
||||
};
|
||||
|
||||
if (!tender) return null;
|
||||
|
||||
const isLive = tender?.liveStatus === true;
|
||||
|
||||
const Section = ({ title, icon: Icon, children }: any) => (
|
||||
<View style={styles.sectionContainer}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Icon size={20} color={colors.primary} />
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
|
||||
const InfoBox = ({ label, value, full = false }: any) => (
|
||||
<View style={[styles.infoBox, full && { width: '100%' }]}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Text style={styles.value}>{value || '-'}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isVisible={visible}
|
||||
onBackButtonPress={onClose}
|
||||
onBackdropPress={onClose}
|
||||
style={styles.fullModal}
|
||||
animationIn="slideInRight"
|
||||
animationOut="slideOutRight"
|
||||
useNativeDriver
|
||||
useNativeDriverForBackdrop
|
||||
statusBarTranslucent
|
||||
>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
@@ -80,21 +80,12 @@ const TenderFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.headerTitle}>Tender Overview</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>
|
||||
<Text style={styles.headerSub}>Ref: {tender?.tenderNumber ?? '—'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
{tender ? (
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
|
||||
{/* Basic Details */}
|
||||
<Section title="Basic Details" icon={Layers}>
|
||||
@@ -199,7 +190,12 @@ const TenderFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
))}
|
||||
</Section>
|
||||
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={styles.loadingState}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
);
|
||||
@@ -214,6 +210,12 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
loadingState: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -7,7 +7,7 @@ import PurchaseTenderTile from './PurchaseTenderTile';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { setTender } from '../../Redux/Slices/SelectedTender';
|
||||
import { SearchInput } from '../../components/SearchInput';
|
||||
import { Skeleton } from '../../components';
|
||||
import { Skeleton, EmptyState } from '../../components';
|
||||
import { allBooleanProps } from './types/types';
|
||||
import FilterModal from './Modal/FilterModal';
|
||||
import colors from '../../Constant/Color';
|
||||
@@ -118,12 +118,14 @@ const PurchaseTenderAllList = () => {
|
||||
data={data?.result ?? []}
|
||||
keyExtractor={(item) => String(item.tenderId || item.id)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 200, paddingHorizontal: 10 }}
|
||||
contentContainerStyle={{ flexGrow: 1, paddingBottom: 200, paddingHorizontal: 10 }}
|
||||
renderItem={({ item }) => (
|
||||
<PurchaseTenderTile
|
||||
item={item}
|
||||
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={() => {
|
||||
Dispatch(setTender(item))
|
||||
@@ -136,6 +138,12 @@ const PurchaseTenderAllList = () => {
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.6}
|
||||
ListFooterComponent={renderFooter}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
title="No purchased tenders found"
|
||||
subtitle="Tenders you purchase will appear here."
|
||||
/>
|
||||
}
|
||||
refreshing={isRefetching}
|
||||
onRefresh={refetch}
|
||||
/>}
|
||||
|
||||
@@ -169,7 +169,6 @@ const TenderItem = ({ item, index, expanded, toggleExpand, colors, styles }: any
|
||||
const Dispatch = useDispatch();
|
||||
const isOpen = item.tenderStatus === 'Opened';
|
||||
const chevronStyle = useChevronStyle(expanded);
|
||||
console.log(item, "itemmmmmmm");
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
|
||||
@@ -56,7 +56,10 @@ const Tender = () => {
|
||||
const isAuctionUser = reduxToken?.userType?.toLowerCase() === 'auction';
|
||||
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) {
|
||||
return (
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
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 { InsideHeader } from '../../../components';
|
||||
import { useTheme } from '../../../Hooks/useTheme';
|
||||
@@ -43,11 +43,27 @@ const TenderBidding = () => {
|
||||
const navigation = useNavigation();
|
||||
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 { data: tender }: any = useProceedToBid(route.params?.tenderId);
|
||||
const { data: tender, refetch: refetchProceedToBid }: any = useProceedToBid(route.params?.tenderId);
|
||||
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 [envelopForm, setEnvelopForm] = useState<EnvelopeFormState>({
|
||||
A: {},
|
||||
@@ -174,8 +190,8 @@ const TenderBidding = () => {
|
||||
});
|
||||
if (res?.statusCode === 200) {
|
||||
logger.info('envelope', `[submit] ===== envelope ${envelopeType} submitted successfully =====`);
|
||||
// Success toast is emitted globally by the HTTP client (api/success).
|
||||
navigation.goBack();
|
||||
ToastAndroid.show(res.message, ToastAndroid.SHORT);
|
||||
} else {
|
||||
logger.warn('envelope', `[submit] envelope ${envelopeType} submission rejected by server`, {
|
||||
statusCode: res?.statusCode,
|
||||
@@ -228,8 +244,8 @@ const TenderBidding = () => {
|
||||
});
|
||||
if (res?.statusCode === 200) {
|
||||
logger.info('envelope', `[submit] ===== ${activeEnvelope} submitted successfully =====`);
|
||||
// Success toast is emitted globally by the HTTP client (api/success).
|
||||
navigation.goBack();
|
||||
ToastAndroid.show(res.message || `${activeEnvelope} submitted successfully`, ToastAndroid.SHORT);
|
||||
} else {
|
||||
logger.warn('envelope', `[submit] ${activeEnvelope} submission rejected by server`, {
|
||||
statusCode: res?.statusCode,
|
||||
@@ -272,6 +288,18 @@ const TenderBidding = () => {
|
||||
const activeEnvelopeList = tenderData?.[`tenderEnvelope${activeEnvelope}List`] || [];
|
||||
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 (
|
||||
<View style={[styles.root, { backgroundColor: colors.background }]}>
|
||||
<InsideHeader title="Tender Bidding" showArrow />
|
||||
@@ -348,6 +376,7 @@ const TenderBidding = () => {
|
||||
key={tab.type}
|
||||
title={tab.title}
|
||||
isActive={activeEnvelope === tab.type}
|
||||
isSubmitted={isEnvelopeSubmitted(tab.type)}
|
||||
onPress={() => setActiveEnvelope(tab.type)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,33 +1,77 @@
|
||||
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 AppText from '../../../../components/AppText';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
isActive: boolean;
|
||||
isSubmitted?: boolean;
|
||||
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 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 (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.button,
|
||||
{ borderColor: colors.border, backgroundColor: colors.surface },
|
||||
isActive && { backgroundColor: colors.primary, borderColor: colors.primary },
|
||||
]}
|
||||
activeOpacity={0.7}
|
||||
style={[styles.button, { borderColor, backgroundColor }]}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<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}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -36,12 +80,31 @@ const styles = StyleSheet.create({
|
||||
button: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 20,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1.5,
|
||||
minWidth: 100,
|
||||
alignItems: 'center',
|
||||
minWidth: 130,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
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);
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
export const useLogin = () => {
|
||||
@@ -46,12 +57,7 @@ export const useFetchPurchaseTender = () => {
|
||||
return nextPage < totalPages ? nextPage : undefined;
|
||||
},
|
||||
|
||||
select: (data) => ({
|
||||
...data,
|
||||
result: data.pages.flatMap(
|
||||
(page: any) => page?.data ?? []
|
||||
),
|
||||
}),
|
||||
select: selectFlattenedResult,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -73,12 +79,7 @@ export const useFetchLiveTender = () => {
|
||||
return nextPage < totalPages ? nextPage : undefined;
|
||||
},
|
||||
|
||||
select: (data) => ({
|
||||
...data,
|
||||
result: data.pages.flatMap(
|
||||
(page: any) => page?.data ?? []
|
||||
),
|
||||
}),
|
||||
select: selectFlattenedResult,
|
||||
})
|
||||
};
|
||||
export const useContractorProfile = () => {
|
||||
@@ -109,21 +110,12 @@ export const useFetchLiveAuctionPage = (payload: any) => {
|
||||
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
const { size, totalCount } = lastPage;
|
||||
const totalPages = Math.ceil(totalCount / size);
|
||||
const totalPages = Math.ceil(totalCount / (size || 10));
|
||||
const nextPage = allPages.length;
|
||||
return nextPage < totalPages ? nextPage : undefined;
|
||||
},
|
||||
|
||||
select: (data) => {
|
||||
const mergedResults = data.pages.flatMap(
|
||||
(page: any) => page?.data ?? []
|
||||
);
|
||||
|
||||
return {
|
||||
...data,
|
||||
result: mergedResults,
|
||||
};
|
||||
},
|
||||
select: selectFlattenedResult,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -139,21 +131,12 @@ export const useFetchLiveAuction = () => {
|
||||
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
const { size, totalCount } = lastPage;
|
||||
const totalPages = Math.ceil(totalCount / size);
|
||||
const totalPages = Math.ceil(totalCount / (size || 10));
|
||||
const nextPage = allPages.length;
|
||||
return nextPage < totalPages ? nextPage : undefined;
|
||||
},
|
||||
|
||||
select: (data) => {
|
||||
const mergedResults = data.pages.flatMap(
|
||||
(page: any) => page?.data ?? []
|
||||
);
|
||||
|
||||
return {
|
||||
...data,
|
||||
result: mergedResults,
|
||||
};
|
||||
},
|
||||
select: selectFlattenedResult,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Rendered above the body list inside the horizontal scroller so it pans with
|
||||
* the columns but never scrolls vertically. Tapping a letter selects the column.
|
||||
*/
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { SheetModel } from '../types';
|
||||
import { GridTheme } from '../hooks/useGridTheme';
|
||||
@@ -21,7 +21,7 @@ export interface ColumnHeaderRowProps {
|
||||
onSelectColumn: (col: number) => void;
|
||||
}
|
||||
|
||||
export const ColumnHeaderRow: React.FC<ColumnHeaderRowProps> = ({
|
||||
const ColumnHeaderRowBase: React.FC<ColumnHeaderRowProps> = ({
|
||||
sheet, cols, width, gridTheme, activeCol, selStartCol, selEndCol, onSelectColumn,
|
||||
}) => (
|
||||
<View style={[styles.row, { width, height: HEADER_HEIGHT, backgroundColor: gridTheme.headerBg }]}>
|
||||
@@ -50,6 +50,13 @@ export const ColumnHeaderRow: React.FC<ColumnHeaderRowProps> = ({
|
||||
</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({
|
||||
row: { flexDirection: 'row' },
|
||||
cell: {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* DataRow — renders one worksheet row's visible cells, honouring merged spans.
|
||||
* Derives each cell's primitive props and defers to the memoized `CellView`.
|
||||
*/
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Address, CellModel, CellRange, SheetModel, ValidationError, cellKey } from '../types';
|
||||
import { GridTheme } from '../hooks/useGridTheme';
|
||||
@@ -45,7 +45,12 @@ export const DataRow: React.FC<DataRowProps> = ({
|
||||
getDisplayValue, getCellModel, getCellError, getUserContent, canEdit,
|
||||
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;
|
||||
|
||||
return (
|
||||
|
||||
@@ -50,7 +50,7 @@ export const useGridTheme = (): GridTheme => {
|
||||
editorBg: colors.surface,
|
||||
editorBorder: colors.primary,
|
||||
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,
|
||||
surface: colors.surface,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Animated, LayoutChangeEvent, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import Svg, { Circle, Path } from 'react-native-svg';
|
||||
import Reanimated, {
|
||||
Easing,
|
||||
cancelAnimation,
|
||||
useAnimatedProps,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import colors from '../Constant/Color';
|
||||
import { appEvents } from '../core/events/appEvents';
|
||||
import { useTheme } from '../Hooks/useTheme';
|
||||
|
||||
const AnimatedPath = Reanimated.createAnimatedComponent(Path);
|
||||
|
||||
/** Corner radius of the card's rounded (left) side; shared by card + border. */
|
||||
const CARD_RADIUS = 30;
|
||||
|
||||
export type AppToastType = 'success' | 'error' | 'info';
|
||||
|
||||
export interface AppToastOptions {
|
||||
message: string;
|
||||
title?: string;
|
||||
type?: AppToastType;
|
||||
/** Auto-dismiss delay in ms */
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
type ToastHandler = (options: AppToastOptions) => void;
|
||||
|
||||
let activeHandler: ToastHandler | null = null;
|
||||
|
||||
/**
|
||||
* Imperative entry point usable from anywhere (screens, sagas, axios
|
||||
* interceptors). Requires <AppToastHost /> to be mounted once at the root.
|
||||
*/
|
||||
export const showAppToast = (options: AppToastOptions) => {
|
||||
if (activeHandler) {
|
||||
activeHandler(options);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Info accent — the theme palette has no `info` token, so this stays a fixed
|
||||
* blue that reads on both light and dark surfaces. The success/error accents
|
||||
* are derived from the active theme inside the host.
|
||||
*/
|
||||
const INFO_ACCENT = '#3B82F6';
|
||||
|
||||
const DEFAULT_TITLES: Record<AppToastType, string> = {
|
||||
success: 'Success!',
|
||||
error: 'Error!',
|
||||
info: 'Info',
|
||||
};
|
||||
|
||||
const SLIDE_DISTANCE = 400;
|
||||
const DEFAULT_DURATION = 3200;
|
||||
|
||||
const ToastIcon = ({ type, accent }: { type: AppToastType; accent: string }) => (
|
||||
<View style={[styles.iconCircle, { backgroundColor: accent }]}>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none">
|
||||
{type === 'success' && (
|
||||
<Path
|
||||
d="M20 6L9 17l-5-5"
|
||||
stroke={colors.white}
|
||||
strokeWidth={3.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
{type === 'error' && (
|
||||
<Path
|
||||
d="M7 7l10 10M17 7L7 17"
|
||||
stroke={colors.white}
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
{type === 'info' && (
|
||||
<>
|
||||
<Circle cx={12} cy={6} r={1.8} fill={colors.white} />
|
||||
<Path
|
||||
d="M12 10.5V19"
|
||||
stroke={colors.white}
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Svg>
|
||||
</View>
|
||||
);
|
||||
|
||||
/**
|
||||
* A single bright "beam" of light that flows continuously around the card's
|
||||
* border. Implemented as an SVG rounded-rect stroke (left corners rounded,
|
||||
* right edge square, matching the card) whose dash offset is animated on the UI
|
||||
* thread — the beam is one dash the length of ~30% of the perimeter, travelling
|
||||
* the full perimeter each loop for a seamless flow. A dim static stroke keeps
|
||||
* the whole border faintly visible between passes.
|
||||
*/
|
||||
const FlowingBorder = ({
|
||||
width,
|
||||
height,
|
||||
color,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
}) => {
|
||||
const inset = 5;
|
||||
const sw = 2.5;
|
||||
const r = Math.max(0, CARD_RADIUS - inset);
|
||||
const x0 = inset;
|
||||
const y0 = inset;
|
||||
const x1 = width - inset;
|
||||
const y1 = height - inset;
|
||||
const w = x1 - x0;
|
||||
const h = y1 - y0;
|
||||
// Perimeter of a rect with two rounded (left) and two square (right) corners.
|
||||
const perimeter = 2 * (w + h) - 4 * r + Math.PI * r;
|
||||
const beam = perimeter * 0.3;
|
||||
const d =
|
||||
`M ${x0 + r} ${y0} L ${x1} ${y0} L ${x1} ${y1} L ${x0 + r} ${y1} ` +
|
||||
`Q ${x0} ${y1} ${x0} ${y1 - r} L ${x0} ${y0 + r} Q ${x0} ${y0} ${x0 + r} ${y0} Z`;
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
useEffect(() => {
|
||||
progress.value = withRepeat(withTiming(1, { duration: 2200, easing: Easing.linear }), -1, false);
|
||||
return () => cancelAnimation(progress);
|
||||
}, [progress]);
|
||||
|
||||
const animatedProps = useAnimatedProps(() => ({
|
||||
strokeDashoffset: -perimeter * progress.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Svg width={width} height={height} style={styles.svgOverlay} pointerEvents="none">
|
||||
<Path d={d} stroke={`${color}30`} strokeWidth={sw} fill="none" />
|
||||
{/* Wide translucent halo trailing the beam. */}
|
||||
<AnimatedPath
|
||||
d={d}
|
||||
stroke={color}
|
||||
strokeOpacity={0.35}
|
||||
strokeWidth={7}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
strokeDasharray={[beam, perimeter - beam]}
|
||||
animatedProps={animatedProps}
|
||||
/>
|
||||
{/* Crisp bright core of the beam. */}
|
||||
<AnimatedPath
|
||||
d={d}
|
||||
stroke={color}
|
||||
strokeWidth={sw}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
strokeDasharray={[beam, perimeter - beam]}
|
||||
animatedProps={animatedProps}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the floating toast card. Mount exactly once at the app root,
|
||||
* above navigation, so it overlays every screen from the top-right corner.
|
||||
*/
|
||||
export const AppToastHost = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
// Re-renders on theme change (useTheme reads the Redux themeMode + system
|
||||
// scheme), so an on-screen toast re-themes live and new toasts pick up the
|
||||
// active mode instead of the old hardcoded dark card.
|
||||
const { colors: theme } = useTheme();
|
||||
const [toast, setToast] = useState<Required<AppToastOptions> | null>(null);
|
||||
const translateX = useRef(new Animated.Value(SLIDE_DISTANCE)).current;
|
||||
const opacity = useRef(new Animated.Value(0)).current;
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Collapses duplicate toasts fired for the same event (e.g. a screen's
|
||||
// explicit toast + the global API-result toast carrying the same message).
|
||||
const lastShown = useRef<{ key: string; at: number } | null>(null);
|
||||
// Card size, measured on layout, so the flowing border path matches exactly.
|
||||
const [cardSize, setCardSize] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
const clearHideTimer = () => {
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
clearHideTimer();
|
||||
Animated.parallel([
|
||||
Animated.timing(translateX, {
|
||||
toValue: SLIDE_DISTANCE,
|
||||
duration: 220,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacity, {
|
||||
toValue: 0,
|
||||
duration: 220,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(({ finished }) => {
|
||||
if (finished) {
|
||||
setToast(null);
|
||||
}
|
||||
});
|
||||
}, [opacity, translateX]);
|
||||
|
||||
const show = useCallback(
|
||||
(options: AppToastOptions) => {
|
||||
const type = options.type ?? 'success';
|
||||
const key = `${type}:${options.message}`;
|
||||
const now = Date.now();
|
||||
if (lastShown.current && lastShown.current.key === key && now - lastShown.current.at < 2500) {
|
||||
return; // identical toast just shown — skip the duplicate
|
||||
}
|
||||
lastShown.current = { key, at: now };
|
||||
clearHideTimer();
|
||||
setToast({
|
||||
message: options.message,
|
||||
title: options.title ?? DEFAULT_TITLES[type],
|
||||
type,
|
||||
duration: options.duration ?? DEFAULT_DURATION,
|
||||
});
|
||||
translateX.setValue(SLIDE_DISTANCE);
|
||||
opacity.setValue(0);
|
||||
Animated.parallel([
|
||||
Animated.spring(translateX, {
|
||||
toValue: 0,
|
||||
friction: 9,
|
||||
tension: 70,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacity, {
|
||||
toValue: 1,
|
||||
duration: 180,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
hideTimer.current = setTimeout(dismiss, options.duration ?? DEFAULT_DURATION);
|
||||
},
|
||||
[dismiss, opacity, translateX],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
activeHandler = show;
|
||||
return () => {
|
||||
if (activeHandler === show) {
|
||||
activeHandler = null;
|
||||
}
|
||||
clearHideTimer();
|
||||
};
|
||||
}, [show]);
|
||||
|
||||
if (!toast) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const accents: Record<AppToastType, string> = {
|
||||
success: theme.primary,
|
||||
error: theme.error,
|
||||
info: INFO_ACCENT,
|
||||
};
|
||||
const accent = accents[toast.type];
|
||||
const onCardLayout = (e: LayoutChangeEvent) => {
|
||||
const { width, height } = e.nativeEvent.layout;
|
||||
setCardSize(prev =>
|
||||
prev && prev.width === width && prev.height === height ? prev : { width, height },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
style={[styles.overlay, { top: insets.top + 8 }]}
|
||||
>
|
||||
<Animated.View style={{ transform: [{ translateX }], opacity }}>
|
||||
<View style={styles.cardWrap}>
|
||||
<Pressable
|
||||
onPress={dismiss}
|
||||
onLayout={onCardLayout}
|
||||
style={[styles.card, { backgroundColor: theme.surface, shadowColor: theme.shadow }]}
|
||||
>
|
||||
<ToastIcon type={toast.type} accent={accent} />
|
||||
<View style={styles.textWrap}>
|
||||
<Text style={[styles.title, { color: theme.text }]}>{toast.title}</Text>
|
||||
<Text style={[styles.message, { color: theme.textSecondary }]} numberOfLines={2}>
|
||||
{toast.message}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
{cardSize && (
|
||||
<FlowingBorder width={cardSize.width} height={cardSize.height} color={accent} />
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Bridges HTTP-layer `api/success` / `api/error` events to the toast, so every
|
||||
* API result surfaces the new toast app-wide without per-screen wiring. Mount
|
||||
* once at the root, alongside <AppToastHost />.
|
||||
*/
|
||||
export const ApiToastBridge = () => {
|
||||
useEffect(() => {
|
||||
const offSuccess = appEvents.on('api/success', ({ message }) =>
|
||||
showAppToast({ type: 'success', message }),
|
||||
);
|
||||
const offError = appEvents.on('api/error', ({ message }) =>
|
||||
showAppToast({ type: 'error', message }),
|
||||
);
|
||||
return () => {
|
||||
offSuccess();
|
||||
offError();
|
||||
};
|
||||
}, []);
|
||||
return null;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
left: 12,
|
||||
alignItems: 'flex-end',
|
||||
zIndex: 9999,
|
||||
},
|
||||
cardWrap: {
|
||||
position: 'relative',
|
||||
},
|
||||
svgOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
},
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
maxWidth: 360,
|
||||
// backgroundColor + shadowColor are applied inline from the active theme.
|
||||
// Flush against the screen's right edge: round the left corners only.
|
||||
borderTopLeftRadius: CARD_RADIUS,
|
||||
borderBottomLeftRadius: CARD_RADIUS,
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
paddingVertical: 12,
|
||||
paddingLeft: 12,
|
||||
paddingRight: 20,
|
||||
shadowOpacity: 0.32,
|
||||
shadowRadius: 11,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
elevation: 9,
|
||||
},
|
||||
iconCircle: {
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 19,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
textWrap: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
title: {
|
||||
// color applied inline from the active theme.
|
||||
fontSize: 15.5,
|
||||
fontWeight: '700',
|
||||
},
|
||||
message: {
|
||||
// color applied inline from the active theme.
|
||||
fontSize: 13,
|
||||
marginTop: 2,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { Inbox } from 'lucide-react-native';
|
||||
import AppText from './AppText';
|
||||
import { useTheme } from '../Hooks/useTheme';
|
||||
|
||||
interface EmptyStateProps {
|
||||
/** Primary line, e.g. "No live tenders found". */
|
||||
title: string;
|
||||
/** Optional supporting line under the title. */
|
||||
subtitle?: string;
|
||||
/** Override the default icon (sized/coloured by the caller). */
|
||||
icon?: React.ReactNode;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered, theme-aware empty placeholder for lists. Designed to be dropped
|
||||
* into a FlatList's `ListEmptyComponent`; pair it with `flexGrow: 1` on the
|
||||
* list's `contentContainerStyle` so it centers in the available space and the
|
||||
* pull-to-refresh gesture still works while empty.
|
||||
*/
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({ title, subtitle, icon, style }) => {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<View style={[styles.iconWrap, { backgroundColor: colors.overlay }]}>
|
||||
{icon ?? <Inbox size={34} color={colors.primary} strokeWidth={1.8} />}
|
||||
</View>
|
||||
<AppText variant="h4" centered color={colors.text} style={styles.title}>
|
||||
{title}
|
||||
</AppText>
|
||||
{!!subtitle && (
|
||||
<AppText variant="bodySmall" centered color={colors.textSecondary} style={styles.subtitle}>
|
||||
{subtitle}
|
||||
</AppText>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 32,
|
||||
paddingVertical: 48,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 88,
|
||||
height: 88,
|
||||
borderRadius: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 18,
|
||||
},
|
||||
title: {
|
||||
marginBottom: 6,
|
||||
},
|
||||
subtitle: {
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
|
||||
export default EmptyState;
|
||||
+194
-51
@@ -1,14 +1,16 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Image, Dimensions } from 'react-native';
|
||||
import Modal from 'react-native-modal';
|
||||
import { useTheme } from '../Hooks/useTheme';
|
||||
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 { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { performLogout } from '../Services/Auth/session';
|
||||
import NavigationString from '../Constant/NavigationString';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getToken } from '../Redux/Slices/Token';
|
||||
|
||||
interface SidebarModalProps {
|
||||
isVisible: boolean;
|
||||
@@ -17,16 +19,54 @@ interface SidebarModalProps {
|
||||
|
||||
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 { colors, themeMode, toggleTheme } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
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 = () => {
|
||||
performLogout();
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [{ name: NavigationString.Login }],
|
||||
dismissThen(() => {
|
||||
performLogout();
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [{ name: NavigationString.Login }],
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -48,12 +88,19 @@ const SidebarModal: React.FC<SidebarModalProps> = ({ isVisible, onClose }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderMenuItem = (label: string, Icon: any, onPress: () => void) => (
|
||||
<TouchableOpacity style={styles.menuItem} onPress={onPress}>
|
||||
<Icon size={22} color={colors.text} />
|
||||
<AppText variant="bodySmall" size={16} style={{ marginLeft: 16, color: colors.text }}>
|
||||
const renderMenuCard = (label: string, Icon: any, onPress: () => void) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.menuCard, { backgroundColor: colors.surface, borderColor: colors.border }]}
|
||||
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}
|
||||
</AppText>
|
||||
<ChevronRight size={18} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@@ -66,39 +113,64 @@ const SidebarModal: React.FC<SidebarModalProps> = ({ isVisible, onClose }) => {
|
||||
animationOut="slideOutLeft"
|
||||
swipeDirection="left"
|
||||
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
|
||||
style={styles.modal}
|
||||
>
|
||||
<View style={[styles.container, { backgroundColor: colors.background, paddingTop: insets.top }]}>
|
||||
|
||||
{/* Header */}
|
||||
<View style={[styles.header, { borderBottomColor: colors.border }]}>
|
||||
<View style={styles.profileSection}>
|
||||
<Image source={ImagePath.Icon.Logo} style={styles.logo} resizeMode="contain" />
|
||||
<View>
|
||||
<AppText variant="h3" size={18} style={{ color: colors.text }}>ProcureLinc</AppText>
|
||||
<AppText variant="caption" size={12} style={{ color: colors.textSecondary }}>v1.0.0</AppText>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
{/* Top bar — close only */}
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
style={styles.closeButton}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<X size={24} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Menu Items */}
|
||||
<View style={styles.content}>
|
||||
{renderMenuItem('Profile', User, () => {
|
||||
onClose();
|
||||
navigation.navigate(NavigationString.Profile);
|
||||
})}
|
||||
{renderMenuItem('Terms & Conditions', FileText, () => {
|
||||
{/* Profile hero */}
|
||||
<View style={styles.hero}>
|
||||
<View style={[styles.heroAvatar, { backgroundColor: colors.primary }]}>
|
||||
<AppText semiBold color="#FFF" style={styles.heroInitial}>
|
||||
{initial}
|
||||
</AppText>
|
||||
</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();
|
||||
// Navigate to Terms
|
||||
})}
|
||||
</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 }]}>
|
||||
<AppText variant="h4" style={{ marginBottom: 16 }}>Appearance</AppText>
|
||||
<View style={styles.themeRow}>
|
||||
@@ -114,6 +186,14 @@ const SidebarModal: React.FC<SidebarModalProps> = ({ isVisible, onClose }) => {
|
||||
<LogOut size={20} color={colors.error} />
|
||||
<AppText variant="button" style={{ marginLeft: 10, color: colors.error }}>Log Out</AppText>
|
||||
</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>
|
||||
@@ -135,39 +215,90 @@ const styles = StyleSheet.create({
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
header: {
|
||||
|
||||
/* Top bar (close button only) */
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
profileSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
logo: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
marginRight: 12,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'flex-end',
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 8,
|
||||
},
|
||||
closeButton: {
|
||||
padding: 8,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
|
||||
/* Profile hero */
|
||||
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',
|
||||
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: {
|
||||
padding: 20,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
borderTopWidth: 1,
|
||||
paddingBottom: 40,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
themeRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -188,6 +319,18 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 12,
|
||||
marginTop: 10,
|
||||
},
|
||||
brandFooter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
marginTop: 20,
|
||||
},
|
||||
brandLogo: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
},
|
||||
});
|
||||
|
||||
export default SidebarModal;
|
||||
|
||||
@@ -2,18 +2,20 @@ import { View, Dimensions } from 'react-native';
|
||||
import React from 'react';
|
||||
import SkeletonPlaceholder from 'react-native-skeleton-placeholder';
|
||||
|
||||
const SkeletonList = () => {
|
||||
const { width, height } = Dimensions.get('screen');
|
||||
const { width, height } = Dimensions.get('screen');
|
||||
|
||||
return (
|
||||
<View style={{ width: width - 30, height }}>
|
||||
{[...Array(10)].map((_, index) => (
|
||||
<SkeletonPlaceholder key={index} borderRadius={5}>
|
||||
<SkeletonPlaceholder.Item width={"100%"} height={50} marginBottom={12} />
|
||||
</SkeletonPlaceholder>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
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 }}>
|
||||
<SkeletonPlaceholder borderRadius={5}>
|
||||
<View>
|
||||
{[...Array(10)].map((_, index) => (
|
||||
<SkeletonPlaceholder.Item key={index} width={"100%"} height={50} marginBottom={12} />
|
||||
))}
|
||||
</View>
|
||||
</SkeletonPlaceholder>
|
||||
</View>
|
||||
);
|
||||
|
||||
export default SkeletonList;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import InsideHeader from './InsideHeader';
|
||||
import Loader from './Loader';
|
||||
import Skeleton from './Skeleton';
|
||||
import EmptyState from './EmptyState';
|
||||
|
||||
export { Skeleton, Loader, InsideHeader };
|
||||
export { Skeleton, Loader, InsideHeader, EmptyState };
|
||||
|
||||
@@ -11,6 +11,10 @@ type AppEventMap = {
|
||||
'auth/unauthorized': void;
|
||||
/** Emitted when the user explicitly logs out. */
|
||||
'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;
|
||||
|
||||
+42
-2
@@ -47,11 +47,51 @@ httpClient.interceptors.request.use(config => {
|
||||
return config;
|
||||
});
|
||||
|
||||
const MUTATION_METHODS = new Set(['post', 'put', 'patch', 'delete']);
|
||||
|
||||
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 => {
|
||||
if (error?.response?.status === 401) {
|
||||
const status = error?.response?.status;
|
||||
if (status === 401) {
|
||||
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(
|
||||
axios.isAxiosError(error)
|
||||
|
||||
@@ -8,11 +8,19 @@ import type { AxiosInstance } from 'axios';
|
||||
import { logger } from './logger';
|
||||
import { redactUrl } from './redact';
|
||||
|
||||
// Build the full URL exactly as it goes on the wire: baseURL + url + ?query
|
||||
// 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 base = config.baseURL || '';
|
||||
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;
|
||||
if (params && typeof params === 'object') {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Screenshot / screen-recording guard — the single source of truth for which
|
||||
* screens block capture.
|
||||
*
|
||||
* The app is single-Activity, so Android's window-level FLAG_SECURE is flipped
|
||||
* on as the user enters a protected route and off as they leave. The controller
|
||||
* is driven centrally from the NavigationContainer's `onStateChange`/`onReady`
|
||||
* in App.tsx — no per-screen wiring required. To change what is protected, edit
|
||||
* SECURE_SCREENS below; to kill the whole feature, flip SCREEN_GUARD_ENABLED.
|
||||
*
|
||||
* Android only. iOS has no equivalent window flag, so the native module is
|
||||
* absent there and every call no-ops (see useSecureScreen for the iOS story).
|
||||
*
|
||||
* Fail-closed: release builds start secured in MainActivity, so sensitive
|
||||
* content is never exposed before JS boots or if this bridge fails to load. The
|
||||
* controller then clears the flag for the non-protected screens.
|
||||
*/
|
||||
import { NativeModules, Platform } from 'react-native';
|
||||
import { logger } from '../observability/logger';
|
||||
import { navigationRef } from '../../Navigation/NavigationService';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
|
||||
type ScreenGuardNative = { setSecure(enable: boolean): void };
|
||||
|
||||
const native: ScreenGuardNative | undefined = NativeModules.ScreenGuard;
|
||||
|
||||
/** Master switch. Set to false to disable screenshot protection app-wide. */
|
||||
export const SCREEN_GUARD_ENABLED = false; // TODO: flip to true for production builds
|
||||
|
||||
/**
|
||||
* Routes that must block screenshots / screen recording. Values are the route
|
||||
* `name`s registered in MainStack (via NavigationString). Add or remove a line
|
||||
* to change a screen's protection — this set is the only place to manage it.
|
||||
*/
|
||||
export const SECURE_SCREENS: ReadonlySet<string> = new Set<string>([
|
||||
NavigationString.Emd, // EMD payment details
|
||||
NavigationString.StartBidding, // sealed bid amount
|
||||
NavigationString.StartReverseBidding, // sealed reverse-bid amount
|
||||
NavigationString.TenderBidding, // sealed bid amount
|
||||
NavigationString.ProceedToPurTender, // bid amounts
|
||||
NavigationString.ProceedToBidDetail, // bid amounts
|
||||
NavigationString.LiveTenderDetail, // live bid data
|
||||
NavigationString.EnvelopeCSpreadsheet, // sealed envelope amounts
|
||||
NavigationString.KycWebViewScreen, // Aadhaar / KYC
|
||||
NavigationString.StageThreeScreen, // KYC OTP
|
||||
NavigationString.DeviceBinding, // device-binding secrets
|
||||
NavigationString.DscBinding, // DSC binding
|
||||
// NavigationString.Login, // uncomment to also protect login
|
||||
// NavigationString.ResetPassword, // uncomment to also protect reset-password
|
||||
]);
|
||||
|
||||
/** Android + native module present. iOS and misconfigured builds no-op. */
|
||||
const canGuard = Platform.OS === 'android' && !!native;
|
||||
|
||||
/** Last flag we pushed to native, to skip redundant UI-thread hops. */
|
||||
let lastApplied: boolean | undefined;
|
||||
|
||||
let warnedMissing = false;
|
||||
const warnUnavailableOnce = (): void => {
|
||||
if (warnedMissing) {return;}
|
||||
warnedMissing = true;
|
||||
// Only meaningful on Android; on iOS the absence is expected.
|
||||
if (Platform.OS === 'android') {
|
||||
logger.warn('security', 'ScreenGuard native module unavailable; screenshot guard inactive');
|
||||
}
|
||||
};
|
||||
|
||||
/** Directly set FLAG_SECURE. Prefer applyGuardForRoute for navigation-driven use. */
|
||||
export const setSecure = (enable: boolean): void => {
|
||||
const target = SCREEN_GUARD_ENABLED ? enable : false;
|
||||
if (!canGuard) {
|
||||
warnUnavailableOnce();
|
||||
return;
|
||||
}
|
||||
if (target === lastApplied) {return;}
|
||||
lastApplied = target;
|
||||
try {
|
||||
native!.setSecure(target);
|
||||
logger.debug('security', `FLAG_SECURE ${target ? 'set' : 'cleared'}`);
|
||||
} catch (error) {
|
||||
logger.error('security', 'failed to toggle FLAG_SECURE', { error: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
/** Whether a given route name should block capture. */
|
||||
export const isScreenProtected = (routeName?: string): boolean =>
|
||||
SCREEN_GUARD_ENABLED && !!routeName && SECURE_SCREENS.has(routeName);
|
||||
|
||||
/** Apply the guard for an explicit route name. */
|
||||
export const applyGuardForRoute = (routeName?: string): void => {
|
||||
setSecure(isScreenProtected(routeName));
|
||||
};
|
||||
|
||||
/** Read the active route from the navigation ref and apply the guard. */
|
||||
export const applyGuardForCurrentRoute = (): void => {
|
||||
const routeName = navigationRef.isReady()
|
||||
? navigationRef.getCurrentRoute()?.name
|
||||
: undefined;
|
||||
applyGuardForRoute(routeName);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* useSecureScreen — protect the CURRENT screen while it is focused.
|
||||
*
|
||||
* The central navigation controller (screenGuard + App.tsx) already covers every
|
||||
* route listed in SECURE_SCREENS. Reach for this hook only when a screen's
|
||||
* sensitivity is conditional at runtime — e.g. protect a page ONLY while a
|
||||
* sealed amount is revealed:
|
||||
*
|
||||
* useSecureScreen(isAmountRevealed);
|
||||
*
|
||||
* On blur it clears the flag; the navigation controller's onStateChange then
|
||||
* re-applies the correct state for the next route.
|
||||
*/
|
||||
import { useCallback } from 'react';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { setSecure } from './screenGuard';
|
||||
|
||||
export const useSecureScreen = (active: boolean = true): void => {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setSecure(active);
|
||||
return () => setSecure(false);
|
||||
}, [active]),
|
||||
);
|
||||
};
|
||||
@@ -212,7 +212,6 @@ export const useWebSocketService = (
|
||||
url: `${env.socketBaseUrl}livebidding/placeBid`,
|
||||
data: BidDTO,
|
||||
});
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
return { data: res.data, status: true, message: res.message };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user