diff --git a/App.tsx b/App.tsx index eb14d7d..45aa408 100644 --- a/App.tsx +++ b/App.tsx @@ -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 ( - + - - - - - - - - - - - + + + + + + + + + + + diff --git a/android/app/src/main/java/com/procure/MainActivity.kt b/android/app/src/main/java/com/procure/MainActivity.kt index 09b6bef..ee64dee 100644 --- a/android/app/src/main/java/com/procure/MainActivity.kt +++ b/android/app/src/main/java/com/procure/MainActivity.kt @@ -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, diff --git a/android/app/src/main/java/com/procure/MainApplication.kt b/android/app/src/main/java/com/procure/MainApplication.kt index 0909423..7ea624a 100644 --- a/android/app/src/main/java/com/procure/MainApplication.kt +++ b/android/app/src/main/java/com/procure/MainApplication.kt @@ -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 = PackageList(this).packages.apply { add(ExcelPackage()) + add(ScreenGuardPackage()) } override fun getJSMainModuleName(): String = "index" diff --git a/android/app/src/main/java/com/procure/excel/ExcelModule.kt b/android/app/src/main/java/com/procure/excel/ExcelModule.kt index 266e3be..2bc1178 100644 --- a/android/app/src/main/java/com/procure/excel/ExcelModule.kt +++ b/android/app/src/main/java/com/procure/excel/ExcelModule.kt @@ -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 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 diff --git a/android/app/src/main/java/com/procure/security/ScreenGuardModule.kt b/android/app/src/main/java/com/procure/security/ScreenGuardModule.kt new file mode 100644 index 0000000..18a67c7 --- /dev/null +++ b/android/app/src/main/java/com/procure/security/ScreenGuardModule.kt @@ -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) + } + } + } +} diff --git a/android/app/src/main/java/com/procure/security/ScreenGuardPackage.kt b/android/app/src/main/java/com/procure/security/ScreenGuardPackage.kt new file mode 100644 index 0000000..3544b0f --- /dev/null +++ b/android/app/src/main/java/com/procure/security/ScreenGuardPackage.kt @@ -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 = + listOf(ScreenGuardModule(reactContext)) + + override fun createViewManagers(reactContext: ReactApplicationContext): List> = + emptyList() +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 93bdc75..03318a1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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). --- diff --git a/package-lock.json b/package-lock.json index a92b848..73bd726 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b12ceef..10c87a6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/Constant/ImagePath.jsx b/src/Constant/ImagePath.jsx index 3d5ab10..9c7b684 100644 --- a/src/Constant/ImagePath.jsx +++ b/src/Constant/ImagePath.jsx @@ -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; \ No newline at end of file diff --git a/src/Constant/ShowDailog.tsx b/src/Constant/ShowDailog.tsx index 9d26934..f78d191 100644 --- a/src/Constant/ShowDailog.tsx +++ b/src/Constant/ShowDailog.tsx @@ -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 } \ No newline at end of file +const showToast = showSuccessAlert; + +export { showErrorAlert, showSuccessAlert, showToast }; diff --git a/src/Helper/showToast.tsx b/src/Helper/showToast.tsx deleted file mode 100644 index 0d8a23a..0000000 --- a/src/Helper/showToast.tsx +++ /dev/null @@ -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 - } -}; \ No newline at end of file diff --git a/src/Images/Backgrounds/homeBackground.png b/src/Images/Backgrounds/homeBackground.png new file mode 100644 index 0000000..9dc66f9 Binary files /dev/null and b/src/Images/Backgrounds/homeBackground.png differ diff --git a/src/Navigation/Bottom.tsx b/src/Navigation/Bottom.tsx index 436b154..23ca4e6 100644 --- a/src/Navigation/Bottom.tsx +++ b/src/Navigation/Bottom.tsx @@ -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 = { + Home: 'House', + Auction: 'Gavel', + Tender: 'FileText', + Profile: 'User', +}; + const TabButton = ({ route, isFocused, onPress }: any) => { const { colors } = useTheme(); - const icons: Record = { - 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 ( - - - {IconName && ( - - )} - - - {isFocused && ( - + {IconName && } + - {route.name === 'BrandDay' ? 'Coupons' : route.name} - - )} - + {route.name} + + + ) : ( + // Inactive: muted icon only. + IconName && ( + + ) + )} ); }; 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 ( - - - {state.routes.map((route: any, index: number) => { - const isFocused = state.index === index; - - return ( - navigation.navigate(route.name)} - /> - ); - })} + + {state.routes.map((route: any, index: number) => ( + navigation.navigate(route.name)} + /> + ))} ); }; @@ -202,9 +97,7 @@ export default function BottomTab() { - {/* */} - ); @@ -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, }, }); diff --git a/src/Screens/Auction/Auction.tsx b/src/Screens/Auction/Auction.tsx index a4110e7..41f8892 100644 --- a/src/Screens/Auction/Auction.tsx +++ b/src/Screens/Auction/Auction.tsx @@ -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], + ); diff --git a/src/Screens/Auction/LiveAuction.tsx b/src/Screens/Auction/LiveAuction.tsx index cd2bbf1..d80c2b2 100644 --- a/src/Screens/Auction/LiveAuction.tsx +++ b/src/Screens/Auction/LiveAuction.tsx @@ -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 ; } @@ -153,6 +140,12 @@ const LiveAuction = () => { onEndReachedCalledDuringMomentum.current = false; }} ListFooterComponent={renderFooter} + ListEmptyComponent={ + + } refreshControl={ void; data?: any } @@ -36,8 +38,11 @@ const AuctionFullDetail: React.FC = ({ 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(null); const { data: auctionDocuments } = useGetAucitonDocument({ auctionId: data?.auctionId }); const { mutate } = useAuctionItem(); @@ -46,13 +51,10 @@ const AuctionFullDetail: React.FC = ({ 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 = ({ 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 = ({ visible, onClose, data }) => { - - - - {isLive ? 'LIVE' : 'CLOSED'} - - {/* ── Body ── */} @@ -204,7 +202,10 @@ const AuctionFullDetail: React.FC = ({ 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}`} > @@ -212,7 +213,7 @@ const AuctionFullDetail: React.FC = ({ visible, onClose, data }) => { {doc.fileName} - {allBoolean.fileLoading + {downloadingId === doc.auctionDocumentId ? : } diff --git a/src/Screens/Auction/Modal/AuctionItemDetail.tsx b/src/Screens/Auction/Modal/AuctionItemDetail.tsx index 435c1f4..5839b07 100644 --- a/src/Screens/Auction/Modal/AuctionItemDetail.tsx +++ b/src/Screens/Auction/Modal/AuctionItemDetail.tsx @@ -141,7 +141,7 @@ const AuctionItemDetail: React.FC = ({ visible, onClose, data }) => { {data?.briefDescription} - {itemData?.length &&
+ {itemData?.length > 0 &&
Sr No. Document Name diff --git a/src/Screens/Auction/PurchaseAuction.tsx b/src/Screens/Auction/PurchaseAuction.tsx index fbf9d78..ad3cf8a 100644 --- a/src/Screens/Auction/PurchaseAuction.tsx +++ b/src/Screens/Auction/PurchaseAuction.tsx @@ -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 ? : ( { /> )} 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={ + + } refreshControl={ { } {allBoolean.isShowFilter && { setAllBoolean((prev: any) => ({ ...prev, isShowFilter: false })) }} setFilter={setFilter} filter={filter} /> - }. + } ); diff --git a/src/Screens/Auction/PurchaseAuctionList.tsx b/src/Screens/Auction/PurchaseAuctionList.tsx index 083a0de..ebf5736 100644 --- a/src/Screens/Auction/PurchaseAuctionList.tsx +++ b/src/Screens/Auction/PurchaseAuctionList.tsx @@ -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'), }, ); }; diff --git a/src/Screens/Bidding/AuctionBiddingScreen.tsx b/src/Screens/Bidding/AuctionBiddingScreen.tsx index e312d50..5e7d5b3 100644 --- a/src/Screens/Bidding/AuctionBiddingScreen.tsx +++ b/src/Screens/Bidding/AuctionBiddingScreen.tsx @@ -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([]); const [bidValues, setBidValues] = useState>({}); 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 = {}; - 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 = {}; - 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 = () => { 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} diff --git a/src/Screens/Bidding/Component/AuctionBiddingCard.tsx b/src/Screens/Bidding/Component/AuctionBiddingCard.tsx index 9043fcb..80199af 100644 --- a/src/Screens/Bidding/Component/AuctionBiddingCard.tsx +++ b/src/Screens/Bidding/Component/AuctionBiddingCard.tsx @@ -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, }; diff --git a/src/Screens/Bidding/Emd.tsx b/src/Screens/Bidding/Emd.tsx index 1826f2c..e3d8569 100644 --- a/src/Screens/Bidding/Emd.tsx +++ b/src/Screens/Bidding/Emd.tsx @@ -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 }) => ( diff --git a/src/Screens/Bidding/__tests__/biddingPattern.test.ts b/src/Screens/Bidding/__tests__/biddingPattern.test.ts index 871c154..ce9f48a 100644 --- a/src/Screens/Bidding/__tests__/biddingPattern.test.ts +++ b/src/Screens/Bidding/__tests__/biddingPattern.test.ts @@ -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); + }); +}); diff --git a/src/Screens/Bidding/biddingPattern.ts b/src/Screens/Bidding/biddingPattern.ts index 0d4d940..bcee0fe 100644 --- a/src/Screens/Bidding/biddingPattern.ts +++ b/src/Screens/Bidding/biddingPattern.ts @@ -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, diff --git a/src/Screens/Home/Home.tsx b/src/Screens/Home/Home.tsx index cb05beb..3df9ec3 100644 --- a/src/Screens/Home/Home.tsx +++ b/src/Screens/Home/Home.tsx @@ -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({}); const [tenderStats, setTenderStats] = useState({}); @@ -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 ( - - + {/* Top Bar */} @@ -145,7 +156,7 @@ const Home = () => { {/* Recent Activity */} Recent Activity - + @@ -157,7 +168,7 @@ const Home = () => { - + ); }; @@ -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, diff --git a/src/Screens/Home/KycWebViewScreen.tsx b/src/Screens/Home/KycWebViewScreen.tsx index b5fcf08..409ff86 100644 --- a/src/Screens/Home/KycWebViewScreen.tsx +++ b/src/Screens/Home/KycWebViewScreen.tsx @@ -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 = () => ( + + + Loading KYC form... + +); export const KycWebViewScreen = ({ route, navigation }: any) => { - const webRef = useRef(null); - const pollTimerRef = useRef | 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 ?? "

No content

"; + const hasCompletedRef = useRef(false); + const countdownIntervalRef = useRef | null>(null); const successTimeoutRef = useRef | null>(null); - const data = route.params ?? {}; - const html = data.res.data; const [isLoading, setIsLoading] = useState(true); const [loadError, setLoadError] = useState(false); const [showSuccessMessage, setShowSuccessMessage] = useState(false); - const [countdown, setCountdown] = useState(5); - const url = false; + const [countdown, setCountdown] = useState(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 = () => ( - - - Loading KYC form... - + const handleNavigationStateChange = useCallback( + (navState: WebViewNavigation) => { + if ( + navState.title?.includes("E-KYC SETU - Verification Success") && + !hasCompletedRef.current + ) { + hasCompletedRef.current = true; + startSuccessCountdown(); + } + }, + [startSuccessCountdown], ); return ( - - {url ? ( - } - /> - ) : ( - No content", - // 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={() => } - /> - )} + + } + /> {isLoading && !showSuccessMessage && } @@ -162,8 +133,8 @@ export const KycWebViewScreen = ({ route, navigation }: any) => { @@ -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, diff --git a/src/Screens/Login/Login.tsx b/src/Screens/Login/Login.tsx index 0151bc7..bed53e5 100644 --- a/src/Screens/Login/Login.tsx +++ b/src/Screens/Login/Login.tsx @@ -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 ( + + {icon} + { + focus.value = withTiming(0); + onBlur(e); + }} + onFocus={() => { + focus.value = withTiming(1); + }} + secureTextEntry={secureTextEntry} + cursorColor={themeColors.primary} + /> + {rightIcon && ( + + {rightIcon} + + )} + + ); +}; + const Login = () => { const { colors, isDarkMode } = useTheme(); const navigation = useNavigation(); @@ -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 ( - - {icon} - { - focus.value = withTiming(0); - onBlur(e); - }} - onFocus={() => { - focus.value = withTiming(1); - }} - secureTextEntry={secureTextEntry} - cursorColor={themeColors.primary} - /> - {rightIcon && ( - - {rightIcon} - - )} - - ); - }; - return ( @@ -237,13 +243,27 @@ const Login = () => { {/* FORM SECTION WITH GRADIENT GLASS CARD - AT BOTTOM */} {showForm && ( - {/* Glass Blur Layer */} - + {/* Glass base layer. + iOS gets the real native blur. On Android @react-native-community/blur + is unreliable — it can blur/overlay the whole window instead of + clipping to this card and leave a persistent frosted veil over the + app after the screen unmounts. So Android uses a plain translucent + fill (the gradients below still give the glass sheen). */} + {Platform.OS === 'ios' ? ( + + ) : ( + + )} {/* Gradient Background Layer */} { 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={ + + } refreshControl={ { } }; - 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 || '-'} - - - {isLive ? 'LIVE' : 'CLOSED'} - diff --git a/src/Screens/Tender/Modal/TenderFullDetail.tsx b/src/Screens/Tender/Modal/TenderFullDetail.tsx index 1792a04..4ab5ebe 100644 --- a/src/Screens/Tender/Modal/TenderFullDetail.tsx +++ b/src/Screens/Tender/Modal/TenderFullDetail.tsx @@ -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) => ( + + + + {title} + + {children} + +); + +const InfoBox = ({ label, value, full = false }: any) => ( + + {label} + {value || '-'} + +); + const TenderFullDetail: React.FC = ({ 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) => ( - - - - {title} - - {children} - - ); - - const InfoBox = ({ label, value, full = false }: any) => ( - - {label} - {value || '-'} - - ); - return ( @@ -80,21 +80,12 @@ const TenderFullDetail: React.FC = ({ visible, onClose, data }) => { Tender Overview - Ref: {tender?.tenderNumber} - - - - - {isLive ? 'LIVE' : 'CLOSED'} + Ref: {tender?.tenderNumber ?? '—'} - + {tender ? ( + {/* Basic Details */}
@@ -199,7 +190,12 @@ const TenderFullDetail: React.FC = ({ visible, onClose, data }) => { ))}
-
+
+ ) : ( + + + + )}
); @@ -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', diff --git a/src/Screens/Tender/PurchaseTenderAllList.tsx b/src/Screens/Tender/PurchaseTenderAllList.tsx index 717c975..b0ce79c 100644 --- a/src/Screens/Tender/PurchaseTenderAllList.tsx +++ b/src/Screens/Tender/PurchaseTenderAllList.tsx @@ -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 }) => ( { - 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={ + + } refreshing={isRefetching} onRefresh={refetch} />} diff --git a/src/Screens/Tender/PurchaseTenderList.tsx b/src/Screens/Tender/PurchaseTenderList.tsx index ae7fa2e..d1aa4c8 100644 --- a/src/Screens/Tender/PurchaseTenderList.tsx +++ b/src/Screens/Tender/PurchaseTenderList.tsx @@ -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 ( { 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 ( diff --git a/src/Screens/Tender/tenderBidding/TenderBidding.tsx b/src/Screens/Tender/tenderBidding/TenderBidding.tsx index 1cbf435..a97d713 100644 --- a/src/Screens/Tender/tenderBidding/TenderBidding.tsx +++ b/src/Screens/Tender/tenderBidding/TenderBidding.tsx @@ -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('A'); const [envelopForm, setEnvelopForm] = useState({ 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 ( @@ -348,6 +376,7 @@ const TenderBidding = () => { key={tab.type} title={tab.title} isActive={activeEnvelope === tab.type} + isSubmitted={isEnvelopeSubmitted(tab.type)} onPress={() => setActiveEnvelope(tab.type)} /> ))} diff --git a/src/Screens/Tender/tenderBidding/components/EnvelopeButton.tsx b/src/Screens/Tender/tenderBidding/components/EnvelopeButton.tsx index 4457d80..b9ccbe2 100644 --- a/src/Screens/Tender/tenderBidding/components/EnvelopeButton.tsx +++ b/src/Screens/Tender/tenderBidding/components/EnvelopeButton.tsx @@ -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 }) => ( + + + +); + +/** + * 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 ( - + {title} + + + {isSubmitted ? ( + + + + ) : ( + + )} + + {isSubmitted ? 'Submitted' : 'Pending'} + + ); }; @@ -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); diff --git a/src/Services/ApiCall/Hooks.ts b/src/Services/ApiCall/Hooks.ts index b80e40a..eecc45d 100644 --- a/src/Services/ApiCall/Hooks.ts +++ b/src/Services/ApiCall/Hooks.ts @@ -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, }); }; diff --git a/src/Spreadsheet/components/ColumnHeaderRow.tsx b/src/Spreadsheet/components/ColumnHeaderRow.tsx index 3bf0ca1..ac39f1c 100644 --- a/src/Spreadsheet/components/ColumnHeaderRow.tsx +++ b/src/Spreadsheet/components/ColumnHeaderRow.tsx @@ -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 = ({ +const ColumnHeaderRowBase: React.FC = ({ sheet, cols, width, gridTheme, activeCol, selStartCol, selEndCol, onSelectColumn, }) => ( @@ -50,6 +50,13 @@ export const ColumnHeaderRow: React.FC = ({ ); +/** + * 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: { diff --git a/src/Spreadsheet/components/DataRow.tsx b/src/Spreadsheet/components/DataRow.tsx index 380edff..31ab2fd 100644 --- a/src/Spreadsheet/components/DataRow.tsx +++ b/src/Spreadsheet/components/DataRow.tsx @@ -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 = ({ 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 ( diff --git a/src/Spreadsheet/hooks/useGridTheme.ts b/src/Spreadsheet/hooks/useGridTheme.ts index 22bc238..4432165 100644 --- a/src/Spreadsheet/hooks/useGridTheme.ts +++ b/src/Spreadsheet/hooks/useGridTheme.ts @@ -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, }), diff --git a/src/components/AppToast.tsx b/src/components/AppToast.tsx new file mode 100644 index 0000000..d166d52 --- /dev/null +++ b/src/components/AppToast.tsx @@ -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 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 = { + success: 'Success!', + error: 'Error!', + info: 'Info', +}; + +const SLIDE_DISTANCE = 400; +const DEFAULT_DURATION = 3200; + +const ToastIcon = ({ type, accent }: { type: AppToastType; accent: string }) => ( + + + {type === 'success' && ( + + )} + {type === 'error' && ( + + )} + {type === 'info' && ( + <> + + + + )} + + +); + +/** + * 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 ( + + + {/* Wide translucent halo trailing the beam. */} + + {/* Crisp bright core of the beam. */} + + + ); +}; + +/** + * 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 | null>(null); + const translateX = useRef(new Animated.Value(SLIDE_DISTANCE)).current; + const opacity = useRef(new Animated.Value(0)).current; + const hideTimer = useRef | 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 = { + 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 ( + + + + + + + {toast.title} + + {toast.message} + + + + {cardSize && ( + + )} + + + + ); +}; + +/** + * 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 . + */ +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, + }, +}); diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx new file mode 100644 index 0000000..19b6f6f --- /dev/null +++ b/src/components/EmptyState.tsx @@ -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 = ({ title, subtitle, icon, style }) => { + const { colors } = useTheme(); + + return ( + + + {icon ?? } + + + {title} + + {!!subtitle && ( + + {subtitle} + + )} + + ); +}; + +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; diff --git a/src/components/SidebarModal.tsx b/src/components/SidebarModal.tsx index d4d495c..4a640f9 100644 --- a/src/components/SidebarModal.tsx +++ b/src/components/SidebarModal.tsx @@ -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 = ({ isVisible, onClose }) => { const { colors, themeMode, toggleTheme } = useTheme(); const insets = useSafeAreaInsets(); const navigation = useNavigation(); + 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 = ({ isVisible, onClose }) => { ); }; - const renderMenuItem = (label: string, Icon: any, onPress: () => void) => ( - - - + const renderMenuCard = (label: string, Icon: any, onPress: () => void) => ( + + + + + {label} + ); @@ -66,39 +113,64 @@ const SidebarModal: React.FC = ({ 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} > - {/* Header */} - - - - - ProcureLinc - v1.0.0 - - - + {/* Top bar — close only */} + + - {/* Menu Items */} - - {renderMenuItem('Profile', User, () => { - onClose(); - navigation.navigate(NavigationString.Profile); - })} - {renderMenuItem('Terms & Conditions', FileText, () => { + {/* Profile hero */} + + + + {initial} + + + + {displayName} + + {!!userData?.userType && ( + + + {String(userData.userType)} + + + )} + + + {/* Menu cards */} + + {renderMenuCard('Profile', User, () => + dismissThen(() => navigation.navigate(NavigationString.Profile)), + )} + {renderMenuCard('Terms & Conditions', FileText, () => { onClose(); // Navigate to Terms })} - {/* Bottom Section: Theme & Logout */} + {/* Pushes the bottom section down */} + + + {/* Bottom Section: Theme, Logout & app identity */} Appearance @@ -114,6 +186,14 @@ const SidebarModal: React.FC = ({ isVisible, onClose }) => { Log Out + + {/* App identity moved to the very bottom */} + + + + ProcureLinc · v1.0.0 + + @@ -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; diff --git a/src/components/SkeletonList.tsx b/src/components/SkeletonList.tsx index 7820653..fad30a0 100644 --- a/src/components/SkeletonList.tsx +++ b/src/components/SkeletonList.tsx @@ -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 ( - - {[...Array(10)].map((_, index) => ( - - - - ))} - - ); -}; +const SkeletonList = () => ( + // One SkeletonPlaceholder (a single shimmer animation) wrapping all rows, + // instead of 10 separate placeholders each running their own animation. + + + + {[...Array(10)].map((_, index) => ( + + ))} + + + +); export default SkeletonList; diff --git a/src/components/index.ts b/src/components/index.ts index cf66a06..b6103ef 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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 }; diff --git a/src/core/events/appEvents.ts b/src/core/events/appEvents.ts index 982447a..e4d50c6 100644 --- a/src/core/events/appEvents.ts +++ b/src/core/events/appEvents.ts @@ -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; diff --git a/src/core/http/client.ts b/src/core/http/client.ts index ba013ab..02fc50e 100644 --- a/src/core/http/client.ts +++ b/src/core/http/client.ts @@ -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) diff --git a/src/core/observability/httpLogging.ts b/src/core/observability/httpLogging.ts index bb13fcf..53d8f41 100644 --- a/src/core/observability/httpLogging.ts +++ b/src/core/observability/httpLogging.ts @@ -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') { diff --git a/src/core/security/screenGuard.ts b/src/core/security/screenGuard.ts new file mode 100644 index 0000000..34d409e --- /dev/null +++ b/src/core/security/screenGuard.ts @@ -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 = new Set([ + 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); +}; diff --git a/src/core/security/useSecureScreen.ts b/src/core/security/useSecureScreen.ts new file mode 100644 index 0000000..9547116 --- /dev/null +++ b/src/core/security/useSecureScreen.ts @@ -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]), + ); +}; diff --git a/src/socket/Socket.tsx b/src/socket/Socket.tsx index 99b31ce..e90c513 100644 --- a/src/socket/Socket.tsx +++ b/src/socket/Socket.tsx @@ -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 }; }