feat: architecture refactor — core layer, spreadsheet engine, envelope bidding, tests
CI / quality (push) Waiting to run
CI / quality (push) Waiting to run
- Add src/core/ (config, errors, events, http, observability, storage, query) - Add src/Spreadsheet/ engine with native POI backend and Excel bridge - Add Envelope C spreadsheet flow and tender bidding envelope modules - Consolidate components into src/components/, remove legacy Component/ and Hooks/ - Add registration state/validation, auth session guard, profile edit modules - Add unit tests across axios, redux, bidding, registration, encryption, core - Add CI workflow, docs (architecture, audit, DSC API spec), keystore example - Remove tracked keystore binary; ignore scripts.zip Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9acec328a7
commit
1d7646d938
@@ -6,11 +6,11 @@ import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { InsideHeader } from '../../Component/Index';
|
||||
import { InsideHeader } from '../../components';
|
||||
import { useFetchLiveAuction, useFetchPurchaseAuction } from '../../Services/ApiCall/Hooks';
|
||||
import LiveAuction from './LiveAuction';
|
||||
import PurchaseAuction from './PurchaseAuction';
|
||||
import { ShoppingCart, Lock, Radio, Package } from 'lucide-react-native';
|
||||
import { ShoppingCart, Radio, Package } from 'lucide-react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -28,7 +28,7 @@ const Auction = () => {
|
||||
const { data: liveAuctionData = {} }: any = useFetchLiveAuction();
|
||||
const { data: purchaseAuctionData = {} } = useFetchPurchaseAuction();
|
||||
const SelectedPurchaseList = useSelector((state: any) => state.selectPurchaseAuction);
|
||||
const reduxToken = useSelector((state: any) => state.token?.token);
|
||||
useSelector((state: any) => state.token?.token);
|
||||
|
||||
const liveCount = Array.isArray(liveAuctionData?.result)
|
||||
? liveAuctionData?.pages?.[0]?.totalCount
|
||||
|
||||
@@ -7,12 +7,11 @@ import {
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import Animated, { FadeInDown, FadeOutUp } from 'react-native-reanimated';
|
||||
import { RefreshCw } from 'lucide-react-native';
|
||||
import LiveAuctionTile from './LiveAuctionTile';
|
||||
import { useFetchLiveAuction } from '../../Services/ApiCall/Hooks';
|
||||
import { SearchInput } from '../../Component/Serach/SeacrhInput';
|
||||
import { Skelton } from '../../Component/Index';
|
||||
import SkeltonList from '../../Component/Skelton/SkeltonList';
|
||||
import { SearchInput } from '../../components/SearchInput';
|
||||
import { Skeleton } from '../../components';
|
||||
import SkeletonList from '../../components/SkeletonList';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { togglePurchaseAuction } from '../../Redux/Slices/selectPurchaseAuction';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
@@ -20,7 +19,7 @@ import AppText from '../../components/AppText';
|
||||
|
||||
const LiveAuction = () => {
|
||||
const Dispatch = useDispatch();
|
||||
const { colors, spacing, shadows } = useTheme();
|
||||
const { colors, shadows } = useTheme();
|
||||
const user = useSelector((state: any) => state.token?.token);
|
||||
const onEndReachedCalledDuringMomentum = useRef(true);
|
||||
const SelectedPurchaseList = useSelector((state: any) => state.selectPurchaseAuction);
|
||||
@@ -64,6 +63,8 @@ const LiveAuction = () => {
|
||||
/>
|
||||
);
|
||||
},
|
||||
// Intentionally runs only on SelectedPurchaseList changes; other deps are stable or recreated each render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[SelectedPurchaseList],
|
||||
);
|
||||
|
||||
@@ -108,7 +109,7 @@ const LiveAuction = () => {
|
||||
}, [data, hasNextPage, isFetchingNextPage, isPending]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skelton />;
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -138,7 +139,7 @@ const LiveAuction = () => {
|
||||
|
||||
{/* List */}
|
||||
{isPending ? (
|
||||
<SkeltonList />
|
||||
<SkeletonList />
|
||||
) : (
|
||||
<FlatList
|
||||
data={data?.result ?? []}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Calendar,
|
||||
Clock,
|
||||
ShoppingCart,
|
||||
Trash2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
} from 'lucide-react-native';
|
||||
|
||||
@@ -15,10 +15,6 @@ import {
|
||||
Eye,
|
||||
Layers,
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
IndianRupee,
|
||||
Wrench,
|
||||
Mail,
|
||||
} from 'lucide-react-native';
|
||||
import { useAuctionItem, useGetAuciton, useGetAucitonDocument } from '../../../Services/ApiCall/Hooks';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
@@ -91,6 +87,8 @@ const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
},
|
||||
},
|
||||
);
|
||||
// Intentionally runs only on auction number/document changes; other deps are stable or recreated each render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data.auctionNumber, auctionDocuments]);
|
||||
|
||||
const isAmended = auctionData?.auctionStatus === 'Amended';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { memo, useState, useMemo } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, Modal } from 'react-native';
|
||||
import { View, StyleSheet, TouchableOpacity, ScrollView, Platform, Modal } from 'react-native';
|
||||
import { Package, IndianRupee, FileText, Download, ArrowLeft, Info, } from 'lucide-react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useAucitonItemDetail } from '../../../Services/ApiCall/Hooks';
|
||||
import ReactNativeBlobUtil from 'react-native-blob-util';
|
||||
import { AUCTION_ITEM_DOCUMENT_DOWNLOAD } from '../../../Services/ApiCall/ApiUrls';
|
||||
import { Loader } from '../../../Component/Index';
|
||||
import { Loader } from '../../../components';
|
||||
import { useTheme } from '../../../Hooks/useTheme';
|
||||
import AppText from '../../../components/AppText';
|
||||
|
||||
@@ -48,7 +48,7 @@ const AuctionItemDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
|
||||
const downloadUrl = `${AUCTION_ITEM_DOCUMENT_DOWNLOAD}?auctionItemId=${id}`;
|
||||
|
||||
const response = await ReactNativeBlobUtil.config({
|
||||
await ReactNativeBlobUtil.config({
|
||||
fileCache: false,
|
||||
addAndroidDownloads: {
|
||||
useDownloadManager: true,
|
||||
@@ -63,7 +63,7 @@ const AuctionItemDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
Accept: 'application/pdf',
|
||||
});
|
||||
|
||||
const stat = await ReactNativeBlobUtil.fs.stat(path);
|
||||
await ReactNativeBlobUtil.fs.stat(path);
|
||||
|
||||
} catch (error: any) {
|
||||
console.log('Error:', error?.message || error);
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import React, { useEffect, useState, useCallback, memo, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, FlatList, RefreshControl, ActivityIndicator } from 'react-native';
|
||||
import LiveAuctionTile from './LiveAuctionTile';
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { View, FlatList, RefreshControl, ActivityIndicator } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import moment from 'moment';
|
||||
import { useFetchLiveAuctionPage, useFetchPurchaseAuction } from '../../Services/ApiCall/Hooks';
|
||||
import { useFetchLiveAuctionPage } from '../../Services/ApiCall/Hooks';
|
||||
import PurchaseAuctionTile from './PurchaseAuctionTile';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { setAuction } from '../../Redux/Slices/SelectedAuction';
|
||||
import { SearchInput } from '../../Component/Serach/SeacrhInput';
|
||||
import { Skelton } from '../../Component/Index';
|
||||
import { SearchInput } from '../../components/SearchInput';
|
||||
import { Skeleton } from '../../components';
|
||||
import { allBooleanProps } from './types/types';
|
||||
import FilterModal from './Modal/FilterModal';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
@@ -20,7 +19,7 @@ const PurchaseAuction = () => {
|
||||
const Dispatch = useDispatch()
|
||||
const onEndReachedCalledDuringMomentum = useRef(true);
|
||||
const [filter, setFilter] = useState({})
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isRefetching, refetch, isPending }: any = useFetchLiveAuctionPage(filter);
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isRefetching, refetch, isPending }: any = useFetchLiveAuctionPage(filter);
|
||||
const [allBoolean, setAllBoolean] = useState<allBooleanProps>({ isShowFilter: false })
|
||||
const format = 'DD-MM-YYYY HH:mm:ss';
|
||||
|
||||
@@ -65,11 +64,11 @@ const PurchaseAuction = () => {
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<SearchInput onChange={(val) => { setFilter(prev => ({ ...prev, auctionNumber: val })) }} placeholder='Search....' filterPress={() => { setAllBoolean((prev: any) => ({ ...prev, isShowFilter: !prev.isShowFilter })) }} filter={false} />
|
||||
{isPending ? <Skelton />
|
||||
{isPending ? <Skeleton />
|
||||
:
|
||||
<FlatList
|
||||
data={data.result ?? []}
|
||||
renderItem={({ item, index }) => (
|
||||
renderItem={({ item }) => (
|
||||
<PurchaseAuctionTile
|
||||
item={item}
|
||||
onPressDetails={() => {
|
||||
|
||||
@@ -15,17 +15,15 @@ import {
|
||||
Receipt,
|
||||
CheckCircle2,
|
||||
Gavel,
|
||||
Building2,
|
||||
BadgeIndianRupee,
|
||||
} from 'lucide-react-native';
|
||||
import { InsideHeader } from '../../Component/Index';
|
||||
import { InsideHeader } from '../../components';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useOfflineAuctionPurchase, usePurchaseAuctionList } from '../../Services/ApiCall/Hooks';
|
||||
import { showSuccessAlert } from '../../Constant/ShowDailog';
|
||||
import { clearPurchaseAuction } from '../../Redux/Slices/selectPurchaseAuction';
|
||||
import { CommonActions, useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import Skeleton from '../../Component/Skelton/Skelton';
|
||||
import Skeleton from '../../components/Skeleton';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import AppText from '../../components/AppText';
|
||||
|
||||
@@ -115,6 +113,8 @@ const PurchaseAuctionList = () => {
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
getPurchaseList();
|
||||
// Intentionally runs only on SelectedPurchaseList changes; other deps are stable or recreated each render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [SelectedPurchaseList]),
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useMemo } from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import Animated, { FadeInUp, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||
import moment from 'moment';
|
||||
import CountdownTimer from '../../Component/Counter/CountdownTimer';
|
||||
import CountdownTimer from '../../components/CountdownTimer';
|
||||
import AuctionDetail from './Modal/AuctionDetail';
|
||||
import AppText from '../../components/AppText';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
|
||||
const PurchaseAuctionTile = ({
|
||||
item,
|
||||
onPressDetails,
|
||||
onStartBidding,
|
||||
buttonEnable,
|
||||
isAuctionEnd,
|
||||
@@ -38,10 +37,6 @@ const PurchaseAuctionTile = ({
|
||||
const biddingEnd = isAmended
|
||||
? item?.amendedkeyDates?.auctionBidding?.endDateTime?.replace(' kk', '')
|
||||
: item?.keyDates?.auctionBidding?.endDateTime?.replace(' kk', '');
|
||||
const biddingStart = isAmended
|
||||
? item?.amendedkeyDates?.auctionBidding?.startDateTime?.replace(' kk', '')
|
||||
: item?.keyDates?.auctionBidding?.startDateTime?.replace(' kk', '');
|
||||
|
||||
const statusConfig = useMemo(() => {
|
||||
if (isAuctionEnd) return { label: 'ENDED', color: colors.error, bg: `${colors.error}15`, Icon: TimerOff };
|
||||
if (buttonEnable) return { label: 'LIVE', color: colors.success, bg: `${colors.success}15`, Icon: CircleDot };
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
FadeInDown,
|
||||
@@ -15,7 +15,7 @@ import Animated, {
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { ChevronDown, ChevronRight, Trash2, ShoppingBag, Gavel, Tag } from 'lucide-react-native';
|
||||
import { InsideHeader } from '../../Component/Index';
|
||||
import { InsideHeader } from '../../components';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -38,6 +38,8 @@ const PurchaseList = () => {
|
||||
if (!SelectedPurchaseList || SelectedPurchaseList.length < 1) {
|
||||
Navigation.goBack();
|
||||
}
|
||||
// Intentionally runs only on SelectedPurchaseList changes; other deps are stable or recreated each render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [SelectedPurchaseList]);
|
||||
|
||||
const styles = makeStyles(colors, spacing, shadows);
|
||||
|
||||
+75
-56
@@ -1,40 +1,52 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
BackHandler,
|
||||
} from 'react-native';
|
||||
import LottieView from 'lottie-react-native';
|
||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||
import { InsideHeader } from '../../Component/Index';
|
||||
import { InsideHeader } from '../../components';
|
||||
import { useWebSocketService } from '../../socket/Socket';
|
||||
import Variables from '../../Constant/Variable';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { apiCall } from '../../Axios/Axios';
|
||||
import {
|
||||
AUCTION_ONE_TIME_BID,
|
||||
FORWARD_AUCTION_ITEM,
|
||||
GET_AUCTION_DETAILS,
|
||||
REVERSE_AUCTION_ITEM,
|
||||
SELECTED_AUCTION_ITEM,
|
||||
} from '../../Services/ApiCall/ApiUrls';
|
||||
import BiddingCard from './Component/BiddingCard';
|
||||
import AuctionBiddingCard from './Component/AuctionBiddingCard';
|
||||
import ConfirmBidModal from './Component/ConfirmBidModal';
|
||||
import GoBackModal from './Component/GoBackModal';
|
||||
import { CommonActions, useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import CountdownTimer from '../../Component/Counter/CountdownTimer';
|
||||
import CountdownTimer from '../../components/CountdownTimer';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import AppText from '../../components/AppText';
|
||||
import { Gavel, Building2, Clock4 } from 'lucide-react-native';
|
||||
import { showErrorAlert, showSuccessAlert } from '../../Constant/ShowDailog';
|
||||
import { logger } from '../../core/observability/logger';
|
||||
import { getBiddingConfig } from './biddingPattern';
|
||||
|
||||
const StartBidding = () => {
|
||||
/**
|
||||
* THE bidding panel — serves both auction patterns. The pattern comes from
|
||||
* the selected auction; every forward/reverse difference lives in
|
||||
* biddingPattern.ts, never in this file.
|
||||
*
|
||||
* Registered under both the StartBidding and StartReverseBidding routes so
|
||||
* existing navigation keeps working unchanged.
|
||||
*/
|
||||
const AuctionBiddingScreen = () => {
|
||||
const { colors, spacing, shadows } = useTheme();
|
||||
const SelectedAuction = useSelector((state: any) => state.auction.auction);
|
||||
const loginUser = useSelector((state: any) => state.token.token);
|
||||
const Navigation: any = useNavigation();
|
||||
const config = useMemo(
|
||||
() => getBiddingConfig(SelectedAuction?.auctionPattern),
|
||||
[SelectedAuction?.auctionPattern],
|
||||
);
|
||||
|
||||
const [items, setItems] = useState<any>([]);
|
||||
const [bidValues, setBidValues] = useState<Record<number, number>>({});
|
||||
@@ -48,11 +60,10 @@ const StartBidding = () => {
|
||||
reload: false,
|
||||
});
|
||||
|
||||
const { subscribe, createRoom, placeBid } = useWebSocketService(
|
||||
const { subscribe, unsubscribe, disconnect, createRoom, placeBid } = useWebSocketService(
|
||||
Variables.webSocketUrl,
|
||||
() => console.log('Connected!'),
|
||||
(error: any) => console.log('WebSocket Error:', error),
|
||||
loginUser.token,
|
||||
() => logger.info('stomp', 'connected'),
|
||||
(error: any) => logger.warn('stomp', `connection error: ${error}`),
|
||||
);
|
||||
|
||||
const getAuction = async () => {
|
||||
@@ -74,10 +85,8 @@ const StartBidding = () => {
|
||||
const payload = { auctionNumber: SelectedAuction.auctionNumber };
|
||||
let auctionItem: any[] = [];
|
||||
|
||||
if (SelectedAuction.auctionPattern === 'Forward') {
|
||||
const res = await apiCall<any>('get', FORWARD_AUCTION_ITEM, {}, payload);
|
||||
if (res?.statusCode === 200) auctionItem = res.data || [];
|
||||
}
|
||||
const res = await apiCall<any>('get', config.itemsUrl, {}, payload);
|
||||
if (res?.statusCode === 200) { auctionItem = res.data || []; }
|
||||
|
||||
const selectedRes = await apiCall<any>('get', SELECTED_AUCTION_ITEM, {}, payload);
|
||||
const uniqueArray: number[] = Array.from(new Set(selectedRes?.data || []));
|
||||
@@ -85,18 +94,15 @@ const StartBidding = () => {
|
||||
|
||||
if (SelectedAuction.auctionCategories === 'Live') {
|
||||
const result = await createRoom(SelectedAuction.auctionNumber);
|
||||
subscribe(`/topic/room/${SelectedAuction.auctionNumber}`, (data: any) => {
|
||||
console.log('🔥 BID UPDATE:', data);
|
||||
});
|
||||
const bidAmountMap2 = new Map<number, { bidAmount: number; contractorId: number }>();
|
||||
const liveBids = new Map<number, { bidAmount: number; contractorId: number }>();
|
||||
(result || []).forEach((r: any) => {
|
||||
bidAmountMap2.set(r.auctionItemId, { bidAmount: r.bidAmount, contractorId: r.contractorId });
|
||||
liveBids.set(r.auctionItemId, { bidAmount: r.bidAmount, contractorId: r.contractorId });
|
||||
});
|
||||
const updatedFilteredItems = filteredItems.map(item => {
|
||||
const bidData = bidAmountMap2.get(item.id);
|
||||
const bidData = liveBids.get(item.id);
|
||||
return {
|
||||
...item,
|
||||
bidAmount: bidData?.bidAmount ?? item.auctionStartValueFigure,
|
||||
bidAmount: bidData?.bidAmount ?? config.startValue(item),
|
||||
bidAvilable: bidData?.bidAmount ?? 0,
|
||||
me: bidData?.contractorId === loginUser.contractorId,
|
||||
};
|
||||
@@ -106,14 +112,13 @@ const StartBidding = () => {
|
||||
setBidValues(initialBids);
|
||||
setItems(updatedFilteredItems);
|
||||
} else {
|
||||
if (SelectedAuction.rebidAllowed) setAllBoolean(prev => ({ ...prev, rebid: true }));
|
||||
let result: any[] = [];
|
||||
if (SelectedAuction.auctionCategories !== 'Live') result = await getOneTimeBidValue();
|
||||
if (SelectedAuction.rebidAllowed) { setAllBoolean(prev => ({ ...prev, rebid: true })); }
|
||||
const result: any[] = await getOneTimeBidValue();
|
||||
const bidMap = new Map<number, any>();
|
||||
result.forEach((bid: any) => bidMap.set(bid.auctionItemId, bid));
|
||||
const mergedData = auctionItem.map((item: any) => {
|
||||
const bid = bidMap.get(item.id);
|
||||
if (!bid) return { ...item, bidAmount: item.auctionStartValueFigure, contractorId: item.contractorId };
|
||||
if (!bid) { return { ...item, bidAmount: config.startValue(item), contractorId: item.contractorId }; }
|
||||
return {
|
||||
...item,
|
||||
bidAmount: bid.bidAmount,
|
||||
@@ -130,34 +135,41 @@ const StartBidding = () => {
|
||||
setItems(mergedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching auction items:', error);
|
||||
logger.error('bidding', 'fetching auction items failed', { error: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
subscribe(`/topic/room/${SelectedAuction.auctionNumber}`, async (message: any) => {
|
||||
const result = message[message.length - 1];
|
||||
setItems((prev: any[]) =>
|
||||
prev.map(item =>
|
||||
item.id === result.auctionItemId
|
||||
? { ...item, bidAmount: result.bidAmount, me: result.contractorId === loginUser.contractorId }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
setBidValues(prev => ({ ...prev, [result.auctionItemId]: result.bidAmount }));
|
||||
});
|
||||
// Live bid updates: subscribe once per room from an effect (never the render
|
||||
// body) and tear the subscription down when the room changes or we unmount.
|
||||
useEffect(() => {
|
||||
const destination = `/topic/room/${SelectedAuction.auctionNumber}`;
|
||||
subscribe(destination, (message: any) => {
|
||||
const result = message[message.length - 1];
|
||||
setItems((prev: any[]) =>
|
||||
prev.map(item =>
|
||||
item.id === result.auctionItemId
|
||||
? { ...item, bidAmount: result.bidAmount, me: result.contractorId === loginUser.contractorId }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
setBidValues(prev => ({ ...prev, [result.auctionItemId]: result.bidAmount }));
|
||||
});
|
||||
return () => unsubscribe(destination);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [SelectedAuction.auctionNumber]);
|
||||
|
||||
// Fully close the STOMP connection on unmount — a leaked client keeps
|
||||
// reconnecting every 5s and drains battery/data.
|
||||
const disconnectRef = useRef(disconnect);
|
||||
disconnectRef.current = disconnect;
|
||||
useEffect(() => () => disconnectRef.current(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (SelectedAuction.auctionCategories === 'One-time') getAuction();
|
||||
if (SelectedAuction.auctionCategories === 'One-time') { getAuction(); }
|
||||
getAllAcutionItems();
|
||||
subscribe(`/topic/room/${SelectedAuction.auctionNumber}`, () => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allBoolean.reload]);
|
||||
|
||||
useEffect(() => {
|
||||
const initial: Record<number, number> = {};
|
||||
items.forEach((item: any) => { initial[item.id] = item.bidAmount + item.bidVariationValue; });
|
||||
setBidValues(initial);
|
||||
}, []);
|
||||
|
||||
const handleUpdate = (id: number, val: number) => {
|
||||
setBidValues(prev => ({ ...prev, [id]: prev[id] + val }));
|
||||
};
|
||||
@@ -177,10 +189,15 @@ const StartBidding = () => {
|
||||
const placeBidSubmit = async (data: any) => {
|
||||
setAllBoolean(prev => ({ ...prev, showDialog: false }));
|
||||
try {
|
||||
const result: any = await placeBid(data);
|
||||
if (result?.status && result.data) getAllAcutionItems();
|
||||
const result = await placeBid(data);
|
||||
if (result?.status && result.data) {
|
||||
if (result.message) { showSuccessAlert(result.message); }
|
||||
getAllAcutionItems();
|
||||
} else if (result?.message) {
|
||||
showErrorAlert(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error placing bid:', error);
|
||||
logger.error('bidding', 'placing bid failed', { error: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -192,7 +209,7 @@ const StartBidding = () => {
|
||||
};
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', onBackPress);
|
||||
return () => sub.remove();
|
||||
}, [Navigation]),
|
||||
}, []),
|
||||
);
|
||||
|
||||
const styles = makeStyles(colors, spacing, shadows);
|
||||
@@ -260,15 +277,16 @@ const StartBidding = () => {
|
||||
data={items}
|
||||
keyExtractor={item => item.id.toString()}
|
||||
renderItem={({ item, index }) => (
|
||||
<BiddingCard
|
||||
<AuctionBiddingCard
|
||||
item={{ ...item, index }}
|
||||
bidValue={bidValues[item.id] || 0}
|
||||
onIncrease={() => handleUpdate(item.id, item.bidVariationValue)}
|
||||
onDecrease={() => handleUpdate(item.id, -item.bidVariationValue)}
|
||||
onIncrease={() => handleUpdate(item.id, config.step(item))}
|
||||
onDecrease={() => handleUpdate(item.id, -config.step(item))}
|
||||
onPlaceBid={() => submitBid(item)}
|
||||
SelectedAuction={SelectedAuction}
|
||||
allBoolean={allBoolean}
|
||||
setAllBoolean={setAllBoolean}
|
||||
config={config}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={styles.listContent}
|
||||
@@ -282,7 +300,7 @@ const StartBidding = () => {
|
||||
source={require('../../lottie/Success.json')}
|
||||
autoPlay
|
||||
loop={false}
|
||||
style={{ width: 200, height: 200 }}
|
||||
style={styles.successLottie}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -311,7 +329,7 @@ const StartBidding = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default StartBidding;
|
||||
export default AuctionBiddingScreen;
|
||||
|
||||
/* ─── Styles factory ─── */
|
||||
const makeStyles = (colors: any, spacing: any, shadows: any) =>
|
||||
@@ -399,4 +417,5 @@ const makeStyles = (colors: any, spacing: any, shadows: any) =>
|
||||
alignItems: 'center',
|
||||
zIndex: 99,
|
||||
},
|
||||
successLottie: { width: 200, height: 200 },
|
||||
});
|
||||
+123
-73
@@ -1,25 +1,48 @@
|
||||
import React, { memo, useCallback, useRef, useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, Platform, TextInput, Alert } from 'react-native';
|
||||
import { View, TouchableOpacity, StyleSheet, TextInput } from 'react-native';
|
||||
import Animated, { FadeInUp, Layout, interpolate, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
|
||||
import colors_const from '../../../Constant/Color';
|
||||
import AuctionItemDetail from '../../Auction/Modal/AuctionItemDetail';
|
||||
import MainStyle from '../../../Styles/MainStyle';
|
||||
import { useTheme } from '../../../Hooks/useTheme';
|
||||
import AppText from '../../../components/AppText';
|
||||
import { Loader } from '../../../Component/Index';
|
||||
import { Loader } from '../../../components';
|
||||
import { useSelector } from 'react-redux';
|
||||
import axios from 'axios';
|
||||
import Variables from '../../../Constant/Variable';
|
||||
import { env } from '../../../core/config/env';
|
||||
import { request } from '../../../core/http/client';
|
||||
import { isAppError } from '../../../core/http/AppError';
|
||||
import { logger } from '../../../core/observability/logger';
|
||||
import { showErrorAlert, showSuccessAlert } from '../../../Constant/ShowDailog';
|
||||
import { BiddingPatternConfig } from '../biddingPattern';
|
||||
|
||||
const BiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid, SelectedAuction, allBoolean, setAllBoolean }: any) => {
|
||||
type Props = {
|
||||
item: any;
|
||||
bidValue: number;
|
||||
onIncrease: () => void;
|
||||
onDecrease: () => void;
|
||||
onPlaceBid: () => void;
|
||||
SelectedAuction: any;
|
||||
allBoolean: any;
|
||||
setAllBoolean: React.Dispatch<React.SetStateAction<any>>;
|
||||
config: BiddingPatternConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* The single bidding card for BOTH auction patterns. All forward/reverse
|
||||
* behavior differences come from `config` (see biddingPattern.ts) — never
|
||||
* branch on the pattern inside this component.
|
||||
*/
|
||||
const AuctionBiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid, SelectedAuction, allBoolean, setAllBoolean, config }: Props) => {
|
||||
const { colors } = useTheme();
|
||||
const styles = React.useMemo(() => makeStyles(colors), [colors]);
|
||||
const Token = useSelector((state: any) => state.token.token)
|
||||
const Token = useSelector((state: any) => state.token.token);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [value, setValue] = useState(`${item.bidAmount}`);
|
||||
const [allState, setAllState] = useState({ isLoading: false })
|
||||
const isMin = bidValue <= item.bidAmount;
|
||||
const [allState, setAllState] = useState({ isLoading: false });
|
||||
|
||||
const canIncrease = config.canIncrease(bidValue, item);
|
||||
const canDecrease = config.canDecrease(bidValue, item);
|
||||
const bodyFull = config.isBodyFull(allBoolean, item);
|
||||
const submitVisible = config.showSubmit(allBoolean, item);
|
||||
|
||||
// 🔒 Persist expand state safely
|
||||
const expandedRef = useRef(false);
|
||||
@@ -31,7 +54,7 @@ const BiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid, Selec
|
||||
damping: 15,
|
||||
stiffness: 140,
|
||||
});
|
||||
}, []);
|
||||
}, [animation]);
|
||||
|
||||
const bodyStyle = useAnimatedStyle(() => ({
|
||||
height: interpolate(animation.value, [0, 1], [0, 190]),
|
||||
@@ -51,22 +74,26 @@ const BiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid, Selec
|
||||
},
|
||||
],
|
||||
}));
|
||||
const MAX_VALUE = Number(item.bidAmount);
|
||||
|
||||
const handleChange = (text: string) => {
|
||||
let cleaned = text.replace(/[^0-9.]/g, '');
|
||||
if ((cleaned.match(/\./g) || []).length > 1) return;
|
||||
const cleaned = text.replace(/[^0-9.]/g, '');
|
||||
if ((cleaned.match(/\./g) || []).length > 1) { return; }
|
||||
if (cleaned === '') {
|
||||
setValue('');
|
||||
return;
|
||||
}
|
||||
if (!config.acceptManualInput(Number(cleaned), item)) { return; }
|
||||
setValue(cleaned);
|
||||
};
|
||||
|
||||
|
||||
const oneTimePlaceBid = async () => {
|
||||
const validationError = config.validateManualBid(Number(value), item);
|
||||
if (validationError) {
|
||||
showErrorAlert(validationError);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (Number(value) < MAX_VALUE) {
|
||||
showErrorAlert("Enter the greather vlaue to current bid value")
|
||||
return false
|
||||
}
|
||||
setAllState((prev: any) => ({ ...prev, isLoading: true }))
|
||||
setAllState((prev: any) => ({ ...prev, isLoading: true }));
|
||||
const payload = {
|
||||
itemId: item.id,
|
||||
bidAmount: Number(value),
|
||||
@@ -75,28 +102,41 @@ const BiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid, Selec
|
||||
fullName: Token.fullName,
|
||||
};
|
||||
|
||||
const res = await request<any>({
|
||||
method: 'post',
|
||||
url: `${env.socketBaseUrl}bidding/placeBidForAuction`,
|
||||
data: [payload],
|
||||
});
|
||||
|
||||
const res = await axios.post(
|
||||
`${Variables.socketUrl}bidding/placeBidForAuction`,
|
||||
[payload],
|
||||
{ headers: { Authentication: `Bearer ${Token.token}` } }
|
||||
);
|
||||
|
||||
if (res.data.statusCode === 200) {
|
||||
showSuccessAlert(res.data.message);
|
||||
if (res.statusCode === 200) {
|
||||
showSuccessAlert(res.message);
|
||||
}
|
||||
setAllBoolean((prev: any) => ({ ...prev, reload: !prev.reload }))
|
||||
setAllBoolean((prev: any) => ({ ...prev, reload: !prev.reload }));
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
showErrorAlert(error.response?.data.message)
|
||||
if (isAppError(error) && error.statusCode !== undefined) {
|
||||
showErrorAlert(error.message);
|
||||
} else {
|
||||
console.log('UNKNOWN ERROR:', error);
|
||||
logger.error('bidding', 'manual bid failed', { error: String(error) });
|
||||
}
|
||||
|
||||
} finally {
|
||||
setAllState((prev: any) => ({ ...prev, isLoading: false }))
|
||||
setAllState((prev: any) => ({ ...prev, isLoading: false }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const manualInput = (allBoolean.oneTimeBid || allBoolean.rebid) && (
|
||||
<View style={[styles.inputBox2, config.inlineManualInput ? styles.inputBox2Inline : styles.inputBox2Block]}>
|
||||
<AppText variant="body" color={colors.textSecondary} bold style={styles.currency2 as any}>₹</AppText>
|
||||
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder="0.00"
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
@@ -109,19 +149,18 @@ const BiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid, Selec
|
||||
{/* HEADER */}
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={() => { setShowDetail(true) }}
|
||||
onPress={() => { setShowDetail(true); }}
|
||||
style={styles.cardHeader}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={styles.flex1}>
|
||||
<AppText variant="h4" color={colors.text} bold style={styles.productName as any}>{item.productName}</AppText>
|
||||
{/* auctionStartValueFigure */}
|
||||
<AppText variant="caption" color={colors.textSecondary} style={styles.itemIdText as any}>
|
||||
Initial Amt : {item?.auctionStartValueFigure}
|
||||
Initial Amt : ₹{config.startValue(item)}
|
||||
</AppText>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.headerRight} onPress={toggleExpand}>
|
||||
<View style={[MainStyle.flexBetween, { gap: 10 }]}>
|
||||
<View style={[MainStyle.flexBetween, styles.gap10]}>
|
||||
<View style={[styles.badge, item.me ? styles.meBadge : styles.liveBadge]}>
|
||||
<AppText variant="caption" bold style={[styles.badgeText as any, item.me ? styles.meText : styles.liveText]}>
|
||||
{item.me ? 'LEADING' : 'LIVE'}
|
||||
@@ -143,77 +182,83 @@ const BiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid, Selec
|
||||
<View style={styles.priceRow}>
|
||||
<View>
|
||||
<AppText variant="caption" color={colors.textSecondary} semiBold style={styles.label as any}>Current Bid</AppText>
|
||||
<AppText variant="h2" color={colors.text} bold style={styles.mainPrice as any}>₹{item?.bidAmount || item.auctionStartValueFigure}</AppText>
|
||||
<AppText variant="h2" color={colors.text} bold style={styles.mainPrice as any}>₹{item?.bidAmount || config.startValue(item)}</AppText>
|
||||
</View>
|
||||
|
||||
<View style={{ alignItems: 'flex-end' }}>
|
||||
<AppText variant="caption" color={colors.textSecondary} semiBold style={styles.label as any}>Increment</AppText>
|
||||
<View style={styles.alignEnd}>
|
||||
<AppText variant="caption" color={colors.textSecondary} semiBold style={styles.label as any}>{config.stepLabel}</AppText>
|
||||
<AppText variant="body" color={colors.primary} bold style={styles.incrementText as any}>
|
||||
+₹{item.bidVariationValue}
|
||||
{config.stepPrefix}₹{config.step(item)}
|
||||
</AppText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* COLLAPSIBLE BODY */}
|
||||
<Animated.View style={[styles.collapsibleContainer, (!allBoolean.rebid && item.contractorId == undefined) ? bodyStyle : bodyStyle2]}>
|
||||
<Animated.View style={[styles.collapsibleContainer, bodyFull ? bodyStyle : bodyStyle2]}>
|
||||
<View style={styles.divider} />
|
||||
|
||||
<AppText variant="bodySmall" color={colors.textSecondary} semiBold style={styles.bidLabel as any}>Set Your Bid</AppText>
|
||||
|
||||
{!allBoolean.oneTimeBid && <View style={styles.stepperContainer}>
|
||||
<TouchableOpacity
|
||||
{(!allBoolean.oneTimeBid || config.inlineManualInput) && <View style={styles.stepperContainer}>
|
||||
{!allBoolean.oneTimeBid && <TouchableOpacity
|
||||
onPress={onDecrease}
|
||||
disabled={isMin}
|
||||
style={[styles.stepBtn, isMin && styles.disabledBtn]}
|
||||
disabled={!canDecrease}
|
||||
style={[styles.stepBtn, !canDecrease && styles.disabledBtn]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Decrease bid amount"
|
||||
accessibilityState={{ disabled: !canDecrease }}
|
||||
>
|
||||
<AppText variant="h3" color="#FFF" bold style={styles.stepText as any}>−</AppText>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>}
|
||||
|
||||
<View style={styles.inputBox}>
|
||||
{!allBoolean.oneTimeBid && <View style={styles.inputBox} accessibilityLabel={`Current bid amount ${bidValue} rupees`}>
|
||||
<AppText variant="body" color={colors.textSecondary} bold style={styles.currency as any}>₹</AppText>
|
||||
<AppText variant="h4" color={colors.text} bold style={styles.bidValueText as any}>{bidValue}</AppText>
|
||||
</View>
|
||||
</View>}
|
||||
|
||||
<TouchableOpacity onPress={onIncrease} style={styles.stepBtn}>
|
||||
{config.inlineManualInput && manualInput}
|
||||
|
||||
{!allBoolean.oneTimeBid && <TouchableOpacity
|
||||
onPress={onIncrease}
|
||||
disabled={!canIncrease}
|
||||
style={[styles.stepBtn, !canIncrease && styles.disabledBtn]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Increase bid amount"
|
||||
accessibilityState={{ disabled: !canIncrease }}
|
||||
>
|
||||
<AppText variant="h3" color="#FFF" bold style={styles.stepText as any}>+</AppText>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>}
|
||||
</View>}
|
||||
{
|
||||
(allBoolean.oneTimeBid || allBoolean.rebid) && <View style={styles.inputBox2}>
|
||||
<AppText variant="body" color={colors.textSecondary} bold style={styles.currency2 as any}>₹</AppText>
|
||||
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder="0.00"
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
{!config.inlineManualInput && manualInput}
|
||||
|
||||
{(!allBoolean.rebid && item.contractorId == undefined) ? <TouchableOpacity
|
||||
{submitVisible ? <TouchableOpacity
|
||||
style={[styles.submitBtn]}
|
||||
onPress={(allBoolean.oneTimeBid || allBoolean.rebid) ? oneTimePlaceBid : onPlaceBid}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Confirm bid"
|
||||
accessibilityState={{ busy: allState.isLoading }}
|
||||
>
|
||||
{
|
||||
allState.isLoading ? <Loader size='small' color="#FFF" /> : <AppText variant="button" color="#FFF" bold style={styles.submitBtnText as any}>Confirm Bid</AppText>
|
||||
allState.isLoading ? <Loader size="small" color="#FFF" /> : <AppText variant="button" color="#FFF" bold style={styles.submitBtnText as any}>Confirm Bid</AppText>
|
||||
}
|
||||
</TouchableOpacity> : null}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
:
|
||||
<AuctionItemDetail data={item} onClose={() => { setShowDetail(false) }} visible={showDetail} />
|
||||
<AuctionItemDetail data={item} onClose={() => { setShowDetail(false); }} visible={showDetail} />
|
||||
}
|
||||
|
||||
</View>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(BiddingCard);
|
||||
export default memo(AuctionBiddingCard);
|
||||
|
||||
const makeStyles = (colors: any) => StyleSheet.create({
|
||||
flex1: { flex: 1 },
|
||||
gap10: { gap: 10 },
|
||||
alignEnd: { alignItems: 'flex-end' },
|
||||
inputBox2: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -221,8 +266,13 @@ const makeStyles = (colors: any) => StyleSheet.create({
|
||||
borderRadius: 12,
|
||||
height: 45,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
inputBox2Block: {
|
||||
width: '100%',
|
||||
marginBottom: 20
|
||||
marginBottom: 20,
|
||||
},
|
||||
inputBox2Inline: {
|
||||
flex: 1,
|
||||
},
|
||||
currency2: {
|
||||
marginRight: 4,
|
||||
@@ -231,7 +281,7 @@ const makeStyles = (colors: any) => StyleSheet.create({
|
||||
flex: 1,
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
width: '100%'
|
||||
width: '100%',
|
||||
},
|
||||
card: {
|
||||
borderRadius: 24,
|
||||
@@ -1,65 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
import moment from 'moment';
|
||||
import LottieView from 'lottie-react-native';
|
||||
import colors from '../../Constant/Color';
|
||||
|
||||
const AuctionTimer = ({ SelectedAuction, setAllBoolean }: any) => {
|
||||
const [timeLeft, setTimeLeft] = useState('00:00:00');
|
||||
|
||||
useEffect(() => {
|
||||
if (!SelectedAuction?.keyDates?.auctionBidding?.endDateTime) return;
|
||||
|
||||
const format = 'DD-MM-YYYY HH:mm:ss';
|
||||
const endTime = moment(
|
||||
SelectedAuction.keyDates.auctionBidding.endDateTime,
|
||||
format
|
||||
);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const now = moment();
|
||||
const diff = moment.duration(endTime.diff(now));
|
||||
|
||||
if (diff.asSeconds() <= 0) {
|
||||
setTimeLeft('00:00:00');
|
||||
clearInterval(timer);
|
||||
|
||||
setAllBoolean((prev: any) => ({
|
||||
...prev,
|
||||
timeEnd: true,
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const hours = String(Math.floor(diff.asHours())).padStart(2, '0');
|
||||
const minutes = String(diff.minutes()).padStart(2, '0');
|
||||
const seconds = String(diff.seconds()).padStart(2, '0');
|
||||
|
||||
setTimeLeft(`${hours}:${minutes}:${seconds}`);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [SelectedAuction]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
<LottieView
|
||||
source={require('../../lottie/clock.json')}
|
||||
autoPlay
|
||||
loop
|
||||
style={{ width: 20, height: 20 }}
|
||||
/>
|
||||
<Text style={{ fontSize: 18, color: colors.black }}>{timeLeft}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuctionTimer;
|
||||
@@ -19,7 +19,7 @@ const ConfirmBidModal = ({ visible, onClose, onConfirm, bidAmount, isLoading }:
|
||||
|
||||
return (
|
||||
<View style={styles.overlay}>
|
||||
<View style={[styles.modalCard, { backgroundColor: colors.surface, ...shadows.large }]}>
|
||||
<View style={[styles.modalCard, { backgroundColor: colors.surface, ...shadows.strong }]}>
|
||||
|
||||
<TouchableOpacity style={[styles.closeBtn, { backgroundColor: colors.background }]} onPress={onClose} disabled={isLoading}>
|
||||
<X size={18} color={colors.textSecondary} strokeWidth={2.5} />
|
||||
|
||||
@@ -19,7 +19,7 @@ const STEPS = [
|
||||
];
|
||||
|
||||
const EmdComplete = ({ onNext }: { onNext?: () => void }) => {
|
||||
const { colors, spacing, shadows } = useTheme();
|
||||
const { colors, shadows } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.root, { backgroundColor: colors.background }]}>
|
||||
|
||||
@@ -17,7 +17,7 @@ const GoBackModal = ({ visible, onClose, onConfirm }: Props) => {
|
||||
|
||||
return (
|
||||
<View style={styles.overlay}>
|
||||
<View style={[styles.modalCard, { backgroundColor: colors.surface, ...shadows.large }]}>
|
||||
<View style={[styles.modalCard, { backgroundColor: colors.surface, ...shadows.strong }]}>
|
||||
|
||||
<TouchableOpacity style={[styles.closeBtn, { backgroundColor: colors.background }]} onPress={onClose}>
|
||||
<X size={18} color={colors.textSecondary} strokeWidth={2.5} />
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
import React, { memo, useCallback, useRef, useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, Platform, TextInput } from 'react-native';
|
||||
import Animated, { FadeInUp, Layout, interpolate, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
|
||||
import colors_const from '../../../Constant/Color';
|
||||
import AuctionItemDetail from '../../Auction/Modal/AuctionItemDetail';
|
||||
import MainStyle from '../../../Styles/MainStyle';
|
||||
import { useTheme } from '../../../Hooks/useTheme';
|
||||
import AppText from '../../../components/AppText';
|
||||
import axios, { all } from 'axios';
|
||||
import Variables from '../../../Constant/Variable';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { showErrorAlert, showSuccessAlert } from '../../../Constant/ShowDailog';
|
||||
import { Loader } from '../../../Component/Index';
|
||||
|
||||
const ReverseBiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid, SelectedAuction, allBoolean, setAllBoolean }: any) => {
|
||||
const { colors } = useTheme();
|
||||
const styles = React.useMemo(() => makeStyles(colors), [colors]);
|
||||
const Token = useSelector((state: any) => state.token.token)
|
||||
const [allState, setAllState] = useState({ isLoading: false })
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [value, setValue] = useState(`${item.bidAmount}`);
|
||||
const canDecrease = bidValue > item.emdAmount;
|
||||
const canIncrease = bidValue < item.bidAmount;
|
||||
// Animation Part Rohit Start
|
||||
|
||||
const expandedRef = useRef(false);
|
||||
const animation = useSharedValue(0);
|
||||
|
||||
const toggleExpand = useCallback(() => {
|
||||
expandedRef.current = !expandedRef.current;
|
||||
animation.value = withSpring(expandedRef.current ? 1 : 0, {
|
||||
damping: 15,
|
||||
stiffness: 140,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const bodyStyle = useAnimatedStyle(() => ({
|
||||
height: interpolate(animation.value, [0, 1], [0, 190]),
|
||||
opacity: interpolate(animation.value, [0, 0.5, 1], [0, 0, 1]),
|
||||
marginTop: interpolate(animation.value, [0, 1], [0, 15]),
|
||||
}));
|
||||
const bodyStyle2 = useAnimatedStyle(() => ({
|
||||
height: interpolate(animation.value, [0, 1], [0, 100]),
|
||||
opacity: interpolate(animation.value, [0, 0.5, 1], [0, 0, 1]),
|
||||
marginTop: interpolate(animation.value, [0, 1], [0, 15]),
|
||||
}));
|
||||
|
||||
const arrowStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
rotate: `${interpolate(animation.value, [0, 1], [0, 180])}deg`,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
// Animation Part Rohit End
|
||||
|
||||
const MAX_VALUE = Number(item.auctionStartValue);
|
||||
|
||||
const handleChange = (text: string) => {
|
||||
let cleaned = text.replace(/[^0-9.]/g, '');
|
||||
if ((cleaned.match(/\./g) || []).length > 1) return;
|
||||
if (cleaned === '') {
|
||||
setValue('');
|
||||
return;
|
||||
}
|
||||
const numericValue = Number(cleaned);
|
||||
if (numericValue < 0) return;
|
||||
if (numericValue > MAX_VALUE) return;
|
||||
setValue(cleaned);
|
||||
};
|
||||
|
||||
const oneTimePlaceBid = async () => {
|
||||
try {
|
||||
setAllState((prev: any) => ({ ...prev, isLoading: true }))
|
||||
const payload = {
|
||||
itemId: item.id,
|
||||
bidAmount: Number(value),
|
||||
auctionId: SelectedAuction.auctionId,
|
||||
contractorId: Token.contractorId,
|
||||
fullName: Token.fullName,
|
||||
};
|
||||
|
||||
|
||||
const res = await axios.post(
|
||||
`${Variables.socketUrl}bidding/placeBidForAuction`,
|
||||
[payload],
|
||||
{ headers: { Authentication: `Bearer ${Token.token}` } }
|
||||
);
|
||||
|
||||
if (res.data.statusCode === 200) {
|
||||
showSuccessAlert(res.data.message);
|
||||
}
|
||||
setAllBoolean((prev: any) => ({ ...prev, reload: !prev.reload }))
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
showErrorAlert(error.response?.data.message)
|
||||
} else {
|
||||
console.log('UNKNOWN ERROR:', error);
|
||||
}
|
||||
|
||||
} finally {
|
||||
setAllState((prev: any) => ({ ...prev, isLoading: false }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
{!showDetail ? (
|
||||
<Animated.View
|
||||
layout={Layout.springify()}
|
||||
entering={FadeInUp.delay(100)}
|
||||
style={[
|
||||
styles.card,
|
||||
item.me ? styles.leadingCard : undefined,
|
||||
{
|
||||
borderColor: item.me ? colors.success : colors.error,
|
||||
backgroundColor: item.me ? `${colors.success}10` : `${colors.error}10`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* HEADER */}
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={() => setShowDetail(true)}
|
||||
style={styles.cardHeader}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<AppText variant="h4" color={colors.text} bold style={styles.productName as any}>{item.productName}</AppText>
|
||||
<AppText variant="caption" color={colors.textSecondary} style={styles.itemIdText as any}>
|
||||
Initial Amt : ₹{item.auctionStartValue}
|
||||
</AppText>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.headerRight} onPress={toggleExpand}>
|
||||
<View style={[MainStyle.flexBetween, { gap: 10 }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.badge,
|
||||
item.me ? styles.meBadge : styles.liveBadge,
|
||||
]}
|
||||
>
|
||||
<AppText
|
||||
variant="caption"
|
||||
bold
|
||||
style={[
|
||||
styles.badgeText as any,
|
||||
item.me ? styles.meText : styles.liveText,
|
||||
]}
|
||||
>
|
||||
{item.me ? 'LEADING' : 'LIVE'}
|
||||
</AppText>
|
||||
</View>
|
||||
|
||||
<Animated.Text style={[styles.arrow, { color: colors.textSecondary }, arrowStyle]}>
|
||||
▼
|
||||
</Animated.Text>
|
||||
</View>
|
||||
<AppText variant="caption" color={colors.textSecondary} style={styles.itemIdText as any}>
|
||||
{SelectedAuction?.auctionNumber}
|
||||
</AppText>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* PRICE INFO */}
|
||||
<View style={styles.priceRow}>
|
||||
<View>
|
||||
<AppText variant="caption" color={colors.textSecondary} semiBold style={styles.label as any}>Current Bid</AppText>
|
||||
<AppText variant="h2" color={colors.text} bold style={styles.mainPrice as any}>₹{item.bidAmount || item.auctionStartValue}</AppText>
|
||||
</View>
|
||||
|
||||
<View style={{ alignItems: 'flex-end' }}>
|
||||
<AppText variant="caption" color={colors.textSecondary} semiBold style={styles.label as any}>Decrement</AppText>
|
||||
<AppText variant="body" color={colors.primary} bold style={styles.incrementText as any}>
|
||||
-₹{item.emdAmount}
|
||||
</AppText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* COLLAPSIBLE BODY */}
|
||||
{/* <Animated.View style={[styles.collapsibleContainer, (allBoolean.oneTimeBid && allBoolean.rebid) ? bodyStyle : (allBoolean.oneTimeBid && !item.me) ? bodyStyle : bodyStyle2]}> */}
|
||||
<Animated.View style={[styles.collapsibleContainer, bodyStyle]}>
|
||||
<View style={styles.divider} />
|
||||
|
||||
<AppText variant="bodySmall" color={colors.textSecondary} semiBold style={styles.bidLabel as any}>Set Your Bid</AppText>
|
||||
|
||||
<View style={styles.stepperContainer}>
|
||||
{/* DECREASE */}
|
||||
{!allBoolean.oneTimeBid && <TouchableOpacity
|
||||
onPress={onDecrease}
|
||||
disabled={!canDecrease}
|
||||
style={[
|
||||
styles.stepBtn,
|
||||
!canDecrease && styles.disabledBtn,
|
||||
]}
|
||||
>
|
||||
<AppText variant="h3" color="#FFF" bold style={styles.stepText as any}>−</AppText>
|
||||
</TouchableOpacity>}
|
||||
|
||||
{/* VALUE */}
|
||||
{!allBoolean.oneTimeBid && <View style={styles.inputBox}>
|
||||
<AppText variant="body" color={colors.textSecondary} bold style={styles.currency as any}>₹</AppText>
|
||||
<AppText variant="h4" color={colors.text} bold style={styles.bidValueText as any}>{bidValue}</AppText>
|
||||
</View>}
|
||||
{
|
||||
(allBoolean.oneTimeBid || allBoolean.rebid) && <View style={styles.inputBox2}>
|
||||
<AppText variant="body" color={colors.textSecondary} bold style={styles.currency2 as any}>₹</AppText>
|
||||
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder="0.00"
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
|
||||
{/* INCREASE */}
|
||||
{!allBoolean.oneTimeBid && <TouchableOpacity
|
||||
onPress={onIncrease}
|
||||
disabled={!canIncrease}
|
||||
style={[
|
||||
styles.stepBtn,
|
||||
!canIncrease && styles.disabledBtn,
|
||||
]}
|
||||
>
|
||||
<AppText variant="h3" color="#FFF" bold style={styles.stepText as any}>+</AppText>
|
||||
</TouchableOpacity>}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.submitBtn]}
|
||||
onPress={(allBoolean.oneTimeBid || allBoolean.rebid) ? oneTimePlaceBid : onPlaceBid}
|
||||
>
|
||||
{
|
||||
allState.isLoading ? <Loader size='small' color="#FFF" /> : <AppText variant="button" color="#FFF" bold style={styles.submitBtnText as any}>Confirm Bid</AppText>
|
||||
}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* {(allBoolean.oneTimeBid && allBoolean.rebid) ?
|
||||
<TouchableOpacity
|
||||
style={[styles.submitBtn]}
|
||||
onPress={(allBoolean.oneTimeBid || allBoolean.rebid) ? oneTimePlaceBid : onPlaceBid}
|
||||
>
|
||||
{
|
||||
allState.isLoading ? <Loader size='small' color={colors.white} /> : <Text style={styles.submitBtnText}>Confirm Bid</Text>
|
||||
}
|
||||
</TouchableOpacity> :
|
||||
(allBoolean.oneTimeBid && !item.me) ? <TouchableOpacity
|
||||
style={[styles.submitBtn]}
|
||||
onPress={(allBoolean.oneTimeBid || allBoolean.rebid) ? oneTimePlaceBid : onPlaceBid}
|
||||
>
|
||||
{
|
||||
allState.isLoading ? <Loader size='small' color={colors.white} /> : <Text style={styles.submitBtnText}>Confirm Bid</Text>
|
||||
}
|
||||
</TouchableOpacity> : null
|
||||
} */}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<AuctionItemDetail
|
||||
data={item}
|
||||
onClose={() => setShowDetail(false)}
|
||||
visible={showDetail}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReverseBiddingCard;
|
||||
|
||||
const makeStyles = (colors: any) => StyleSheet.create({
|
||||
inputBox2: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: 12,
|
||||
height: 45,
|
||||
paddingHorizontal: 12,
|
||||
width: '100%'
|
||||
},
|
||||
currency2: {
|
||||
marginRight: 4,
|
||||
} as any,
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
width: '100%'
|
||||
},
|
||||
card: {
|
||||
borderRadius: 24,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
leadingCard: {
|
||||
borderWidth: 2,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
headerRight: { flexDirection: 'column', alignItems: 'center', gap: 8 },
|
||||
productName: {} as any,
|
||||
itemIdText: { marginTop: 2 } as any,
|
||||
badge: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 8 },
|
||||
liveBadge: { backgroundColor: `${colors.error}20` },
|
||||
meBadge: { backgroundColor: `${colors.success}20` },
|
||||
badgeText: { fontSize: 10 } as any,
|
||||
liveText: { color: colors.error },
|
||||
meText: { color: colors.success },
|
||||
arrow: { fontSize: 10 },
|
||||
priceRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 20 },
|
||||
label: {
|
||||
textTransform: 'uppercase',
|
||||
} as any,
|
||||
mainPrice: {} as any,
|
||||
incrementText: {} as any,
|
||||
collapsibleContainer: { overflow: 'hidden' },
|
||||
divider: { height: 1, backgroundColor: colors.border, marginVertical: 10 },
|
||||
bidLabel: { marginBottom: 10 } as any,
|
||||
stepperContainer: { flexDirection: 'row', gap: 12, marginBottom: 20 },
|
||||
stepBtn: {
|
||||
width: 45,
|
||||
height: 45,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
disabledBtn: { backgroundColor: colors.border },
|
||||
stepText: {} as any,
|
||||
inputBox: {
|
||||
flex: 1,
|
||||
height: 45,
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
currency: { marginRight: 4 } as any,
|
||||
bidValueText: {} as any,
|
||||
submitBtn: {
|
||||
height: 50,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 15,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
submitBtnText: {} as any,
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { useSelector } from 'react-redux'
|
||||
import { apiCall } from '../../../Axios/Axios'
|
||||
import { TECHNICAL_DOCUMENT_SUBMIT, TECHNICAL_VALUE_SUBMIT } from '../../../Services/ApiCall/ApiUrls'
|
||||
import { Props } from '../Type/BidType'
|
||||
import { Loader } from '../../../Component/Index'
|
||||
import { Loader } from '../../../components'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import NavigationString from '../../../Constant/NavigationString'
|
||||
import { useTheme } from '../../../Hooks/useTheme'
|
||||
@@ -15,13 +15,16 @@ import AppText from '../../../components/AppText'
|
||||
import { Upload, FileText, X } from 'lucide-react-native'
|
||||
|
||||
const TemplateFormModal: React.FC<Props> = ({ visible, template, setAllBoolean }) => {
|
||||
if (!template) return null
|
||||
|
||||
const SelectedAuction = useSelector((state: any) => state.auction.auction)
|
||||
const Navigation: any = useNavigation()
|
||||
const { colors, spacing, typography } = useTheme()
|
||||
const styles = useMemo(() => makeStyles(colors, spacing, typography), [colors, spacing, typography])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Hooks must all run before this guard — a conditional return above any
|
||||
// hook changes the hook order between renders and crashes React.
|
||||
if (!template) return null
|
||||
|
||||
const initialValues = template?.labels?.reduce((acc: any, label: any) => {
|
||||
acc[label.labelName] = ''
|
||||
return acc
|
||||
@@ -254,7 +257,7 @@ const TemplateFormModal: React.FC<Props> = ({ visible, template, setAllBoolean }
|
||||
})
|
||||
}}
|
||||
>
|
||||
{({ handleSubmit, values, errors, touched, setFieldValue }) => (
|
||||
{({ handleSubmit, values, setFieldValue }) => (
|
||||
<>
|
||||
{template.labels.map((label: any) =>
|
||||
renderInput(
|
||||
@@ -298,7 +301,7 @@ const TemplateFormModal: React.FC<Props> = ({ visible, template, setAllBoolean }
|
||||
|
||||
export default TemplateFormModal
|
||||
|
||||
const makeStyles = (colors: any, spacing: any, typography: any) =>
|
||||
const makeStyles = (colors: any, spacing: any, _typography: any) =>
|
||||
StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { pick } from '@react-native-documents/picker';
|
||||
import Animated, { FadeInDown, FadeOutUp, Layout } from 'react-native-reanimated';
|
||||
import BouncyCheckbox from 'react-native-bouncy-checkbox';
|
||||
import { InsideHeader, Skelton } from '../../Component/Index';
|
||||
import { InsideHeader, Skeleton } from '../../components';
|
||||
import EmdComplete from './Component/EmdComplete';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@@ -34,12 +34,9 @@ import AppText from '../../components/AppText';
|
||||
import {
|
||||
FileText,
|
||||
Upload,
|
||||
CheckCircle2,
|
||||
ShieldCheck,
|
||||
Paperclip,
|
||||
ChevronRight,
|
||||
PackageCheck,
|
||||
BadgeIndianRupee,
|
||||
} from 'lucide-react-native';
|
||||
|
||||
const Emd = () => {
|
||||
@@ -350,6 +347,9 @@ const Emd = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Run once on mount; AlreadyEmdCheck is redeclared each render, so listing
|
||||
// it as a dependency would re-fire the check on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { AlreadyEmdCheck(); }, []);
|
||||
|
||||
const styles = makeStyles(colors, spacing, shadows, typography);
|
||||
@@ -410,7 +410,7 @@ const Emd = () => {
|
||||
)}
|
||||
|
||||
{(isPending || isInitialLoading) ? (
|
||||
<Skelton />
|
||||
<Skeleton />
|
||||
) : allBoolean.isEmdComplete ? (
|
||||
<EmdComplete onNext={EmdContinueHandle} />
|
||||
) : allBoolean.emdType ? (
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { View, FlatList, StyleSheet, Platform, Alert, BackHandler, Text, ToastAndroid } from 'react-native';
|
||||
import LottieView from 'lottie-react-native';
|
||||
import { InsideHeader } from '../../Component/Index';
|
||||
import colors from '../../Constant/Color';
|
||||
import { useWebSocketService } from '../../socket/Socket';
|
||||
import Variables from '../../Constant/Variable';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { apiCall } from '../../Axios/Axios';
|
||||
import { AUCTION_ONE_TIME_BID, GET_AUCTION_DETAILS, REVERSE_AUCTION_ITEM, SELECTED_AUCTION_ITEM } from '../../Services/ApiCall/ApiUrls';
|
||||
import { CommonActions, useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import CountdownTimer from '../../Component/Counter/CountdownTimer';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import AppText from '../../components/AppText';
|
||||
import ReverseBiddingCard from './Component/ReverseBiddingCard';
|
||||
import ConfirmBidModal from './Component/ConfirmBidModal';
|
||||
import GoBackModal from './Component/GoBackModal';
|
||||
|
||||
const StartReverseBidding = () => {
|
||||
const { colors } = useTheme();
|
||||
const styles = React.useMemo(() => makeStyles(colors), [colors]);
|
||||
const SelectedAuction = useSelector((state: any) => state.auction.auction);
|
||||
const loginUser = useSelector((state: any) => state.token.token)
|
||||
const Navigation: any = useNavigation()
|
||||
const [items, setItems] = useState<any>([]);
|
||||
const [bidValues, setBidValues] = useState<Record<number, number>>({});
|
||||
const [allBoolean, setAllBoolean] = useState({ showSuccess: false, showDialog: false, showBackDialog: false, bidPayload: {} as any, oneTimeBid: false, rebid: false, reload: false })
|
||||
const { subscribe, createRoom, placeBid } = useWebSocketService(
|
||||
Variables.webSocketUrl,
|
||||
() => console.log('Connected!'),
|
||||
(error) => console.log('WebSocket Error:', error),
|
||||
loginUser.token,
|
||||
);
|
||||
const getAuction = async () => {
|
||||
const selectedRes = await apiCall<any>('get', GET_AUCTION_DETAILS, {}, { id: SelectedAuction.auctionId });
|
||||
if (selectedRes.statusCode == 200 && selectedRes.data.contractorId.length) {
|
||||
setAllBoolean((prev: any) => ({ ...prev, oneTimeBid: true }))
|
||||
}
|
||||
}
|
||||
|
||||
const getOneTimeBidValue = async () => {
|
||||
try {
|
||||
const selectedRes = await apiCall<any>('get', AUCTION_ONE_TIME_BID, {}, { auctionId: SelectedAuction.auctionId });
|
||||
if (selectedRes.statusCode == 200) {
|
||||
return selectedRes.data
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const getAllAcutionItems = async () => {
|
||||
try {
|
||||
const payload = { auctionNumber: SelectedAuction.auctionNumber };
|
||||
let auctionItem: any[] = [];
|
||||
|
||||
if (SelectedAuction.auctionPattern === 'Reverse') {
|
||||
const res = await apiCall<any>('get', REVERSE_AUCTION_ITEM, {}, payload);
|
||||
if (res?.statusCode === 200) auctionItem = res.data || [];
|
||||
}
|
||||
|
||||
const selectedRes = await apiCall<any>('get', SELECTED_AUCTION_ITEM, {}, payload);
|
||||
|
||||
const uniqueArray: number[] = Array.from(
|
||||
new Set(selectedRes?.data || [])
|
||||
);
|
||||
|
||||
const filteredItems = auctionItem.filter(item =>
|
||||
uniqueArray.includes(item.id)
|
||||
);
|
||||
|
||||
if (SelectedAuction.auctionCategories == 'Live') {
|
||||
const result = await createRoom(SelectedAuction.auctionNumber);
|
||||
|
||||
subscribe(`/topic/room/${SelectedAuction.auctionNumber}`, data => {
|
||||
console.log('🔥 BID UPDATE:', data);
|
||||
});
|
||||
const bidAmountMap2 = new Map<number, { bidAmount: number; contractorId: number }>();
|
||||
|
||||
(result || []).forEach((r: any) => {
|
||||
bidAmountMap2.set(r.auctionItemId, {
|
||||
bidAmount: r.bidAmount,
|
||||
contractorId: r.contractorId,
|
||||
});
|
||||
});
|
||||
// Check One Time bid enable butoon
|
||||
if (SelectedAuction.auctionCategories != 'Live') {
|
||||
const myBid = result.find(
|
||||
(bid: any) => bid.contractorId === loginUser.contractorId
|
||||
);
|
||||
|
||||
if (myBid) {
|
||||
setAllBoolean((prev: any) => ({ ...prev, oneTimeBid: true }))
|
||||
}
|
||||
}
|
||||
|
||||
const bidAmountMap = new Map<number, number>();
|
||||
|
||||
(result || []).forEach((r: any) => {
|
||||
bidAmountMap.set(r.auctionItemId, r.bidAmount);
|
||||
});
|
||||
|
||||
const updatedFilteredItems = filteredItems.map(item => {
|
||||
const bidData = bidAmountMap2.get(item.id);
|
||||
return {
|
||||
...item,
|
||||
bidAmount: bidData?.bidAmount ?? item.auctionStartValue,
|
||||
bidAvilable: bidData?.bidAmount ?? 0,
|
||||
me: bidData?.contractorId == loginUser.contractorId ? true : false
|
||||
};
|
||||
});
|
||||
|
||||
const initialBids: Record<number, number> = {};
|
||||
updatedFilteredItems.forEach(item => {
|
||||
initialBids[item.id] = item.bidAmount;
|
||||
});
|
||||
setBidValues(initialBids);
|
||||
setItems(updatedFilteredItems);
|
||||
} else {
|
||||
let result = []
|
||||
if (SelectedAuction.rebidAllowed) {
|
||||
setAllBoolean((prev: any) => ({ ...prev, rebid: true }))
|
||||
}
|
||||
|
||||
if (SelectedAuction.auctionCategories != 'Live') {
|
||||
result = await getOneTimeBidValue()
|
||||
}
|
||||
const bidMap = new Map<number, any>();
|
||||
|
||||
result.forEach((bid: any) => {
|
||||
bidMap.set(bid.auctionItemId, bid);
|
||||
});
|
||||
const mergedData = auctionItem.map((item: any) => {
|
||||
const bid = bidMap.get(item.id);
|
||||
|
||||
if (!bid) {
|
||||
return {
|
||||
...item,
|
||||
bidAmount: item.auctionStartValue,
|
||||
contractorId: item.contractorId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
bidAmount: bid.bidAmount,
|
||||
bidId: bid.bidId,
|
||||
contractorId: bid.contractorId,
|
||||
contractorName: bid.contractorName,
|
||||
me: bid.contractorId === loginUser.contractorId,
|
||||
bidTime: bid.bidTime,
|
||||
};
|
||||
});
|
||||
const initialBids: Record<number, number> = {};
|
||||
mergedData.forEach(item => {
|
||||
initialBids[item.id] = item.bidAmount;
|
||||
});
|
||||
setBidValues(initialBids);
|
||||
setItems(mergedData)
|
||||
setBidValues(initialBids);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching auction items:', error);
|
||||
}
|
||||
};
|
||||
|
||||
subscribe(`/topic/room/${SelectedAuction.auctionNumber}`, async (message) => {
|
||||
|
||||
const result = message[message.length - 1]
|
||||
|
||||
const updatedBidItem = (prev: any[]) =>
|
||||
prev.map(item =>
|
||||
item.id === result.auctionItemId
|
||||
? { ...item, bidAmount: result.bidAmount, me: result.contractorId == loginUser.contractorId ? true : false }
|
||||
: item
|
||||
);
|
||||
|
||||
setItems(updatedBidItem);
|
||||
|
||||
setBidValues(prev => ({
|
||||
...prev,
|
||||
[result.auctionItemId]: result.bidAmount,
|
||||
}));
|
||||
try {
|
||||
|
||||
} catch (err) {
|
||||
console.log('JSON parse error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (SelectedAuction.auctionCategories == 'One-time') {
|
||||
getAuction()
|
||||
}
|
||||
getAllAcutionItems();
|
||||
subscribe(`/topic/room/${SelectedAuction.auctionNumber}`, (data) => { });
|
||||
}, [allBoolean.reload]);
|
||||
|
||||
useEffect(() => {
|
||||
const initial: Record<number, number> = {};
|
||||
items.forEach((item: any) => {
|
||||
initial[item.id] = item.bidAmount + item.bidVariationValue;
|
||||
});
|
||||
setBidValues(initial);
|
||||
}, []);
|
||||
|
||||
const handleUpdate = (id: number, val: number) => {
|
||||
setBidValues(prev => ({ ...prev, [id]: prev[id] + val }));
|
||||
};
|
||||
|
||||
const submitBid = async (item: any) => {
|
||||
const payload = {
|
||||
itemId: item.id,
|
||||
bidAmount: bidValues[item.id],
|
||||
auctionId: SelectedAuction.auctionId,
|
||||
contractorId: loginUser.contractorId,
|
||||
fullName: loginUser.fullName,
|
||||
roomId: SelectedAuction.auctionNumber,
|
||||
};
|
||||
setAllBoolean((prev: any) => ({ ...prev, bidPayload: payload, showDialog: true }));
|
||||
};
|
||||
|
||||
const placeBidSubmit = async (data: any) => {
|
||||
setAllBoolean((prev: any) => ({ ...prev, showDialog: false }));
|
||||
try {
|
||||
const result: any = await placeBid(data);
|
||||
|
||||
if (result?.status && result.data) {
|
||||
const updatedBidItem = (prev: any[]) =>
|
||||
prev.map(item =>
|
||||
item.id === result.data.auctionItemId
|
||||
? { ...item, bidAmount: result.data.bidAmount, me: result.data?.contractorId == loginUser.contractorId ? true : false }
|
||||
: item
|
||||
);
|
||||
|
||||
getAllAcutionItems()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error placing bid:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const onBackPress = () => {
|
||||
setAllBoolean((prev: any) => ({ ...prev, showBackDialog: true }));
|
||||
return true; // block default back action
|
||||
};
|
||||
|
||||
const subscription = BackHandler.addEventListener(
|
||||
'hardwareBackPress',
|
||||
onBackPress
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [Navigation])
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<InsideHeader title="Bidding Panel" showArrow />
|
||||
<View style={styles.container2}>
|
||||
<AppText variant="h3" color={colors.text} bold style={styles.title as any}>
|
||||
{SelectedAuction.deptName}
|
||||
</AppText>
|
||||
|
||||
<View style={styles.timerWrapper}>
|
||||
<CountdownTimer
|
||||
endDate={SelectedAuction.keyDates.auctionBidding.endDateTime}
|
||||
myStyle={{ fontSize: 30 }}
|
||||
size={30}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={item => item.id.toString()}
|
||||
renderItem={({ item, index }) => (
|
||||
<ReverseBiddingCard
|
||||
item={{ ...item, index }}
|
||||
bidValue={bidValues[item.id] || 0}
|
||||
onIncrease={() => handleUpdate(item.id, item.emdAmount)}
|
||||
onDecrease={() => handleUpdate(item.id, -item.emdAmount)}
|
||||
onPlaceBid={() => { submitBid(item) }}
|
||||
SelectedAuction={SelectedAuction}
|
||||
allBoolean={allBoolean}
|
||||
setAllBoolean={setAllBoolean}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={{ padding: 16 }}
|
||||
/>
|
||||
|
||||
{allBoolean.showSuccess && (
|
||||
<View style={styles.overlay}>
|
||||
<LottieView
|
||||
source={require('../../lottie/Success.json')}
|
||||
autoPlay
|
||||
loop={false}
|
||||
style={{ width: 200, height: 200 }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ConfirmBidModal
|
||||
visible={allBoolean.showDialog}
|
||||
onClose={() => setAllBoolean(prev => ({ ...prev, showDialog: false }))}
|
||||
onConfirm={() => placeBidSubmit(allBoolean.bidPayload)}
|
||||
bidAmount={allBoolean.bidPayload?.bidAmount}
|
||||
/>
|
||||
|
||||
<GoBackModal
|
||||
visible={allBoolean.showBackDialog}
|
||||
onClose={() => setAllBoolean(prev => ({ ...prev, showBackDialog: false }))}
|
||||
onConfirm={() => {
|
||||
setAllBoolean(prev => ({ ...prev, showBackDialog: false }));
|
||||
Navigation.dispatch(
|
||||
CommonActions.reset({
|
||||
index: 0,
|
||||
routes: [{ name: NavigationString.Home }],
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default StartReverseBidding;
|
||||
|
||||
const makeStyles = (colors: any) => StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
container2: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
title: {
|
||||
marginBottom: 6,
|
||||
} as any,
|
||||
timerWrapper: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: colors.overlay, justifyContent: 'center', alignItems: 'center', zIndex: 99 },
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { getBiddingConfig } from '../biddingPattern';
|
||||
import { FORWARD_AUCTION_ITEM, REVERSE_AUCTION_ITEM } from '../../../Services/ApiCall/ApiUrls';
|
||||
|
||||
describe('getBiddingConfig', () => {
|
||||
it('returns the reverse config only for exactly "Reverse"', () => {
|
||||
expect(getBiddingConfig('Reverse').pattern).toBe('Reverse');
|
||||
// Forward is the safe default for anything else (Forward, undefined, junk).
|
||||
expect(getBiddingConfig('Forward').pattern).toBe('Forward');
|
||||
expect(getBiddingConfig(undefined).pattern).toBe('Forward');
|
||||
expect(getBiddingConfig('anything').pattern).toBe('Forward');
|
||||
});
|
||||
});
|
||||
|
||||
describe('forward pattern', () => {
|
||||
const cfg = getBiddingConfig('Forward');
|
||||
const item = { auctionStartValueFigure: 100, bidVariationValue: 10, bidAmount: 150 };
|
||||
|
||||
it('reads the forward start-value and step fields', () => {
|
||||
expect(cfg.itemsUrl).toBe(FORWARD_AUCTION_ITEM);
|
||||
expect(cfg.startValue(item)).toBe(100);
|
||||
expect(cfg.step(item)).toBe(10);
|
||||
expect(cfg.stepLabel).toBe('Increment');
|
||||
expect(cfg.stepPrefix).toBe('+');
|
||||
expect(cfg.inlineManualInput).toBe(false);
|
||||
});
|
||||
|
||||
it('allows unbounded increases but blocks decreasing below the current bid', () => {
|
||||
expect(cfg.canIncrease(999999, item)).toBe(true);
|
||||
expect(cfg.canDecrease(160, item)).toBe(true); // 160 > 150
|
||||
expect(cfg.canDecrease(150, item)).toBe(false); // not strictly greater
|
||||
expect(cfg.canDecrease(140, item)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a manual bid below the current bid', () => {
|
||||
expect(cfg.validateManualBid(149, item)).toBe('Enter a value greater than the current bid value');
|
||||
expect(cfg.validateManualBid(150, item)).toBeNull();
|
||||
expect(cfg.validateManualBid(200, item)).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts any typed input (forward is unbounded)', () => {
|
||||
expect(cfg.acceptManualInput(5, item)).toBe(true);
|
||||
});
|
||||
|
||||
it('shows body/submit only for a fresh (un-bid) item when not rebidding', () => {
|
||||
const fresh = { contractorId: undefined };
|
||||
const alreadyBid = { contractorId: 7 };
|
||||
expect(cfg.isBodyFull({ oneTimeBid: false, rebid: false }, fresh)).toBe(true);
|
||||
expect(cfg.showSubmit({ oneTimeBid: false, rebid: false }, fresh)).toBe(true);
|
||||
expect(cfg.showSubmit({ oneTimeBid: false, rebid: false }, alreadyBid)).toBe(false);
|
||||
expect(cfg.showSubmit({ oneTimeBid: false, rebid: true }, fresh)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reverse pattern', () => {
|
||||
const cfg = getBiddingConfig('Reverse');
|
||||
const item = { auctionStartValue: 1000, emdAmount: 50, bidAmount: 800 };
|
||||
|
||||
it('reads the reverse start-value and step fields', () => {
|
||||
expect(cfg.itemsUrl).toBe(REVERSE_AUCTION_ITEM);
|
||||
expect(cfg.startValue(item)).toBe(1000);
|
||||
expect(cfg.step(item)).toBe(50);
|
||||
expect(cfg.stepLabel).toBe('Decrement');
|
||||
expect(cfg.stepPrefix).toBe('-');
|
||||
expect(cfg.inlineManualInput).toBe(true);
|
||||
});
|
||||
|
||||
it('caps increases at the current bid and decreases at the EMD floor', () => {
|
||||
expect(cfg.canIncrease(700, item)).toBe(true); // 700 < 800
|
||||
expect(cfg.canIncrease(800, item)).toBe(false);
|
||||
expect(cfg.canDecrease(51, item)).toBe(true); // 51 > 50
|
||||
expect(cfg.canDecrease(50, item)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts typed input only within [0, auctionStartValue]', () => {
|
||||
expect(cfg.acceptManualInput(0, item)).toBe(true);
|
||||
expect(cfg.acceptManualInput(1000, item)).toBe(true);
|
||||
expect(cfg.acceptManualInput(1001, item)).toBe(false);
|
||||
expect(cfg.acceptManualInput(-1, item)).toBe(false);
|
||||
});
|
||||
|
||||
it('always shows body + submit (reverse has no fresh-item gate)', () => {
|
||||
expect(cfg.isBodyFull({ oneTimeBid: true, rebid: true }, item)).toBe(true);
|
||||
expect(cfg.showSubmit({ oneTimeBid: false, rebid: false }, item)).toBe(true);
|
||||
expect(cfg.validateManualBid(123, item)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Pattern configuration for the unified auction bidding screen/card.
|
||||
*
|
||||
* Forward and Reverse bidding were 90%-duplicated screens that drifted apart.
|
||||
* Every behavioral difference between them is captured HERE, explicitly, so
|
||||
* the screen and card contain zero `if (pattern === ...)` logic and a bidding
|
||||
* fix can never again land in one pattern but not the other.
|
||||
*/
|
||||
import { FORWARD_AUCTION_ITEM, REVERSE_AUCTION_ITEM } from '../../Services/ApiCall/ApiUrls';
|
||||
|
||||
export type AuctionPattern = 'Forward' | 'Reverse';
|
||||
|
||||
export type BiddingFlags = {
|
||||
oneTimeBid: boolean;
|
||||
rebid: boolean;
|
||||
};
|
||||
|
||||
export type BiddingPatternConfig = {
|
||||
pattern: AuctionPattern;
|
||||
/** Endpoint returning this pattern's auction items. */
|
||||
itemsUrl: string;
|
||||
/** Opening amount — the backend uses a different field per pattern. */
|
||||
startValue: (item: any) => number;
|
||||
/** Stepper step size. */
|
||||
step: (item: any) => number;
|
||||
stepLabel: 'Increment' | 'Decrement';
|
||||
stepPrefix: '+' | '-';
|
||||
/** Forward bids may rise without bound; reverse bids may not exceed the current bid. */
|
||||
canIncrease: (bidValue: number, item: any) => boolean;
|
||||
/** Forward bids may not drop below the current bid; reverse not below the EMD amount. */
|
||||
canDecrease: (bidValue: number, item: any) => boolean;
|
||||
/** Gate applied while typing a manual (one-time/rebid) amount. */
|
||||
acceptManualInput: (value: number, item: any) => boolean;
|
||||
/** Submit-time validation for manual bids; returns an error message or null. */
|
||||
validateManualBid: (value: number, item: any) => string | null;
|
||||
/** Reverse renders the manual input inline within the stepper row. */
|
||||
inlineManualInput: boolean;
|
||||
/** Whether the collapsible body opens at full height. */
|
||||
isBodyFull: (flags: BiddingFlags, item: any) => boolean;
|
||||
/** Whether the Confirm Bid button is rendered. */
|
||||
showSubmit: (flags: BiddingFlags, item: any) => boolean;
|
||||
};
|
||||
|
||||
const forward: BiddingPatternConfig = {
|
||||
pattern: 'Forward',
|
||||
itemsUrl: FORWARD_AUCTION_ITEM,
|
||||
startValue: item => item?.auctionStartValueFigure,
|
||||
step: item => item?.bidVariationValue,
|
||||
stepLabel: 'Increment',
|
||||
stepPrefix: '+',
|
||||
canIncrease: () => true,
|
||||
canDecrease: (bidValue, item) => bidValue > item.bidAmount,
|
||||
acceptManualInput: () => true,
|
||||
validateManualBid: (value, item) =>
|
||||
value < Number(item.bidAmount) ? 'Enter a value greater than the current bid value' : null,
|
||||
inlineManualInput: false,
|
||||
isBodyFull: (flags, item) => !flags.rebid && item.contractorId == undefined,
|
||||
showSubmit: (flags, item) => !flags.rebid && item.contractorId == undefined,
|
||||
};
|
||||
|
||||
const reverse: BiddingPatternConfig = {
|
||||
pattern: 'Reverse',
|
||||
itemsUrl: REVERSE_AUCTION_ITEM,
|
||||
startValue: item => item?.auctionStartValue,
|
||||
step: item => item?.emdAmount,
|
||||
stepLabel: 'Decrement',
|
||||
stepPrefix: '-',
|
||||
canIncrease: (bidValue, item) => bidValue < item.bidAmount,
|
||||
canDecrease: (bidValue, item) => bidValue > item.emdAmount,
|
||||
acceptManualInput: (value, item) => value >= 0 && value <= Number(item?.auctionStartValue),
|
||||
validateManualBid: () => null,
|
||||
inlineManualInput: true,
|
||||
isBodyFull: () => true,
|
||||
showSubmit: () => true,
|
||||
};
|
||||
|
||||
export const getBiddingConfig = (pattern: string | undefined): BiddingPatternConfig =>
|
||||
pattern === 'Reverse' ? reverse : forward;
|
||||
@@ -24,16 +24,20 @@ const Home = () => {
|
||||
const userData = useSelector(getToken);
|
||||
|
||||
// Hooks for data
|
||||
const { data: dashboardData, refetch: refetchDashboard, isPending: isLoadingDashboard } = useDashboard();
|
||||
const { data: tenderData, refetch: refetchTender, isPending: isLoadingTender } = useTenderDashboard();
|
||||
const { data: dashboardData, refetch: refetchDashboard, isPending: isLoadingDashboard, isFetching: isFetchingDashboard } = useDashboard();
|
||||
const { data: tenderData, refetch: refetchTender, isPending: isLoadingTender, isFetching: isFetchingTender } = useTenderDashboard();
|
||||
|
||||
// Reload the dashboard data every time the Home tab gains focus.
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
refetchDashboard();
|
||||
refetchTender();
|
||||
}, [])
|
||||
}, [refetchDashboard, refetchTender])
|
||||
);
|
||||
|
||||
// True while a background refresh (on tab focus) is in flight and data is already shown.
|
||||
const isRefreshing = (isFetchingDashboard || isFetchingTender) && !(isLoadingDashboard || isLoadingTender);
|
||||
|
||||
useEffect(() => {
|
||||
if (dashboardData) {
|
||||
setAuctionStats(dashboardData.data || {});
|
||||
@@ -84,6 +88,9 @@ const Home = () => {
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
{isRefreshing && (
|
||||
<ActivityIndicator size="small" color={colors.primary} style={{ marginRight: 12 }} />
|
||||
)}
|
||||
<TouchableOpacity onPress={() => setIsMenuVisible(true)}>
|
||||
<Menu size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { WebView } from "react-native-webview";
|
||||
import { CommonActions } from "@react-navigation/native";
|
||||
import colors from "../../Constant/Color";
|
||||
import { BASE_URL } from "../../Services/ApiCall/ApiUrls";
|
||||
|
||||
const SUCCESS_DELAY_MS = 5000; // 5 seconds
|
||||
|
||||
@@ -25,6 +24,8 @@ export const KycWebViewScreen = ({ route, navigation }: any) => {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollTimerRef.current) {
|
||||
// Intentionally reads the ref at cleanup; the timer id stored on the ref is exactly what must be cleared.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
clearInterval(pollTimerRef.current);
|
||||
}
|
||||
if (successTimeoutRef.current) {
|
||||
@@ -66,16 +67,16 @@ export const KycWebViewScreen = ({ route, navigation }: any) => {
|
||||
}, SUCCESS_DELAY_MS);
|
||||
};
|
||||
|
||||
const handleLoadStart = (event: any) => {
|
||||
const handleLoadStart = (_event: any) => {
|
||||
setIsLoading(true);
|
||||
setLoadError(false);
|
||||
};
|
||||
|
||||
const handleLoadEnd = (event: any) => {
|
||||
const handleLoadEnd = (_event: any) => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleError = (event: any) => {
|
||||
const handleError = (_event: any) => {
|
||||
setIsLoading(false);
|
||||
setLoadError(true);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Modal,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
TouchableWithoutFeedback,
|
||||
|
||||
+20
-134
@@ -1,25 +1,25 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, KeyboardAvoidingView, Platform, ScrollView, TouchableOpacity, Dimensions, StatusBar, TextInput, Image, ActivityIndicator, Keyboard } from 'react-native';
|
||||
import { View, Text, StyleSheet, KeyboardAvoidingView, Platform, ScrollView, TouchableOpacity, Dimensions, TextInput, Image, ActivityIndicator, Keyboard } from 'react-native';
|
||||
import { Formik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useNavigation, CommonActions } from '@react-navigation/native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { Mail, Lock, Eye, EyeOff, ArrowRight } from 'lucide-react-native';
|
||||
import Animated, { FadeInDown, FadeInUp, FadeInRight, FadeOutLeft, useSharedValue, useAnimatedStyle, withRepeat, withTiming, Easing, interpolate, interpolateColor } from 'react-native-reanimated';
|
||||
import Animated, { FadeInDown, FadeInUp, FadeInRight, FadeOutLeft, useSharedValue, useAnimatedStyle, withTiming, interpolate, interpolateColor } from 'react-native-reanimated';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import { BlurView } from '@react-native-community/blur';
|
||||
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import { useCreate } from '../../Hooks/useCreate';
|
||||
import { setToken } from '../../Redux/Slices/Token';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import ImagePath from '../../Constant/ImagePath';
|
||||
import { TENDER_LOGIN } from '../../Services/ApiCall/ApiUrls';
|
||||
import { showErrorAlert, showToast } from '../../Constant/ShowDailog';
|
||||
import AppBackground from '../../components/AppBackground';
|
||||
import FontsFamily from '../../Constant/FontsFamily';
|
||||
import { useRagistrationState } from '../../Services/ApiCall/Hooks';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useLogin, useRagistrationState } from '../../Services/ApiCall/Hooks';
|
||||
import { performLogout } from '../../Services/Auth/session';
|
||||
import { applyRouteDecision, decideRegistrationRoute } from '../../Services/Auth/registrationRouting';
|
||||
import { logger } from '../../core/observability/logger';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
@@ -36,7 +36,7 @@ const CAROUSEL_TEXTS = [
|
||||
];
|
||||
|
||||
const Login = () => {
|
||||
const { colors, spacing, shadows, isDarkMode } = useTheme();
|
||||
const { colors, isDarkMode } = useTheme();
|
||||
const navigation = useNavigation<any>();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -65,155 +65,39 @@ const Login = () => {
|
||||
bgTransition.value = withTiming(1, { duration: 1200 });
|
||||
};
|
||||
|
||||
// Animation Shared Values for Background Blobs
|
||||
const blob1Pos = useSharedValue(0);
|
||||
const blob2Pos = useSharedValue(0);
|
||||
const blob3Pos = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Animate Blobs
|
||||
blob1Pos.value = withRepeat(
|
||||
withTiming(1, { duration: 8000, easing: Easing.inOut(Easing.sin) }),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
blob2Pos.value = withRepeat(
|
||||
withTiming(1, { duration: 10000, easing: Easing.inOut(Easing.quad) }),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
blob3Pos.value = withRepeat(
|
||||
withTiming(1, { duration: 12000, easing: Easing.inOut(Easing.cubic) }),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
}, []);
|
||||
|
||||
const blob1Style = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: interpolate(blob1Pos.value, [0, 1], [0, 50]) },
|
||||
{ translateY: interpolate(blob1Pos.value, [0, 1], [0, 30]) },
|
||||
{ scale: interpolate(blob1Pos.value, [0, 1], [1, 1.2]) },
|
||||
],
|
||||
}));
|
||||
|
||||
const blob2Style = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: interpolate(blob2Pos.value, [0, 1], [0, -40]) },
|
||||
{ translateY: interpolate(blob2Pos.value, [0, 1], [0, -50]) },
|
||||
{ scale: interpolate(blob2Pos.value, [0, 1], [1, 1.1]) },
|
||||
],
|
||||
}));
|
||||
|
||||
const blob3Style = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: interpolate(blob3Pos.value, [0, 1], [0, 30]) },
|
||||
{ translateY: interpolate(blob3Pos.value, [0, 1], [0, 40]) },
|
||||
{ scale: interpolate(blob3Pos.value, [0, 1], [1, 1.3]) },
|
||||
],
|
||||
}));
|
||||
|
||||
const animatedHeaderStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: headerTranslateY.value }],
|
||||
}));
|
||||
|
||||
const animatedFormStyle = useAnimatedStyle(() => ({
|
||||
opacity: formOpacity.value,
|
||||
transform: [{ translateY: interpolate(formOpacity.value, [0, 1], [20, 0]) }],
|
||||
}));
|
||||
|
||||
|
||||
// Primary brand color
|
||||
const PRIMARY = '#e8a44c';
|
||||
const ACCENT_1 = '#fbbf24'; // Amber-400
|
||||
const ACCENT_2 = '#f59e0b'; // Amber-500
|
||||
|
||||
// Mutations
|
||||
const loginMutation = useCreate<any, any>(TENDER_LOGIN, {
|
||||
onSuccess: (res) => handleSuccess(res),
|
||||
onError: (err) => showErrorAlert(err.message || 'Login failed')
|
||||
});
|
||||
const routeByOrgType = useCallback((params: { otherId: any; orgType: any }) => {
|
||||
const orgType = String(params.orgType ?? '');
|
||||
if (orgType === '4' || orgType === '5' || orgType === '6') {
|
||||
navigation.replace(NavigationString.StageThreeScreen, params);
|
||||
return;
|
||||
}
|
||||
|
||||
if (orgType === '7') {
|
||||
navigation.replace(NavigationString.Partnership, params);
|
||||
return;
|
||||
}
|
||||
|
||||
navigation.replace('Dashboard');
|
||||
}, [navigation]);
|
||||
|
||||
const loginMutation = useLogin();
|
||||
const handleGetData = useCallback(async () => {
|
||||
try {
|
||||
const res = await registrationStateMutate();
|
||||
console.log("splash", res);
|
||||
if (res?.status === false) {
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [{ name: NavigationString.Login }],
|
||||
});
|
||||
const decision = decideRegistrationRoute(res);
|
||||
if (decision) {
|
||||
applyRouteDecision(navigation, decision);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res?.data?.message === 'No Response From Server' && res?.data?.success === false) {
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [{ name: NavigationString.Login }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (res?.statusCode === 404 || res?.statusCode === 401) {
|
||||
navigation.replace('Registration');
|
||||
return;
|
||||
}
|
||||
|
||||
if (res?.statusCode === 200 && res?.data) {
|
||||
const { registrationStatus, contOtherId, contOrganization } = res.data;
|
||||
const params = { otherId: contOtherId, orgType: contOrganization };
|
||||
switch (registrationStatus) {
|
||||
case null:
|
||||
case undefined:
|
||||
navigation.replace(NavigationString.Registration);
|
||||
break;
|
||||
case 'Pending':
|
||||
routeByOrgType(params);
|
||||
break;
|
||||
case 'Complete':
|
||||
case 'Completed':
|
||||
navigation.replace(NavigationString.Home);
|
||||
break;
|
||||
default:
|
||||
navigation.replace(NavigationString.Login);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error('API error:', res);
|
||||
logger.warn('auth', 'unexpected registration-state response', { statusCode: res?.statusCode });
|
||||
showToast(res?.message || 'Something went wrong');
|
||||
navigation.replace('Registration');
|
||||
} catch (error: any) {
|
||||
console.error('Registration status error:', error);
|
||||
logger.error('auth', 'registration status resolution failed', { error: String(error) });
|
||||
showToast(error?.message || 'Something went wrong');
|
||||
|
||||
if (error?.response?.status === 404) {
|
||||
navigation.replace('Registration');
|
||||
} else {
|
||||
await AsyncStorage.removeItem('persist:root');
|
||||
performLogout();
|
||||
navigation.replace(NavigationString.Login);
|
||||
}
|
||||
}
|
||||
}, [navigation, registrationStateMutate, routeByOrgType]);
|
||||
}, [navigation, registrationStateMutate]);
|
||||
const handleSuccess = (res: any) => {
|
||||
dispatch(setToken({ ...res.data, token: res.token, userType: 'tender' }));
|
||||
console.log('handleSuccess', res)
|
||||
console.log('==================== 🔑 AUTH TOKEN ====================');
|
||||
console.log('TOKEN →', res?.token);
|
||||
console.log('=======================================================');
|
||||
if (res.statusCode === 200) {
|
||||
handleGetData()
|
||||
} else {
|
||||
@@ -225,8 +109,10 @@ const Login = () => {
|
||||
if (isLoading) return; // guard against double submit
|
||||
Keyboard.dismiss(); // smooth the navigation transition (no keyboard mid-replace)
|
||||
const payload = { emailAdd: values.email, password: values.password, device: "mobileApp" };
|
||||
console.log('payload------------', payload)
|
||||
loginMutation.mutate(payload);
|
||||
loginMutation.mutate(payload, {
|
||||
onSuccess: res => handleSuccess(res),
|
||||
onError: (err: any) => showErrorAlert(err?.message || 'Login failed'),
|
||||
});
|
||||
};
|
||||
|
||||
// Covers the WHOLE sign-in flow: login request + registration-state resolve
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import axios from 'axios';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { Eye, EyeOff } from 'lucide-react-native';
|
||||
import { BASE_URL } from '../../Services/ApiCall/ApiUrls';
|
||||
import { request } from '../../core/http/client';
|
||||
import colors from '../../Constant/Color';
|
||||
|
||||
interface ResetPasswordProps {
|
||||
@@ -26,7 +26,7 @@ interface ResetPasswordProps {
|
||||
}
|
||||
|
||||
const ResetPassword: React.FC<ResetPasswordProps> = ({ route, navigation }) => {
|
||||
const [email, setEmail] = useState<string>(route?.params?.email || '');
|
||||
const [email] = useState<string>(route?.params?.email || '');
|
||||
const [otp, setOtp] = useState<string[]>(['', '', '', '', '', '']);
|
||||
const [newPassword, setNewPassword] = useState<string>('');
|
||||
const [confirmPassword, setConfirmPassword] = useState<string>('');
|
||||
@@ -45,7 +45,7 @@ const ResetPassword: React.FC<ResetPasswordProps> = ({ route, navigation }) => {
|
||||
const otpInputs = useRef<Array<TextInput | null>>([]);
|
||||
const newPasswordRef = useRef<TextInput>(null);
|
||||
const confirmPasswordRef = useRef<TextInput>(null);
|
||||
const resendTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const resendTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
checkResendCooldown();
|
||||
@@ -107,6 +107,8 @@ const ResetPassword: React.FC<ResetPasswordProps> = ({ route, navigation }) => {
|
||||
if (fullOtp.length === 6 && !isOtpVerified) {
|
||||
handleSubmitOtp();
|
||||
}
|
||||
// Intentionally runs only on otp changes; other deps are stable or recreated each render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [otp]);
|
||||
|
||||
const handleOtpChange = (value: string, index: number) => {
|
||||
@@ -145,20 +147,13 @@ const ResetPassword: React.FC<ResetPasswordProps> = ({ route, navigation }) => {
|
||||
setOtpError('');
|
||||
|
||||
try {
|
||||
const res = await axios.post(
|
||||
`${BASE_URL}eproc/verify-otp?email=${encodeURIComponent(email)}&otp=${fullOtp}`,
|
||||
null,
|
||||
{
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
const res = await request<any>({
|
||||
method: 'post',
|
||||
url: `${BASE_URL}eproc/verify-otp`,
|
||||
params: { email, otp: fullOtp },
|
||||
});
|
||||
|
||||
console.log('API Response OTP verification:', res.data);
|
||||
|
||||
if (res.data?.statusCode === 200) {
|
||||
if (res?.statusCode === 200) {
|
||||
ToastAndroid.show('OTP verified successfully!', ToastAndroid.SHORT);
|
||||
setIsOtpVerified(true);
|
||||
|
||||
@@ -166,16 +161,11 @@ const ResetPassword: React.FC<ResetPasswordProps> = ({ route, navigation }) => {
|
||||
newPasswordRef.current?.focus();
|
||||
}, 100);
|
||||
} else {
|
||||
throw new Error(res.data?.message || 'OTP verification failed');
|
||||
throw new Error(res?.message || 'OTP verification failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Verification Error:', error);
|
||||
|
||||
const errorMessage = axios.isAxiosError(error)
|
||||
? error.response?.data?.message || error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'OTP verification failed';
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'OTP verification failed';
|
||||
|
||||
setOtpError(errorMessage);
|
||||
ToastAndroid.show(errorMessage, ToastAndroid.SHORT);
|
||||
@@ -216,20 +206,21 @@ const ResetPassword: React.FC<ResetPasswordProps> = ({ route, navigation }) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await axios.post(
|
||||
`${BASE_URL}eproc/reset-password?email=${encodeURIComponent(email)}&otp=${otp.join('')}&newPassword=${encodeURIComponent(newPassword)}&confirmPassword=${encodeURIComponent(confirmPassword)}`,
|
||||
null,
|
||||
{
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
// TODO(backend): credentials belong in the request body, not the
|
||||
// query string (they land in server/proxy logs) — needs an
|
||||
// endpoint contract change.
|
||||
const res = await request<any>({
|
||||
method: 'post',
|
||||
url: `${BASE_URL}eproc/reset-password`,
|
||||
params: {
|
||||
email,
|
||||
otp: otp.join(''),
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('API Response password reset:', res.data);
|
||||
|
||||
if (res.data?.statusCode === 200) {
|
||||
if (res?.statusCode === 200) {
|
||||
Alert.alert(
|
||||
'Success',
|
||||
'Your password has been reset successfully.',
|
||||
@@ -240,14 +231,11 @@ const ResetPassword: React.FC<ResetPasswordProps> = ({ route, navigation }) => {
|
||||
}]
|
||||
);
|
||||
} else {
|
||||
throw new Error(res.data?.message || 'Password reset failed');
|
||||
throw new Error(res?.message || 'Password reset failed');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = axios.isAxiosError(err)
|
||||
? err.response?.data?.message || err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to reset password. Please try again.';
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to reset password. Please try again.';
|
||||
|
||||
setError(errorMessage);
|
||||
ToastAndroid.show(errorMessage, ToastAndroid.SHORT);
|
||||
@@ -262,31 +250,24 @@ const ResetPassword: React.FC<ResetPasswordProps> = ({ route, navigation }) => {
|
||||
setResendLoading(true);
|
||||
|
||||
try {
|
||||
const res = await axios.post(
|
||||
`${BASE_URL}eproc/send-otp-forgot-password?email=${encodeURIComponent(email)}`,
|
||||
null,
|
||||
{
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
}
|
||||
}
|
||||
);
|
||||
const res = await request<any>({
|
||||
method: 'post',
|
||||
url: `${BASE_URL}eproc/send-otp-forgot-password`,
|
||||
params: { email },
|
||||
});
|
||||
|
||||
if (res.data?.statusCode === 200) {
|
||||
if (res?.statusCode === 200) {
|
||||
ToastAndroid.show('OTP sent successfully', ToastAndroid.SHORT);
|
||||
setOtp(['', '', '', '', '', '']);
|
||||
setIsOtpVerified(false);
|
||||
setOtpError('');
|
||||
startResendCooldown();
|
||||
} else {
|
||||
throw new Error(res.data?.message || 'Failed to resend OTP');
|
||||
throw new Error(res?.message || 'Failed to resend OTP');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = axios.isAxiosError(error)
|
||||
? error.response?.data?.message || error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to resend OTP';
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to resend OTP';
|
||||
|
||||
ToastAndroid.show(errorMessage, ToastAndroid.SHORT);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Save, X } from 'lucide-react-native';
|
||||
import { useUpdateUserProfile } from '../../Services/ApiCall/Hooks';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import AppText from '../../components/AppText';
|
||||
import { ContractorData, UpdateProfilePayload } from './profileTypes';
|
||||
|
||||
interface EditProfileModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
data: ContractorData;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
/** Personal-information edit form; owns its validation and update mutation. */
|
||||
const EditProfileModal: React.FC<EditProfileModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
data,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => createStyles(colors), [colors]);
|
||||
const [formData, setFormData] = useState<UpdateProfilePayload>({
|
||||
firstName: data.firstName || '',
|
||||
middleName: data.middleName || '',
|
||||
lastName: data.lastName || '',
|
||||
mobileNo: data.mobileNo || '',
|
||||
emailId: data.emailId || '',
|
||||
fullname: data.fullName || '',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Partial<UpdateProfilePayload>>({});
|
||||
|
||||
const updateProfileMutation = useUpdateUserProfile();
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setFormData({
|
||||
firstName: data.firstName || '',
|
||||
middleName: data.middleName || '',
|
||||
lastName: data.lastName || '',
|
||||
mobileNo: data.mobileNo || '',
|
||||
emailId: data.emailId || '',
|
||||
fullname: data.fullName || '',
|
||||
});
|
||||
setErrors({});
|
||||
}
|
||||
}, [visible, data]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<UpdateProfilePayload> = {};
|
||||
|
||||
if (!formData.firstName.trim()) {
|
||||
newErrors.firstName = 'First name is required';
|
||||
}
|
||||
|
||||
if (!formData.lastName.trim()) {
|
||||
newErrors.lastName = 'Last name is required';
|
||||
}
|
||||
|
||||
if (!formData.emailId.trim()) {
|
||||
newErrors.emailId = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.emailId)) {
|
||||
newErrors.emailId = 'Invalid email format';
|
||||
}
|
||||
|
||||
if (!formData.mobileNo.trim()) {
|
||||
newErrors.mobileNo = 'Mobile number is required';
|
||||
} else if (!/^\d{10}$/.test(formData.mobileNo.replace(/\s/g, ''))) {
|
||||
newErrors.mobileNo = 'Invalid mobile number';
|
||||
}
|
||||
|
||||
const generatedFullName = `${formData.firstName} ${formData.middleName} ${formData.lastName}`.trim();
|
||||
setFormData(prev => ({ ...prev, fullname: generatedFullName }));
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateProfileMutation.mutateAsync(formData);
|
||||
Alert.alert('Success', 'Profile updated successfully');
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
Alert.alert('Error', error?.message || 'Failed to update profile');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.modalOverlay}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<View style={[styles.modalContent, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.modalHeader}>
|
||||
<AppText variant="h3" style={styles.modalTitle}>Edit Personal Information</AppText>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<X size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.formContainer} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.inputGroup}>
|
||||
<AppText variant="bodySmall" style={styles.inputLabel}>First Name *</AppText>
|
||||
<TextInput
|
||||
style={[styles.input, errors.firstName && styles.inputError]}
|
||||
value={formData.firstName}
|
||||
onChangeText={(text) => setFormData({ ...formData, firstName: text })}
|
||||
placeholder="Enter first name"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
/>
|
||||
{errors.firstName && (
|
||||
<AppText variant="caption" style={styles.errorText}>{errors.firstName}</AppText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<AppText variant="bodySmall" style={styles.inputLabel}>Middle Name</AppText>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={formData.middleName}
|
||||
onChangeText={(text) => setFormData({ ...formData, middleName: text })}
|
||||
placeholder="Enter middle name"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<AppText variant="bodySmall" style={styles.inputLabel}>Last Name *</AppText>
|
||||
<TextInput
|
||||
style={[styles.input, errors.lastName && styles.inputError]}
|
||||
value={formData.lastName}
|
||||
onChangeText={(text) => setFormData({ ...formData, lastName: text })}
|
||||
placeholder="Enter last name"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
/>
|
||||
{errors.lastName && (
|
||||
<AppText variant="caption" style={styles.errorText}>{errors.lastName}</AppText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<AppText variant="bodySmall" style={styles.inputLabel}>Email *</AppText>
|
||||
<TextInput
|
||||
style={[styles.input, errors.emailId && styles.inputError]}
|
||||
value={formData.emailId}
|
||||
onChangeText={(text) => setFormData({ ...formData, emailId: text })}
|
||||
placeholder="Enter email"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
{errors.emailId && (
|
||||
<AppText variant="caption" style={styles.errorText}>{errors.emailId}</AppText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<AppText variant="bodySmall" style={styles.inputLabel}>Mobile Number *</AppText>
|
||||
<TextInput
|
||||
style={[styles.input, errors.mobileNo && styles.inputError]}
|
||||
value={formData.mobileNo}
|
||||
onChangeText={(text) => setFormData({ ...formData, mobileNo: text })}
|
||||
placeholder="Enter mobile number"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
keyboardType="phone-pad"
|
||||
maxLength={10}
|
||||
/>
|
||||
{errors.mobileNo && (
|
||||
<AppText variant="caption" style={styles.errorText}>{errors.mobileNo}</AppText>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.modalFooter}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.cancelButton]}
|
||||
onPress={onClose}
|
||||
disabled={updateProfileMutation.isPending}
|
||||
>
|
||||
<AppText variant="button" style={styles.cancelButtonText}>Cancel</AppText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.saveButton]}
|
||||
onPress={handleSave}
|
||||
disabled={updateProfileMutation.isPending}
|
||||
>
|
||||
{updateProfileMutation.isPending ? (
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
) : (
|
||||
<>
|
||||
<Save size={18} color={colors.buttonText} />
|
||||
<AppText variant="button" style={styles.saveButtonText}>Save</AppText>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const createStyles = (colors: any) => StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
maxHeight: '90%',
|
||||
paddingTop: 20,
|
||||
borderRadius: 30,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
formContainer: {
|
||||
padding: 20,
|
||||
maxHeight: 500,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
fontSize: 15,
|
||||
color: colors.text,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
inputError: {
|
||||
borderColor: colors.error,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
color: colors.error,
|
||||
marginTop: 4,
|
||||
},
|
||||
modalFooter: {
|
||||
flexDirection: 'row',
|
||||
padding: 20,
|
||||
gap: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
cancelButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
},
|
||||
saveButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: colors.buttonText,
|
||||
},
|
||||
});
|
||||
|
||||
export default EditProfileModal;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
@@ -6,19 +6,10 @@ import {
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
Platform,
|
||||
ViewStyle,
|
||||
TextStyle,
|
||||
Modal,
|
||||
TextInput,
|
||||
KeyboardAvoidingView,
|
||||
Alert,
|
||||
StatusBar,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import Animated, { FadeInDown, FadeInUp, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
import {
|
||||
AlertCircle,
|
||||
UserCircle,
|
||||
@@ -45,70 +36,22 @@ import {
|
||||
Star,
|
||||
List,
|
||||
Factory,
|
||||
CheckCircle2,
|
||||
CheckCircle,
|
||||
CalendarClock,
|
||||
ShieldCheck,
|
||||
LucideIcon,
|
||||
Edit,
|
||||
X,
|
||||
Save,
|
||||
ChevronRight,
|
||||
LogOut
|
||||
} from 'lucide-react-native';
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import { useContractorProfile, useUpdateUserProfile, useESetuAdharVarification } from '../../Services/ApiCall/Hooks';
|
||||
import { useContractorProfile, useESetuAdharVarification } from '../../Services/ApiCall/Hooks';
|
||||
import { useSelector } from 'react-redux';
|
||||
import ConfirmDialog from '../../Constant/Dialog/ConfirmDialog';
|
||||
import { generateTransactionId } from '../../Helper/uniqueId';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import AppText from '../../components/AppText';
|
||||
import ImagePath from '../../Constant/ImagePath';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
// Type Definitions
|
||||
interface ContractorData {
|
||||
contractorId: number;
|
||||
firstName: string;
|
||||
middleName: string;
|
||||
lastName: string;
|
||||
fullName: string;
|
||||
emailId: string;
|
||||
mobileNo: string;
|
||||
company: string;
|
||||
contractorType: string;
|
||||
dialCode: string;
|
||||
twoFactorAuthEnabled: boolean;
|
||||
colorCodeHSL: string | null;
|
||||
isActive: string;
|
||||
isEnabled: boolean | null;
|
||||
role: string;
|
||||
contractorStatus: boolean;
|
||||
designation: string | null;
|
||||
contOtherId: number;
|
||||
contCompanyType: string | null;
|
||||
contOrganization: string;
|
||||
agentAddress: string;
|
||||
agentPincode: string;
|
||||
agentCity: string;
|
||||
agentState: string | null;
|
||||
agentWebsite: string;
|
||||
agentRegno: string;
|
||||
orgPAN: string;
|
||||
orgPANdob: string;
|
||||
orgPANname: string;
|
||||
gistnNo: string;
|
||||
comAadharNo: string;
|
||||
contractorClass: string;
|
||||
socialCategory: string;
|
||||
wizardStepStatus: string;
|
||||
registrationStatus: string;
|
||||
indianOrgName: string;
|
||||
expiryDate: string;
|
||||
msmeStatus: string;
|
||||
}
|
||||
import EditProfileModal from './EditProfileModal';
|
||||
import { ContractorData, hasSectionData, isValidValue } from './profileTypes';
|
||||
|
||||
interface ProfileHeaderProps {
|
||||
data: ContractorData;
|
||||
@@ -144,37 +87,12 @@ interface StatItem {
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
interface UpdateProfilePayload {
|
||||
firstName: string;
|
||||
middleName: string;
|
||||
lastName: string;
|
||||
mobileNo: string;
|
||||
emailId: string;
|
||||
fullname: string;
|
||||
}
|
||||
|
||||
// Helper function to check if value is valid
|
||||
const isValidValue = (value: any): boolean => {
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 && trimmed.toLowerCase() !== 'n/a';
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Helper function to check if section has any valid data
|
||||
const hasSectionData = (values: any[]): boolean => {
|
||||
return values.some(value => isValidValue(value));
|
||||
};
|
||||
|
||||
// Main Component
|
||||
const ProfileTab: React.FC = () => {
|
||||
const { colors, spacing, shadows, isDarkMode } = useTheme();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => createStyles(colors), [colors]);
|
||||
const { data, refetch, isLoading, error } = useContractorProfile();
|
||||
const user = useSelector((state: any) => state.token?.token);
|
||||
console.log("User: ", user)
|
||||
const Navigation: any = useNavigation();
|
||||
const { mutateAsync: eSetuAdharVarification, isPending } = useESetuAdharVarification();
|
||||
|
||||
@@ -190,9 +108,12 @@ const ProfileTab: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
useFocusEffect(() => {
|
||||
setIsNavigating(false)
|
||||
})
|
||||
// useFocusEffect requires a memoized callback, or the effect re-runs on every render.
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
setIsNavigating(false);
|
||||
}, []),
|
||||
);
|
||||
|
||||
const handleProceedVerification = async () => {
|
||||
try {
|
||||
@@ -383,216 +304,6 @@ const ProfileTab: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Edit Profile Modal Component
|
||||
interface EditProfileModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
data: ContractorData;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const EditProfileModal: React.FC<EditProfileModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
data,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => createStyles(colors), [colors]);
|
||||
const [formData, setFormData] = useState<UpdateProfilePayload>({
|
||||
firstName: data.firstName || '',
|
||||
middleName: data.middleName || '',
|
||||
lastName: data.lastName || '',
|
||||
mobileNo: data.mobileNo || '',
|
||||
emailId: data.emailId || '',
|
||||
fullname: data.fullName || '',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Partial<UpdateProfilePayload>>({});
|
||||
|
||||
const updateProfileMutation = useUpdateUserProfile();
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setFormData({
|
||||
firstName: data.firstName || '',
|
||||
middleName: data.middleName || '',
|
||||
lastName: data.lastName || '',
|
||||
mobileNo: data.mobileNo || '',
|
||||
emailId: data.emailId || '',
|
||||
fullname: data.fullName || '',
|
||||
});
|
||||
setErrors({});
|
||||
}
|
||||
}, [visible, data]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<UpdateProfilePayload> = {};
|
||||
|
||||
if (!formData.firstName.trim()) {
|
||||
newErrors.firstName = 'First name is required';
|
||||
}
|
||||
|
||||
if (!formData.lastName.trim()) {
|
||||
newErrors.lastName = 'Last name is required';
|
||||
}
|
||||
|
||||
if (!formData.emailId.trim()) {
|
||||
newErrors.emailId = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.emailId)) {
|
||||
newErrors.emailId = 'Invalid email format';
|
||||
}
|
||||
|
||||
if (!formData.mobileNo.trim()) {
|
||||
newErrors.mobileNo = 'Mobile number is required';
|
||||
} else if (!/^\d{10}$/.test(formData.mobileNo.replace(/\s/g, ''))) {
|
||||
newErrors.mobileNo = 'Invalid mobile number';
|
||||
}
|
||||
|
||||
const generatedFullName = `${formData.firstName} ${formData.middleName} ${formData.lastName}`.trim();
|
||||
setFormData(prev => ({ ...prev, fullname: generatedFullName }));
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateProfileMutation.mutateAsync(formData);
|
||||
Alert.alert('Success', 'Profile updated successfully');
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
Alert.alert('Error', error?.message || 'Failed to update profile');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.modalOverlay}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<View style={[styles.modalContent, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.modalHeader}>
|
||||
<AppText variant="h3" style={styles.modalTitle}>Edit Personal Information</AppText>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<X size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.formContainer} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.inputGroup}>
|
||||
<AppText variant="bodySmall" style={styles.inputLabel}>First Name *</AppText>
|
||||
<TextInput
|
||||
style={[styles.input, errors.firstName && styles.inputError]}
|
||||
value={formData.firstName}
|
||||
onChangeText={(text) => setFormData({ ...formData, firstName: text })}
|
||||
placeholder="Enter first name"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
/>
|
||||
{errors.firstName && (
|
||||
<AppText variant="caption" style={styles.errorText}>{errors.firstName}</AppText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<AppText variant="bodySmall" style={styles.inputLabel}>Middle Name</AppText>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={formData.middleName}
|
||||
onChangeText={(text) => setFormData({ ...formData, middleName: text })}
|
||||
placeholder="Enter middle name"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<AppText variant="bodySmall" style={styles.inputLabel}>Last Name *</AppText>
|
||||
<TextInput
|
||||
style={[styles.input, errors.lastName && styles.inputError]}
|
||||
value={formData.lastName}
|
||||
onChangeText={(text) => setFormData({ ...formData, lastName: text })}
|
||||
placeholder="Enter last name"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
/>
|
||||
{errors.lastName && (
|
||||
<AppText variant="caption" style={styles.errorText}>{errors.lastName}</AppText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<AppText variant="bodySmall" style={styles.inputLabel}>Email *</AppText>
|
||||
<TextInput
|
||||
style={[styles.input, errors.emailId && styles.inputError]}
|
||||
value={formData.emailId}
|
||||
onChangeText={(text) => setFormData({ ...formData, emailId: text })}
|
||||
placeholder="Enter email"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
{errors.emailId && (
|
||||
<AppText variant="caption" style={styles.errorText}>{errors.emailId}</AppText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<AppText variant="bodySmall" style={styles.inputLabel}>Mobile Number *</AppText>
|
||||
<TextInput
|
||||
style={[styles.input, errors.mobileNo && styles.inputError]}
|
||||
value={formData.mobileNo}
|
||||
onChangeText={(text) => setFormData({ ...formData, mobileNo: text })}
|
||||
placeholder="Enter mobile number"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
keyboardType="phone-pad"
|
||||
maxLength={10}
|
||||
/>
|
||||
{errors.mobileNo && (
|
||||
<AppText variant="caption" style={styles.errorText}>{errors.mobileNo}</AppText>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.modalFooter}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.cancelButton]}
|
||||
onPress={onClose}
|
||||
disabled={updateProfileMutation.isPending}
|
||||
>
|
||||
<AppText variant="button" style={styles.cancelButtonText}>Cancel</AppText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.saveButton]}
|
||||
onPress={handleSave}
|
||||
disabled={updateProfileMutation.isPending}
|
||||
>
|
||||
{updateProfileMutation.isPending ? (
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
) : (
|
||||
<>
|
||||
<Save size={18} color={colors.buttonText} />
|
||||
<AppText variant="button" style={styles.saveButtonText}>Save</AppText>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// Profile Header Component - OPTIMIZED
|
||||
const ProfileHeader: React.FC<ProfileHeaderProps> = React.memo(({ data, displayName }) => {
|
||||
const { colors } = useTheme();
|
||||
@@ -810,7 +521,7 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||
shadowRadius: 4,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: colors.white,
|
||||
color: colors.buttonText,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
@@ -828,11 +539,11 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: colors.card,
|
||||
backgroundColor: colors.surface,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 4,
|
||||
borderColor: colors.card,
|
||||
borderColor: colors.surface,
|
||||
elevation: 4,
|
||||
shadowColor: colors.shadow,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
@@ -852,7 +563,7 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
borderWidth: 3,
|
||||
borderColor: colors.card, // Updated
|
||||
borderColor: colors.surface, // Updated
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
@@ -860,7 +571,7 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: colors.white,
|
||||
backgroundColor: colors.buttonText,
|
||||
},
|
||||
headerName: {
|
||||
fontSize: 26,
|
||||
@@ -894,7 +605,7 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.card,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
@@ -920,7 +631,7 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||
section: {
|
||||
marginHorizontal: 16,
|
||||
marginTop: 24,
|
||||
backgroundColor: colors.card,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
elevation: 2,
|
||||
@@ -984,7 +695,7 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.card, // Updated
|
||||
backgroundColor: colors.surface, // Updated
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
@@ -1007,97 +718,6 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||
lineHeight: 21,
|
||||
},
|
||||
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
maxHeight: '90%',
|
||||
paddingTop: 20,
|
||||
borderRadius: 30
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
formContainer: {
|
||||
padding: 20,
|
||||
maxHeight: 500,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
fontSize: 15,
|
||||
color: colors.text,
|
||||
backgroundColor: colors.card, // Updated
|
||||
},
|
||||
inputError: {
|
||||
borderColor: colors.error,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
color: colors.error,
|
||||
marginTop: 4,
|
||||
},
|
||||
modalFooter: {
|
||||
flexDirection: 'row',
|
||||
padding: 20,
|
||||
gap: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: colors.background, // Updated
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
cancelButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
},
|
||||
saveButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: colors.white,
|
||||
},
|
||||
navigationOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/** Shared types and value helpers for the Profile feature. */
|
||||
|
||||
export interface ContractorData {
|
||||
contractorId: number;
|
||||
firstName: string;
|
||||
middleName: string;
|
||||
lastName: string;
|
||||
fullName: string;
|
||||
emailId: string;
|
||||
mobileNo: string;
|
||||
company: string;
|
||||
contractorType: string;
|
||||
dialCode: string;
|
||||
twoFactorAuthEnabled: boolean;
|
||||
colorCodeHSL: string | null;
|
||||
isActive: string;
|
||||
isEnabled: boolean | null;
|
||||
role: string;
|
||||
contractorStatus: boolean;
|
||||
designation: string | null;
|
||||
contOtherId: number;
|
||||
contCompanyType: string | null;
|
||||
contOrganization: string;
|
||||
agentAddress: string;
|
||||
agentPincode: string;
|
||||
agentCity: string;
|
||||
agentState: string | null;
|
||||
agentWebsite: string;
|
||||
agentRegno: string;
|
||||
orgPAN: string;
|
||||
orgPANdob: string;
|
||||
orgPANname: string;
|
||||
gistnNo: string;
|
||||
comAadharNo: string;
|
||||
contractorClass: string;
|
||||
socialCategory: string;
|
||||
wizardStepStatus: string;
|
||||
registrationStatus: string;
|
||||
indianOrgName: string;
|
||||
expiryDate: string;
|
||||
msmeStatus: string;
|
||||
}
|
||||
|
||||
export interface UpdateProfilePayload {
|
||||
firstName: string;
|
||||
middleName: string;
|
||||
lastName: string;
|
||||
mobileNo: string;
|
||||
emailId: string;
|
||||
fullname: string;
|
||||
}
|
||||
|
||||
/** True when the value is renderable profile data (not empty/null/"N/A"). */
|
||||
export const isValidValue = (value: any): boolean => {
|
||||
if (value === null || value === undefined) { return false; }
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 && trimmed.toLowerCase() !== 'n/a';
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/** True when at least one of the section's values is renderable. */
|
||||
export const hasSectionData = (values: any[]): boolean => values.some(isValidValue);
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
} from 'react-native';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import PrimaryButton from '../../Component/PrimaryButton/PrimaryButton';
|
||||
import PrimaryButton from '../../components/PrimaryButton';
|
||||
import MyInput from '../../components/MyInput';
|
||||
import { showToast } from '../../Constant/ShowDailog';
|
||||
import { usePartnerShipSubmit } from '../../Services/ApiCall/Hooks';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { NavigationString } from '../../Constant/AllImports';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import AppText from '../../components/AppText';
|
||||
@@ -246,7 +246,7 @@ const Partnership = () => {
|
||||
</SafeAreaView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<PrimaryButton title={loading ? 'Loading...' : 'Submit'} onPress={handleSubmit} />
|
||||
<PrimaryButton title={loading ? 'Loading...' : 'Submit'} onPress={handleSubmit} style={{ marginBottom: 20 }} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -254,7 +254,7 @@ const Partnership = () => {
|
||||
|
||||
export default Partnership;
|
||||
|
||||
const makeStyles = (colors: any, spacing: any, shadows: any, typography: any) => StyleSheet.create({
|
||||
const makeStyles = (colors: any, spacing: any, _shadows: any, _typography: any) => StyleSheet.create({
|
||||
header: {
|
||||
paddingHorizontal: spacing.m,
|
||||
paddingBottom: spacing.m,
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Platform, KeyboardAvoidingView, ScrollView, BackHandler, ToastAndroid, Alert } from 'react-native';
|
||||
import React, { useEffect, useMemo, useReducer } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Platform, KeyboardAvoidingView, ScrollView, BackHandler, ToastAndroid } from 'react-native';
|
||||
import { ArrowLeft } from 'lucide-react-native';
|
||||
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { ApiResponse, DocumentsType, FormDataType, OrgType, UserData } from './types/types';
|
||||
import { FormDataType } from './types/types';
|
||||
import { pick } from '@react-native-documents/picker';
|
||||
import axios from 'axios';
|
||||
import StageOne from './ragistrationStages/StageOne';
|
||||
import StageTwo from './ragistrationStages/StageTwo';
|
||||
import StageZero from './ragistrationStages/StageZero';
|
||||
import StageThree from './ragistrationStages/StageThree';
|
||||
import StageFour from './ragistrationStages/StageFour';
|
||||
import PrimaryButton from '../../Component/PrimaryButton/PrimaryButton';
|
||||
import StepProgressBar from '../../Component/StepProgressBar';
|
||||
import PrimaryButton from '../../components/PrimaryButton';
|
||||
import StepProgressBar from '../../components/StepProgressBar';
|
||||
import { formatDateInput } from '../../Helper/formatDateInput';
|
||||
import { showToast } from '../../Constant/ShowDailog';
|
||||
import { useCityDropdown, useContractorStageTwoSubmit, usefetchMasterData, useSendAadhaarOtp, useVerifyAadhaarOtp, useVerifyGstNumber, useVerifyPan } from '../../Services/ApiCall/Hooks';
|
||||
import { useCityDropdown, useContractorStageTwoSubmit, useFetchMasterData, useSendAadhaarOtp, useVerifyAadhaarOtp, useVerifyPan } from '../../Services/ApiCall/Hooks';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Animated, { FadeInUp, FadeInDown } from 'react-native-reanimated';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import AppText from '../../components/AppText';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import { logger } from '../../core/observability/logger';
|
||||
import { initialRegistrationState, registrationReducer } from './registrationState';
|
||||
import { validatePanFormat, validateStage } from './registrationValidation';
|
||||
|
||||
type RouteParams = {
|
||||
userData?: {
|
||||
@@ -33,192 +34,96 @@ type RouteParams = {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Multi-stage registration wizard. All wizard state lives in
|
||||
* registrationState.ts (one reducer); per-stage validation in
|
||||
* registrationValidation.ts. This component orchestrates API calls and
|
||||
* renders the current stage.
|
||||
*/
|
||||
const Registration: React.FC = () => {
|
||||
const { colors, spacing, typography, shadows } = useTheme();
|
||||
const navigation = useNavigation<any>();
|
||||
const { params } = useRoute<RouteProp<Record<string, RouteParams>, string>>();
|
||||
const userData = params as RouteParams;
|
||||
const [stage, setStage] = useState(0);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [focusedField, setFocusedField] = useState<null | string>(null);
|
||||
const [selectedOrg, setSelectedOrg] = useState<OrgType>(0);
|
||||
const [documents, setDocuments] = useState<DocumentsType>({
|
||||
panCard: null,
|
||||
gstnCertificate: null,
|
||||
registrationCertificate: null,
|
||||
msmefile: null,
|
||||
constractorClass: null,
|
||||
aadharCard: null,
|
||||
});
|
||||
const [formData, setFormData] = useState<FormDataType>({
|
||||
firstName: '',
|
||||
middleName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
mobileNumber: '',
|
||||
organizationName: '',
|
||||
officialWebsite: '',
|
||||
registrationNumber: '',
|
||||
contractorClass: '',
|
||||
orgType: '',
|
||||
registrationDetail: '',
|
||||
socialCategory: '',
|
||||
orgPanNumber: '',
|
||||
dateOfIncorp: '',
|
||||
orgPanName: '',
|
||||
gstnNo: '',
|
||||
aadhaarNumber: '',
|
||||
aadhaarOtp: '',
|
||||
address: '',
|
||||
state: '',
|
||||
city: '',
|
||||
pinCode: '',
|
||||
agentContact2: '',
|
||||
expiryDate: '',
|
||||
});
|
||||
const [loader, setloader] = useState(false);
|
||||
const [aadhaarVerifying, setAadhaarVerifying] = useState(false);
|
||||
const [aadhaarVerified, setAadhaarVerified] = useState(false);
|
||||
const [panVerifying, setPanVerifying] = useState(false);
|
||||
const [panVerified, setPanVerified] = useState(false);
|
||||
const [contractorClassItems, setContractorClassItems] = useState([]);
|
||||
const [allOrganizations, setAllOrganizations] = useState([]);
|
||||
const [allSocialCategories, setAllSocialCategories] = useState([]);
|
||||
const [allMsmeOrgs, setAllMsmeOrgs] = useState([]);
|
||||
const [adharreference, setadharreference] = useState('');
|
||||
const [adharotpsend, setadharotpsend] = useState(false);
|
||||
const [gstverifying, setgstverifying] = useState(false);
|
||||
const [gstverified, setgstverified] = useState(false);
|
||||
const [states, setstates] = useState([]);
|
||||
const [cities, setCities] = useState([]);
|
||||
const [allMSME, setAllMSME] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [state, dispatch] = useReducer(registrationReducer, initialRegistrationState);
|
||||
const { stage, formData, documents, selectedOrg, focusedField } = state;
|
||||
|
||||
const { mutateAsync: verifyPanHandlerRes } = useVerifyPan();
|
||||
const { mutateAsync: sendAadhaarOtp } = useSendAadhaarOtp();
|
||||
const { mutateAsync: verifyAadhaarOtp } = useVerifyAadhaarOtp();
|
||||
const { mutateAsync: verifyGstNumber } = useVerifyGstNumber();
|
||||
const { mutateAsync: contractorStageTwoSubmit } = useContractorStageTwoSubmit();
|
||||
const { data: masterData } = usefetchMasterData();
|
||||
const { mutate: cityDropdownMutate } = useCityDropdown();
|
||||
const { data: masterData } = useFetchMasterData();
|
||||
const user = useSelector((s: any) => s.token.token);
|
||||
const steps = ['', '', '', '', ''];
|
||||
|
||||
const styles = useMemo(() => makeStyles(colors, spacing, shadows, typography), [colors, spacing, shadows, typography]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!masterData?.data) return;
|
||||
const contractorClasses = masterData.data.contractorClasses || [];
|
||||
const msmeOrgs = masterData.data.msmeOrgs || [];
|
||||
const mappedmsmeOrgs = msmeOrgs.map((item: any) => ({
|
||||
label: item.comName,
|
||||
value: item.msmeId,
|
||||
}));
|
||||
const orgs = masterData.data.organizationTypes || [];
|
||||
const mappedcontractorClasses = contractorClasses.map((item: any) => ({
|
||||
label: item.contClassName || '',
|
||||
value: item.contClassId,
|
||||
}));
|
||||
const mapped = orgs.map((item: any) => ({
|
||||
label: item.organizationType,
|
||||
value: item.orgId,
|
||||
}));
|
||||
const socialCategories = masterData.data.socialCategories || [];
|
||||
const mappedsocialCategories = socialCategories.map((item: any) => ({
|
||||
label: item.categoryName,
|
||||
value: item.socialCategoryId,
|
||||
}));
|
||||
const stateList = masterData.data.states || [];
|
||||
const mappedStates = stateList.map((item: any) => ({
|
||||
label: item.stateName,
|
||||
value: item.stateId,
|
||||
}));
|
||||
setAllMsmeOrgs(mappedmsmeOrgs);
|
||||
setAllSocialCategories(mappedsocialCategories);
|
||||
setContractorClassItems(mappedcontractorClasses);
|
||||
setAllOrganizations(mapped);
|
||||
setstates(mappedStates);
|
||||
if (!masterData?.data) { return; }
|
||||
const mapList = (list: any[], label: string, value: string) =>
|
||||
(list || []).map((item: any) => ({ label: item[label] || '', value: item[value] }));
|
||||
|
||||
dispatch({
|
||||
type: 'MASTER_DATA_LOADED',
|
||||
lists: {
|
||||
contractorClasses: mapList(masterData.data.contractorClasses, 'contClassName', 'contClassId'),
|
||||
organizations: mapList(masterData.data.organizationTypes, 'organizationType', 'orgId'),
|
||||
socialCategories: mapList(masterData.data.socialCategories, 'categoryName', 'socialCategoryId'),
|
||||
msmeOrgs: mapList(masterData.data.msmeOrgs, 'comName', 'msmeId'),
|
||||
states: mapList(masterData.data.states, 'stateName', 'stateId'),
|
||||
},
|
||||
});
|
||||
}, [masterData?.data]);
|
||||
|
||||
|
||||
const { mutate: cityDropdownMutate } = useCityDropdown();
|
||||
const { data: MasterData } = usefetchMasterData();
|
||||
const user = useSelector((state: any) => state.token.token);
|
||||
const steps = ['', '', '', '', ''];
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserData = async (user: any) => {
|
||||
try {
|
||||
if (user) {
|
||||
const parsedUser = user;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
firstName: parsedUser?.firstName || userData?.userData?.firstName,
|
||||
lastName: parsedUser?.lastName || userData?.userData?.lastName,
|
||||
mobileNumber: parsedUser?.mobileNo || userData?.userData?.mobileNo,
|
||||
middleName: parsedUser?.middleName || userData?.userData?.middleName,
|
||||
email: parsedUser?.email || userData?.userData?.email,
|
||||
}));
|
||||
} else {
|
||||
console.log('No user data found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserData(user);
|
||||
if (user || userData?.userData) {
|
||||
dispatch({
|
||||
type: 'PREFILL_CONTACT',
|
||||
values: {
|
||||
firstName: user?.firstName || userData?.userData?.firstName,
|
||||
lastName: user?.lastName || userData?.userData?.lastName,
|
||||
mobileNumber: user?.mobileNo || userData?.userData?.mobileNo,
|
||||
middleName: user?.middleName || userData?.userData?.middleName,
|
||||
email: user?.email || userData?.userData?.email,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Intentionally runs only on mount; user/userData are stable route params.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const isPANLinkedToGSTIN = (pan: string, gstin: string): { valid: boolean; message: string } => {
|
||||
pan = pan.toUpperCase();
|
||||
gstin = gstin.toUpperCase();
|
||||
|
||||
const panRegex = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
|
||||
|
||||
if (!panRegex.test(pan)) {
|
||||
return { valid: false, message: "Invalid PAN format. It should be like 'ABCDE1234F'." };
|
||||
}
|
||||
|
||||
return { valid: true, message: 'PAN and GSTIN are correctly linked.' };
|
||||
};
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
dispatch({ type: 'SET_FIELD', field: field as keyof FormDataType, value });
|
||||
};
|
||||
|
||||
const setFocusedField = (field: string | null) =>
|
||||
dispatch({ type: 'SET_FOCUSED_FIELD', field });
|
||||
|
||||
const verifyPanHandler = async () => {
|
||||
setPanVerifying(true);
|
||||
dispatch({ type: 'PAN_VERIFY_START' });
|
||||
const payload = {
|
||||
dob: formData?.dateOfIncorp.trim(),
|
||||
name: formData?.orgPanName.trim(),
|
||||
panNumber: formData?.orgPanNumber.trim(),
|
||||
};
|
||||
const result = await verifyPanHandlerRes(payload);
|
||||
|
||||
if (result) {
|
||||
setPanVerified(true);
|
||||
} else {
|
||||
setPanVerified(false);
|
||||
}
|
||||
setPanVerifying(false);
|
||||
dispatch({ type: 'PAN_VERIFY_DONE', verified: !!result });
|
||||
};
|
||||
|
||||
const sendAdharOTPHandler = async (text: string) => {
|
||||
setAadhaarVerifying(true);
|
||||
setadharreference('');
|
||||
dispatch({ type: 'AADHAAR_OTP_REQUEST' });
|
||||
|
||||
const payload = {
|
||||
aadhaarNumber: text.trim(),
|
||||
};
|
||||
|
||||
const response = await sendAadhaarOtp(payload);
|
||||
const response = await sendAadhaarOtp({ aadhaarNumber: text.trim() });
|
||||
|
||||
if (response) {
|
||||
setadharotpsend(true);
|
||||
setadharreference(response);
|
||||
dispatch({ type: 'AADHAAR_OTP_DONE', sent: true, refNumber: response });
|
||||
} else {
|
||||
setadharotpsend(false);
|
||||
dispatch({ type: 'AADHAAR_OTP_DONE', sent: false, refNumber: '' });
|
||||
ToastAndroid.show('Failed to send Aadhaar OTP', ToastAndroid.SHORT);
|
||||
}
|
||||
|
||||
setAadhaarVerifying(false);
|
||||
};
|
||||
|
||||
const verifyAdharOtpHandler = async () => {
|
||||
@@ -226,33 +131,18 @@ const Registration: React.FC = () => {
|
||||
ToastAndroid.show('Please enter a valid 6-digit OTP', ToastAndroid.SHORT);
|
||||
return;
|
||||
}
|
||||
setAadhaarVerifying(true);
|
||||
const payload = {
|
||||
dispatch({ type: 'AADHAAR_VERIFY_START' });
|
||||
const response = await verifyAadhaarOtp({
|
||||
otp: formData.aadhaarOtp,
|
||||
refNumber: adharreference,
|
||||
};
|
||||
const response = await verifyAadhaarOtp(payload);
|
||||
refNumber: state.aadhaarRefNumber,
|
||||
});
|
||||
if (response) {
|
||||
setAadhaarVerified(true);
|
||||
dispatch({ type: 'AADHAAR_VERIFY_DONE', verified: true });
|
||||
ToastAndroid.show('Aadhaar Verified', ToastAndroid.SHORT);
|
||||
} else {
|
||||
dispatch({ type: 'AADHAAR_VERIFY_DONE' });
|
||||
ToastAndroid.show('Verification failed', ToastAndroid.SHORT);
|
||||
}
|
||||
setAadhaarVerifying(false);
|
||||
};
|
||||
|
||||
const verifyGSTHandler = async () => {
|
||||
setgstverifying(true);
|
||||
const payload = {
|
||||
gstNumber: formData?.gstnNo.trim(),
|
||||
};
|
||||
const response = await verifyGstNumber(payload);
|
||||
if (response) {
|
||||
setgstverified(true);
|
||||
} else {
|
||||
ToastAndroid.show('GST verification failed', ToastAndroid.SHORT);
|
||||
}
|
||||
setgstverifying(false);
|
||||
};
|
||||
|
||||
const handleDocumentPick = async (field: string) => {
|
||||
@@ -264,19 +154,15 @@ const Registration: React.FC = () => {
|
||||
});
|
||||
|
||||
if (response && response?.length > 0) {
|
||||
const selectedFile = response[0];
|
||||
setDocuments(prev => ({
|
||||
...prev,
|
||||
[field]: selectedFile,
|
||||
}));
|
||||
dispatch({ type: 'SET_DOCUMENT', field, file: response[0] });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Document pick error:', err);
|
||||
logger.error('registration', 'document pick failed', { error: String(err) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
dispatch({ type: 'SUBMIT_START' });
|
||||
|
||||
const dtoData = {
|
||||
agentAddress: formData.address,
|
||||
@@ -297,7 +183,8 @@ const Registration: React.FC = () => {
|
||||
orgPAN: formData.orgPanNumber,
|
||||
comAadharNo: formData.aadhaarNumber,
|
||||
gistnNo: formData.gstnNo,
|
||||
stateName: MasterData?.data?.states?.find((state: any) => state.stateId == formData.state)?.stateName || '',
|
||||
// eslint-disable-next-line eqeqeq
|
||||
stateName: masterData?.data?.states?.find((s: any) => s.stateId == formData.state)?.stateName || '',
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -311,185 +198,93 @@ const Registration: React.FC = () => {
|
||||
fileData.append('regfile', documents.registrationCertificate as any);
|
||||
|
||||
const res = await contractorStageTwoSubmit(fileData);
|
||||
console.log('Stage Two Res : ', res)
|
||||
if (res.statusCode === 200) {
|
||||
if (res) {
|
||||
showToast('Form submitted successfully!');
|
||||
|
||||
showToast('Form submitted successfully!');
|
||||
|
||||
const orgType = selectedOrg;
|
||||
const params = { otherId: res?.data?.contOtherId, orgType: orgType };
|
||||
console.log("params : ", params)
|
||||
if ([4, 5, 6].includes(orgType)) {
|
||||
navigation.replace(NavigationString.StageThreeScreen as never, params as never);
|
||||
} else if (orgType === 7) {
|
||||
navigation.replace(NavigationString.Partnership as never, { otherId: res?.data?.contOtherId } as never);
|
||||
} else {
|
||||
navigation.replace(NavigationString.Home as never);
|
||||
}
|
||||
const orgType = selectedOrg;
|
||||
const routeParams = { otherId: res?.data?.contOtherId, orgType: orgType };
|
||||
if ([4, 5, 6].includes(orgType as number)) {
|
||||
navigation.replace(NavigationString.StageThreeScreen as never, routeParams as never);
|
||||
} else if (orgType === 7) {
|
||||
navigation.replace(NavigationString.Partnership as never, { otherId: res?.data?.contOtherId } as never);
|
||||
} else {
|
||||
showToast('Form submission failed');
|
||||
navigation.replace(NavigationString.Home as never);
|
||||
}
|
||||
} else {
|
||||
|
||||
showToast(res.message || 'Form submission failed. Please try again.');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log("Error in form submission")
|
||||
if (axios.isAxiosError(error)) {
|
||||
} else {
|
||||
console.error('Unexpected error', error);
|
||||
}
|
||||
console.error('Form submission failed:', error);
|
||||
logger.error('registration', 'stage-two submission failed', { error: String(error) });
|
||||
showToast(error.message || 'Form submission failed. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
dispatch({ type: 'SUBMIT_END' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const fetchCity = async (stateId: number) => {
|
||||
const fetchCity = (stateId: number) => {
|
||||
cityDropdownMutate(stateId, {
|
||||
onSuccess: (data) => {
|
||||
setCities(data);
|
||||
onSuccess: data => dispatch({ type: 'CITIES_LOADED', cities: data }),
|
||||
onError: error => {
|
||||
dispatch({ type: 'CITIES_LOADED', cities: [] });
|
||||
logger.error('registration', 'city fetch failed', { error: String(error) });
|
||||
},
|
||||
onError: (error) => {
|
||||
setCities([]);
|
||||
console.error('Error in city fetch:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
console.log('formdata : ', formData);
|
||||
|
||||
useEffect(() => {
|
||||
if (formData?.state) {
|
||||
fetchCity(Number(formData.state));
|
||||
}
|
||||
// Intentionally re-runs only when the selected state changes; fetchCity is recreated each render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formData?.state]);
|
||||
|
||||
useEffect(() => {
|
||||
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
if (stage > 1) {
|
||||
setStage(prev => prev - 1);
|
||||
dispatch({ type: 'PREV_STAGE' });
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => backHandler.remove();
|
||||
}, [stage]);
|
||||
|
||||
const validateStage = (stage: number) => {
|
||||
console.log(stage, 'validateStage');
|
||||
switch (stage) {
|
||||
case 0:
|
||||
if (!formData.organizationName?.trim()) {
|
||||
showToast('Please fill all required details.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
const handleNextPress = () => {
|
||||
const validation = validateStage(stage, { formData, documents, selectedOrg });
|
||||
if (!validation.valid) {
|
||||
showToast(validation.message || 'Please fill all required fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
case 1:
|
||||
if (selectedOrg == 8) {
|
||||
if (!formData.orgType || !formData.socialCategory) {
|
||||
showToast('Please enter all valid details.');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
!formData.registrationNumber ||
|
||||
!formData.registrationDetail ||
|
||||
!formData.socialCategory ||
|
||||
!formData.orgType
|
||||
) {
|
||||
showToast('Please enter all valid details.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
if (stage === 2) {
|
||||
if (!state.panVerified) {
|
||||
showToast('Please verify PAN details.');
|
||||
return;
|
||||
}
|
||||
if (!state.aadhaarVerified) {
|
||||
showToast('Please verify Aadhar Number.');
|
||||
return;
|
||||
}
|
||||
const panCheck = validatePanFormat(formData.orgPanNumber);
|
||||
if (!panCheck.valid) {
|
||||
showToast(panCheck.message || 'Invalid PAN.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
case 2:
|
||||
if (
|
||||
!formData.orgPanNumber ||
|
||||
!formData.dateOfIncorp ||
|
||||
!formData.orgPanName ||
|
||||
(selectedOrg == 8 && !formData.gstnNo) ||
|
||||
!formData.aadhaarNumber ||
|
||||
!formData.aadhaarOtp
|
||||
) {
|
||||
Alert.alert('aadhar otp : ', formData?.aadhaarOtp);
|
||||
showToast('Please fill all required KYC and Aadhaar details.');
|
||||
return false;
|
||||
}
|
||||
if (formData.aadhaarNumber.length !== 12) {
|
||||
showToast('Aadhaar number must be 12 digits.');
|
||||
return false;
|
||||
}
|
||||
if (formData.aadhaarOtp.length !== 6) {
|
||||
showToast('OTP must be 6 digits.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
case 3:
|
||||
if (!formData.address || !formData.state || !formData.city || !formData.pinCode) {
|
||||
showToast('Please fill all required address details.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.pinCode.length !== 6) {
|
||||
showToast('Please enter a valid 6-digit pin code.');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
case 4:
|
||||
if (selectedOrg == 8) {
|
||||
if (!documents.panCard || !documents.aadharCard) {
|
||||
showToast('Please upload all required documents.');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (formData?.contractorClass !== 1) {
|
||||
if (!documents.constractorClass) {
|
||||
showToast('Please upload CONTRACTOR CLASS documents.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (formData?.registrationDetail !== 3) {
|
||||
if (!documents.msmefile) {
|
||||
showToast('Please upload MSME FILE.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!documents.gstnCertificate || !documents.registrationCertificate) {
|
||||
showToast('Please upload all required documents.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
default:
|
||||
return true;
|
||||
if (stage === 4) {
|
||||
handleSubmit();
|
||||
} else {
|
||||
dispatch({ type: 'NEXT_STAGE' });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Stage Titles ──
|
||||
const stageTitles = [
|
||||
'Personal & Contact',
|
||||
'Registration Details',
|
||||
'KYC Verification',
|
||||
'Address Details',
|
||||
'Upload Documents',
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<View style={[styles.root, { backgroundColor: colors.background }]}>
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
style={styles.flex1}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : -30}
|
||||
>
|
||||
@@ -499,43 +294,25 @@ const Registration: React.FC = () => {
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={{ paddingBottom: 20, marginTop: insets.top, flex: 1 }}>
|
||||
<View style={[styles.stageArea, { marginTop: insets.top }]}>
|
||||
{/* ═══ Back Button ═══ */}
|
||||
<View style={styles.headerRow}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (stage > 0) {
|
||||
setStage(prev => prev - 1);
|
||||
}
|
||||
}}
|
||||
onPress={() => dispatch({ type: 'PREV_STAGE' })}
|
||||
style={styles.backBtn}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ArrowLeft size={22} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<AppText variant="h3" color={colors.text} style={{ fontWeight: '700' }}>
|
||||
<AppText variant="h3" color={colors.text} style={styles.title as any}>
|
||||
Registration
|
||||
</AppText>
|
||||
<View style={{ width: 36 }} />
|
||||
<View style={styles.headerSpacer} />
|
||||
</View>
|
||||
|
||||
{/* ═══ Progress Bar ═══ */}
|
||||
<StepProgressBar currentStep={stage} steps={steps} />
|
||||
|
||||
{/* ═══ Stage Title ═══
|
||||
<Animated.View
|
||||
key={`title-${stage}`}
|
||||
entering={FadeInUp.duration(300)}
|
||||
style={styles.stageTitleContainer}
|
||||
>
|
||||
<AppText variant="h3" color={colors.text} style={styles.stageTitle}>
|
||||
{stageTitles[stage]}
|
||||
</AppText>
|
||||
<AppText variant="caption" color={colors.textSecondary}>
|
||||
Step {stage + 1} of {steps.length}
|
||||
</AppText>
|
||||
</Animated.View> */}
|
||||
|
||||
{/* ═══ Stage Content ═══ */}
|
||||
<Animated.View
|
||||
key={`stage-${stage}`}
|
||||
@@ -545,9 +322,7 @@ const Registration: React.FC = () => {
|
||||
{stage === 0 && (
|
||||
<StageZero
|
||||
formData={formData}
|
||||
handleChange={(key: string, value: string) =>
|
||||
handleChange(key as keyof FormDataType, value)
|
||||
}
|
||||
handleChange={handleChange}
|
||||
focusedField={focusedField}
|
||||
setFocusedField={setFocusedField}
|
||||
styles={styles}
|
||||
@@ -557,18 +332,14 @@ const Registration: React.FC = () => {
|
||||
{stage === 1 && (
|
||||
<StageOne
|
||||
formData={formData}
|
||||
handleChange={(key: string, value: string) =>
|
||||
handleChange(key as keyof FormDataType, value)
|
||||
}
|
||||
handleChange={handleChange}
|
||||
focusedField={focusedField}
|
||||
setFocusedField={setFocusedField}
|
||||
contractorClassItems={contractorClassItems}
|
||||
allOrganizations={allOrganizations}
|
||||
allMsmeOrgs={allMsmeOrgs}
|
||||
allSocialCategories={allSocialCategories}
|
||||
allMSME={allMSME}
|
||||
dropdownStyle={{}}
|
||||
setSelectedOrg={setSelectedOrg}
|
||||
contractorClassItems={state.masterLists.contractorClasses}
|
||||
allOrganizations={state.masterLists.organizations}
|
||||
allMsmeOrgs={state.masterLists.msmeOrgs}
|
||||
allSocialCategories={state.masterLists.socialCategories}
|
||||
setSelectedOrg={(org: any) => dispatch({ type: 'SET_SELECTED_ORG', org })}
|
||||
selectedOrg={selectedOrg}
|
||||
/>
|
||||
)}
|
||||
@@ -576,25 +347,20 @@ const Registration: React.FC = () => {
|
||||
{stage === 2 && (
|
||||
<StageTwo
|
||||
formData={formData}
|
||||
handleChange={(key: string, value: string) =>
|
||||
handleChange(key as keyof FormDataType, value)
|
||||
}
|
||||
handleChange={handleChange}
|
||||
focusedField={focusedField}
|
||||
setFocusedField={setFocusedField}
|
||||
panVerified={panVerified}
|
||||
setPanVerified={setPanVerified}
|
||||
panVerified={state.panVerified}
|
||||
setPanVerified={(verified: boolean) => dispatch({ type: 'SET_PAN_VERIFIED', verified })}
|
||||
verifyPanHandler={verifyPanHandler}
|
||||
panVerifying={panVerifying}
|
||||
aadhaarVerified={aadhaarVerified}
|
||||
setAadhaarVerified={setAadhaarVerified}
|
||||
panVerifying={state.panVerifying}
|
||||
aadhaarVerified={state.aadhaarVerified}
|
||||
setAadhaarVerified={(verified: boolean) => dispatch({ type: 'SET_AADHAAR_VERIFIED', verified })}
|
||||
sendAdharOTPHandler={sendAdharOTPHandler}
|
||||
adharotpsend={adharotpsend}
|
||||
setadharotpsend={setadharotpsend}
|
||||
adharotpsend={state.aadhaarOtpSent}
|
||||
setadharotpsend={(sent: boolean) => dispatch({ type: 'SET_AADHAAR_OTP_SENT', sent })}
|
||||
verifyAdharOtpHandler={verifyAdharOtpHandler}
|
||||
aadhaarVerifying={aadhaarVerifying}
|
||||
verifyGSTHandler={verifyGSTHandler}
|
||||
gstverifying={gstverifying}
|
||||
color={colors}
|
||||
aadhaarVerifying={state.aadhaarVerifying}
|
||||
styles={styles}
|
||||
formatDateInput={formatDateInput}
|
||||
selectedOrg={selectedOrg}
|
||||
@@ -604,16 +370,12 @@ const Registration: React.FC = () => {
|
||||
{stage === 3 && (
|
||||
<StageThree
|
||||
formData={formData}
|
||||
handleChange={(key: string, value: string) =>
|
||||
handleChange(key as keyof FormDataType, value)
|
||||
}
|
||||
handleChange={handleChange}
|
||||
setFocusedField={setFocusedField}
|
||||
focusedField={focusedField}
|
||||
states={states}
|
||||
cities={cities}
|
||||
fetchCity={() => {
|
||||
fetchCity(Number(formData.state));
|
||||
}}
|
||||
states={state.masterLists.states}
|
||||
cities={state.cities}
|
||||
fetchCity={() => fetchCity(Number(formData.state))}
|
||||
dropdownStyle={{}}
|
||||
styles={styles}
|
||||
/>
|
||||
@@ -634,51 +396,9 @@ const Registration: React.FC = () => {
|
||||
{/* ═══ Next / Submit Button ═══ */}
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
|
||||
<PrimaryButton
|
||||
title={stage === 4 ? (loading ? 'Loading...' : 'Submit') : 'Next'}
|
||||
style={{ backgroundColor: colors.primary }}
|
||||
onPress={() => {
|
||||
if (validateStage(stage)) {
|
||||
switch (stage) {
|
||||
case 0:
|
||||
case 1:
|
||||
setStage(prev => prev + 1);
|
||||
break;
|
||||
case 2:
|
||||
if (!panVerified) {
|
||||
showToast('Please verify PAN details.');
|
||||
return;
|
||||
}
|
||||
if (!aadhaarVerified) {
|
||||
showToast('Please verify Aadhar Number.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { valid, message } = isPANLinkedToGSTIN(
|
||||
formData.orgPanNumber,
|
||||
formData.gstnNo,
|
||||
);
|
||||
if (!valid) {
|
||||
showToast(message);
|
||||
return;
|
||||
}
|
||||
|
||||
setStage(prev => prev + 1);
|
||||
break;
|
||||
case 3:
|
||||
setStage(prev => prev + 1);
|
||||
break;
|
||||
case 4:
|
||||
handleSubmit();
|
||||
break;
|
||||
default:
|
||||
console.log(stage, 'stage');
|
||||
setStage(prev => prev + 1);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
showToast('Please fill all required fields.');
|
||||
}
|
||||
}}
|
||||
title={stage === 4 ? (state.submitting ? 'Loading...' : 'Submit') : 'Next'}
|
||||
style={{ backgroundColor: colors.primary, marginBottom: 20 }}
|
||||
onPress={handleNextPress}
|
||||
/>
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
@@ -691,6 +411,11 @@ export default Registration;
|
||||
|
||||
// ── Styles ────────────────────────────────────────────────────────────
|
||||
const makeStyles = (colors: any, spacing: any, shadows: any, typography: any) => StyleSheet.create({
|
||||
root: { flex: 1 },
|
||||
flex1: { flex: 1 },
|
||||
stageArea: { paddingBottom: 20, flex: 1 },
|
||||
title: { fontWeight: '700' } as any,
|
||||
headerSpacer: { width: 36 },
|
||||
scrollContainer: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: spacing.m,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from 'react-native';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import PrimaryButton from '../../Component/PrimaryButton/PrimaryButton';
|
||||
import PrimaryButton from '../../components/PrimaryButton';
|
||||
import MyInput from '../../components/MyInput';
|
||||
import { showToast } from '../../Constant/ShowDailog';
|
||||
import { useContractorStageThreeSubmit } from '../../Services/ApiCall/Hooks';
|
||||
@@ -17,6 +17,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import AppText from '../../components/AppText';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import { logger } from '../../core/observability/logger';
|
||||
import { Trash2, Building2 } from 'lucide-react-native';
|
||||
|
||||
type Company = {
|
||||
@@ -84,16 +86,17 @@ const StageThreeScreen = () => {
|
||||
shPANNo: p.panNumber,
|
||||
}));
|
||||
|
||||
console.log('stage 3 payload : ', payload);
|
||||
const response = await stageThreeSubmit(payload);
|
||||
console.log('✅ company submitted successfully:', response);
|
||||
|
||||
if (response.statusCode === 200 || response.statusCode === 201) {
|
||||
navigation.replace('Dashboard');
|
||||
// Was navigation.replace('Dashboard') — a route that never existed, so
|
||||
// the navigation was silently dropped and the user was stranded on the
|
||||
// form. Completed registration lands on Home (matches Splash/Login).
|
||||
navigation.replace(NavigationString.Home);
|
||||
showToast('Submitted successfully!');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('❌ Submission failed:', error.message);
|
||||
logger.error('registration', 'stage-three submission failed', { error: String(error) });
|
||||
showToast('Submission failed. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -241,7 +244,7 @@ const StageThreeScreen = () => {
|
||||
</SafeAreaView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<PrimaryButton title={loading ? 'Loading...' : 'Submit'} onPress={handleSubmit} />
|
||||
<PrimaryButton title={loading ? 'Loading...' : 'Submit'} onPress={handleSubmit} style={{ marginBottom: 20 }} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -249,7 +252,7 @@ const StageThreeScreen = () => {
|
||||
|
||||
export default StageThreeScreen;
|
||||
|
||||
const makeStyles = (colors: any, spacing: any, shadows: any, typography: any) => StyleSheet.create({
|
||||
const makeStyles = (colors: any, spacing: any, _shadows: any, _typography: any) => StyleSheet.create({
|
||||
header: {
|
||||
paddingHorizontal: spacing.m,
|
||||
paddingBottom: spacing.m,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { initialRegistrationState, registrationReducer, RegistrationState } from '../registrationState';
|
||||
|
||||
const s = (over: Partial<RegistrationState> = {}): RegistrationState => ({
|
||||
...initialRegistrationState,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('registrationReducer', () => {
|
||||
it('navigates stages and never goes below 0', () => {
|
||||
expect(registrationReducer(s(), { type: 'NEXT_STAGE' }).stage).toBe(1);
|
||||
expect(registrationReducer(s({ stage: 2 }), { type: 'PREV_STAGE' }).stage).toBe(1);
|
||||
expect(registrationReducer(s({ stage: 0 }), { type: 'PREV_STAGE' }).stage).toBe(0);
|
||||
});
|
||||
|
||||
it('updates a single form field immutably', () => {
|
||||
const next = registrationReducer(s(), { type: 'SET_FIELD', field: 'orgPanNumber', value: 'ABCDE1234F' });
|
||||
expect(next.formData.orgPanNumber).toBe('ABCDE1234F');
|
||||
expect(next.formData.firstName).toBe('');
|
||||
expect(initialRegistrationState.formData.orgPanNumber).toBe('');
|
||||
});
|
||||
|
||||
it('prefills contact fields without clobbering others', () => {
|
||||
const next = registrationReducer(s(), {
|
||||
type: 'PREFILL_CONTACT',
|
||||
values: { firstName: 'A', email: 'a@b.c' },
|
||||
});
|
||||
expect(next.formData.firstName).toBe('A');
|
||||
expect(next.formData.email).toBe('a@b.c');
|
||||
expect(next.formData.lastName).toBe('');
|
||||
});
|
||||
|
||||
it('runs the PAN verification lifecycle', () => {
|
||||
let state = registrationReducer(s(), { type: 'PAN_VERIFY_START' });
|
||||
expect(state.panVerifying).toBe(true);
|
||||
state = registrationReducer(state, { type: 'PAN_VERIFY_DONE', verified: true });
|
||||
expect(state).toMatchObject({ panVerifying: false, panVerified: true });
|
||||
});
|
||||
|
||||
it('resets the ref number when requesting a new Aadhaar OTP', () => {
|
||||
let state = s({ aadhaarRefNumber: 'old-ref' });
|
||||
state = registrationReducer(state, { type: 'AADHAAR_OTP_REQUEST' });
|
||||
expect(state).toMatchObject({ aadhaarVerifying: true, aadhaarRefNumber: '' });
|
||||
state = registrationReducer(state, { type: 'AADHAAR_OTP_DONE', sent: true, refNumber: 'ref-1' });
|
||||
expect(state).toMatchObject({ aadhaarVerifying: false, aadhaarOtpSent: true, aadhaarRefNumber: 'ref-1' });
|
||||
});
|
||||
|
||||
it('leaves the verified flag untouched on a failed Aadhaar verify', () => {
|
||||
let state = s({ aadhaarVerified: true, aadhaarVerifying: true });
|
||||
state = registrationReducer(state, { type: 'AADHAAR_VERIFY_DONE' });
|
||||
expect(state).toMatchObject({ aadhaarVerifying: false, aadhaarVerified: true });
|
||||
});
|
||||
|
||||
it('stores a picked document under its field', () => {
|
||||
const file = { name: 'pan.pdf' };
|
||||
const next = registrationReducer(s(), { type: 'SET_DOCUMENT', field: 'panCard', file });
|
||||
expect((next.documents as any).panCard).toBe(file);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { initialRegistrationState } from '../registrationState';
|
||||
import { validatePanFormat, validateStage } from '../registrationValidation';
|
||||
|
||||
const base = () => ({
|
||||
formData: { ...initialRegistrationState.formData },
|
||||
documents: { ...initialRegistrationState.documents },
|
||||
selectedOrg: 1 as any,
|
||||
});
|
||||
|
||||
describe('validateStage', () => {
|
||||
it('stage 0 requires an organization name', () => {
|
||||
expect(validateStage(0, base()).valid).toBe(false);
|
||||
const input = base();
|
||||
input.formData.organizationName = 'Acme';
|
||||
expect(validateStage(0, input).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('stage 1 requires fewer fields for individual (org 8)', () => {
|
||||
const individual = base();
|
||||
individual.selectedOrg = 8;
|
||||
individual.formData.orgType = '2';
|
||||
individual.formData.socialCategory = '1';
|
||||
expect(validateStage(1, individual).valid).toBe(true);
|
||||
|
||||
const company = base();
|
||||
company.formData.orgType = '2';
|
||||
company.formData.socialCategory = '1';
|
||||
expect(validateStage(1, company).valid).toBe(false); // missing regno/detail
|
||||
});
|
||||
|
||||
it('stage 2 enforces Aadhaar and OTP lengths', () => {
|
||||
const input = base();
|
||||
Object.assign(input.formData, {
|
||||
orgPanNumber: 'ABCDE1234F',
|
||||
dateOfIncorp: '01/01/2020',
|
||||
orgPanName: 'Acme',
|
||||
aadhaarNumber: '123456789012',
|
||||
aadhaarOtp: '123456',
|
||||
});
|
||||
expect(validateStage(2, input).valid).toBe(true);
|
||||
|
||||
input.formData.aadhaarNumber = '1234';
|
||||
expect(validateStage(2, input)).toMatchObject({
|
||||
valid: false,
|
||||
message: 'Aadhaar number must be 12 digits.',
|
||||
});
|
||||
});
|
||||
|
||||
it('stage 3 enforces the pin code length', () => {
|
||||
const input = base();
|
||||
Object.assign(input.formData, { address: 'x', state: '1', city: '2', pinCode: '12345' });
|
||||
expect(validateStage(3, input)).toMatchObject({
|
||||
valid: false,
|
||||
message: 'Please enter a valid 6-digit pin code.',
|
||||
});
|
||||
input.formData.pinCode = '123456';
|
||||
expect(validateStage(3, input).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('stage 4 requires only PAN + Aadhaar files for individual (org 8)', () => {
|
||||
const input = base();
|
||||
input.selectedOrg = 8;
|
||||
expect(validateStage(4, input).valid).toBe(false);
|
||||
(input.documents as any).panCard = { name: 'pan.pdf' };
|
||||
(input.documents as any).aadharCard = { name: 'aadhaar.pdf' };
|
||||
expect(validateStage(4, input).valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePanFormat', () => {
|
||||
it('accepts a valid PAN in any case', () => {
|
||||
expect(validatePanFormat('abcde1234f').valid).toBe(true);
|
||||
expect(validatePanFormat('ABCDE1234F').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects malformed PANs', () => {
|
||||
expect(validatePanFormat('1234').valid).toBe(false);
|
||||
expect(validatePanFormat('ABCDE12345').valid).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Building2,
|
||||
ScrollText,
|
||||
CheckCircle,
|
||||
Upload,
|
||||
} from 'lucide-react-native';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
import { useTheme } from '../../../Hooks/useTheme';
|
||||
@@ -35,7 +34,7 @@ const StageFour: React.FC<UploadDocumentsSectionProps> = ({
|
||||
formData,
|
||||
selectedOrg,
|
||||
}) => {
|
||||
const { colors, spacing, shadows } = useTheme();
|
||||
const { colors, spacing } = useTheme();
|
||||
|
||||
const documentItems: DocumentItem[] = [
|
||||
{ key: 'panCard', label: 'PAN Card', icon: 'pan' },
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import DatePicker from 'react-native-date-picker';
|
||||
import MyInput from '../../../components/MyInput';
|
||||
import MyDropdown from '../../../Component/Dropdown/MyDropdown';
|
||||
import MyDropdown from '../../../components/MyDropdown';
|
||||
import { DropdownOption } from '../types/types';
|
||||
import { useTheme } from '../../../Hooks/useTheme';
|
||||
import AppText from '../../../components/AppText';
|
||||
@@ -23,8 +23,6 @@ interface Props {
|
||||
allOrganizations: DropdownOption[];
|
||||
allMsmeOrgs: DropdownOption[];
|
||||
allSocialCategories: DropdownOption[];
|
||||
allMSME: DropdownOption[];
|
||||
dropdownStyle: object;
|
||||
setSelectedOrg: (val: number) => void;
|
||||
selectedOrg: string | number;
|
||||
}
|
||||
@@ -38,7 +36,6 @@ const StageOne: React.FC<Props> = ({
|
||||
allOrganizations,
|
||||
allMsmeOrgs,
|
||||
allSocialCategories,
|
||||
dropdownStyle,
|
||||
setSelectedOrg,
|
||||
selectedOrg,
|
||||
}) => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { DropdownOption } from '../types/types';
|
||||
import MyInput from '../../../components/MyInput';
|
||||
import MyDropdown from '../../../Component/Dropdown/MyDropdown';
|
||||
import MyDropdown from '../../../components/MyDropdown';
|
||||
import { useTheme } from '../../../Hooks/useTheme';
|
||||
import AppText from '../../../components/AppText';
|
||||
|
||||
|
||||
@@ -33,9 +33,6 @@ interface Props {
|
||||
setadharotpsend: (value: boolean) => void;
|
||||
verifyAdharOtpHandler: () => void;
|
||||
aadhaarVerifying: boolean;
|
||||
verifyGSTHandler: () => void;
|
||||
gstverifying: boolean;
|
||||
color: any;
|
||||
styles: any;
|
||||
formatDateInput: (text: string) => string;
|
||||
selectedOrg: string | number;
|
||||
@@ -57,14 +54,11 @@ const StageTwo: React.FC<Props> = ({
|
||||
setadharotpsend,
|
||||
verifyAdharOtpHandler,
|
||||
aadhaarVerifying,
|
||||
verifyGSTHandler,
|
||||
gstverifying,
|
||||
color,
|
||||
styles,
|
||||
formatDateInput,
|
||||
selectedOrg,
|
||||
}) => {
|
||||
const { colors, spacing, shadows } = useTheme();
|
||||
const { colors, spacing } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [date, setDate] = useState(new Date());
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import MyInput from '../../../components/MyInput';
|
||||
import { useTheme } from '../../../Hooks/useTheme';
|
||||
import AppText from '../../../components/AppText';
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Registration wizard state — one reducer instead of the historical ~20
|
||||
* scattered useStates. Every action's semantics mirror the exact setState
|
||||
* sequences it replaced; async flows dispatch a *_START action, do the work,
|
||||
* then dispatch a *_DONE action.
|
||||
*/
|
||||
import { DocumentsType, FormDataType, OrgType } from './types/types';
|
||||
|
||||
export type DropdownItem = { label: string; value: number | string };
|
||||
|
||||
export type MasterLists = {
|
||||
contractorClasses: DropdownItem[];
|
||||
organizations: DropdownItem[];
|
||||
socialCategories: DropdownItem[];
|
||||
msmeOrgs: DropdownItem[];
|
||||
states: DropdownItem[];
|
||||
};
|
||||
|
||||
export type RegistrationState = {
|
||||
stage: number;
|
||||
focusedField: string | null;
|
||||
selectedOrg: OrgType;
|
||||
formData: FormDataType;
|
||||
documents: DocumentsType;
|
||||
|
||||
panVerifying: boolean;
|
||||
panVerified: boolean;
|
||||
aadhaarVerifying: boolean;
|
||||
aadhaarVerified: boolean;
|
||||
aadhaarOtpSent: boolean;
|
||||
aadhaarRefNumber: string;
|
||||
gstVerifying: boolean;
|
||||
gstVerified: boolean;
|
||||
|
||||
masterLists: MasterLists;
|
||||
cities: DropdownItem[];
|
||||
submitting: boolean;
|
||||
};
|
||||
|
||||
export const initialRegistrationState: RegistrationState = {
|
||||
stage: 0,
|
||||
focusedField: null,
|
||||
selectedOrg: 0,
|
||||
formData: {
|
||||
firstName: '',
|
||||
middleName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
mobileNumber: '',
|
||||
organizationName: '',
|
||||
officialWebsite: '',
|
||||
registrationNumber: '',
|
||||
contractorClass: '',
|
||||
orgType: '',
|
||||
registrationDetail: '',
|
||||
socialCategory: '',
|
||||
orgPanNumber: '',
|
||||
dateOfIncorp: '',
|
||||
orgPanName: '',
|
||||
gstnNo: '',
|
||||
aadhaarNumber: '',
|
||||
aadhaarOtp: '',
|
||||
address: '',
|
||||
state: '',
|
||||
city: '',
|
||||
pinCode: '',
|
||||
agentContact2: '',
|
||||
expiryDate: '',
|
||||
},
|
||||
documents: {
|
||||
panCard: null,
|
||||
gstnCertificate: null,
|
||||
registrationCertificate: null,
|
||||
msmefile: null,
|
||||
constractorClass: null,
|
||||
aadharCard: null,
|
||||
},
|
||||
|
||||
panVerifying: false,
|
||||
panVerified: false,
|
||||
aadhaarVerifying: false,
|
||||
aadhaarVerified: false,
|
||||
aadhaarOtpSent: false,
|
||||
aadhaarRefNumber: '',
|
||||
gstVerifying: false,
|
||||
gstVerified: false,
|
||||
|
||||
masterLists: {
|
||||
contractorClasses: [],
|
||||
organizations: [],
|
||||
socialCategories: [],
|
||||
msmeOrgs: [],
|
||||
states: [],
|
||||
},
|
||||
cities: [],
|
||||
submitting: false,
|
||||
};
|
||||
|
||||
export type RegistrationAction =
|
||||
| { type: 'NEXT_STAGE' }
|
||||
| { type: 'PREV_STAGE' }
|
||||
| { type: 'SET_FOCUSED_FIELD'; field: string | null }
|
||||
| { type: 'SET_SELECTED_ORG'; org: OrgType }
|
||||
| { type: 'SET_FIELD'; field: keyof FormDataType; value: string }
|
||||
| { type: 'PREFILL_CONTACT'; values: Partial<FormDataType> }
|
||||
| { type: 'SET_DOCUMENT'; field: string; file: any }
|
||||
| { type: 'MASTER_DATA_LOADED'; lists: MasterLists }
|
||||
| { type: 'CITIES_LOADED'; cities: DropdownItem[] }
|
||||
| { type: 'PAN_VERIFY_START' }
|
||||
| { type: 'PAN_VERIFY_DONE'; verified: boolean }
|
||||
| { type: 'SET_PAN_VERIFIED'; verified: boolean }
|
||||
| { type: 'AADHAAR_OTP_REQUEST' }
|
||||
| { type: 'AADHAAR_OTP_DONE'; sent: boolean; refNumber: string }
|
||||
| { type: 'SET_AADHAAR_OTP_SENT'; sent: boolean }
|
||||
| { type: 'AADHAAR_VERIFY_START' }
|
||||
/** `verified` omitted → leave the current verified flag untouched (failure path). */
|
||||
| { type: 'AADHAAR_VERIFY_DONE'; verified?: boolean }
|
||||
| { type: 'SET_AADHAAR_VERIFIED'; verified: boolean }
|
||||
| { type: 'GST_VERIFY_START' }
|
||||
/** `verified` omitted → leave the current verified flag untouched (failure path). */
|
||||
| { type: 'GST_VERIFY_DONE'; verified?: boolean }
|
||||
| { type: 'SUBMIT_START' }
|
||||
| { type: 'SUBMIT_END' };
|
||||
|
||||
export function registrationReducer(
|
||||
state: RegistrationState,
|
||||
action: RegistrationAction,
|
||||
): RegistrationState {
|
||||
switch (action.type) {
|
||||
case 'NEXT_STAGE':
|
||||
return { ...state, stage: state.stage + 1 };
|
||||
case 'PREV_STAGE':
|
||||
return state.stage > 0 ? { ...state, stage: state.stage - 1 } : state;
|
||||
case 'SET_FOCUSED_FIELD':
|
||||
return { ...state, focusedField: action.field };
|
||||
case 'SET_SELECTED_ORG':
|
||||
return { ...state, selectedOrg: action.org };
|
||||
case 'SET_FIELD':
|
||||
return { ...state, formData: { ...state.formData, [action.field]: action.value } };
|
||||
case 'PREFILL_CONTACT':
|
||||
return { ...state, formData: { ...state.formData, ...action.values } };
|
||||
case 'SET_DOCUMENT':
|
||||
return { ...state, documents: { ...state.documents, [action.field]: action.file } };
|
||||
case 'MASTER_DATA_LOADED':
|
||||
return { ...state, masterLists: action.lists };
|
||||
case 'CITIES_LOADED':
|
||||
return { ...state, cities: action.cities };
|
||||
|
||||
case 'PAN_VERIFY_START':
|
||||
return { ...state, panVerifying: true };
|
||||
case 'PAN_VERIFY_DONE':
|
||||
return { ...state, panVerifying: false, panVerified: action.verified };
|
||||
case 'SET_PAN_VERIFIED':
|
||||
return { ...state, panVerified: action.verified };
|
||||
|
||||
case 'AADHAAR_OTP_REQUEST':
|
||||
return { ...state, aadhaarVerifying: true, aadhaarRefNumber: '' };
|
||||
case 'AADHAAR_OTP_DONE':
|
||||
return {
|
||||
...state,
|
||||
aadhaarVerifying: false,
|
||||
aadhaarOtpSent: action.sent,
|
||||
aadhaarRefNumber: action.refNumber,
|
||||
};
|
||||
case 'SET_AADHAAR_OTP_SENT':
|
||||
return { ...state, aadhaarOtpSent: action.sent };
|
||||
|
||||
case 'AADHAAR_VERIFY_START':
|
||||
return { ...state, aadhaarVerifying: true };
|
||||
case 'AADHAAR_VERIFY_DONE':
|
||||
return {
|
||||
...state,
|
||||
aadhaarVerifying: false,
|
||||
aadhaarVerified: action.verified ?? state.aadhaarVerified,
|
||||
};
|
||||
case 'SET_AADHAAR_VERIFIED':
|
||||
return { ...state, aadhaarVerified: action.verified };
|
||||
|
||||
case 'GST_VERIFY_START':
|
||||
return { ...state, gstVerifying: true };
|
||||
case 'GST_VERIFY_DONE':
|
||||
return {
|
||||
...state,
|
||||
gstVerifying: false,
|
||||
gstVerified: action.verified ?? state.gstVerified,
|
||||
};
|
||||
|
||||
case 'SUBMIT_START':
|
||||
return { ...state, submitting: true };
|
||||
case 'SUBMIT_END':
|
||||
return { ...state, submitting: false };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Pure per-stage validation for the registration wizard. No UI, no toasts —
|
||||
* the screen decides how to present the returned message.
|
||||
*
|
||||
* The comparisons intentionally mirror the historical logic exactly
|
||||
* (including loose equality where dropdowns may yield string or number).
|
||||
*/
|
||||
import { DocumentsType, FormDataType, OrgType } from './types/types';
|
||||
|
||||
export type StageValidationInput = {
|
||||
formData: FormDataType;
|
||||
documents: DocumentsType;
|
||||
selectedOrg: OrgType;
|
||||
};
|
||||
|
||||
export type StageValidationResult = { valid: boolean; message?: string };
|
||||
|
||||
const ok: StageValidationResult = { valid: true };
|
||||
const fail = (message: string): StageValidationResult => ({ valid: false, message });
|
||||
|
||||
export const validateStage = (
|
||||
stage: number,
|
||||
{ formData, documents, selectedOrg }: StageValidationInput,
|
||||
): StageValidationResult => {
|
||||
switch (stage) {
|
||||
case 0:
|
||||
if (!formData.organizationName?.trim()) {
|
||||
return fail('Please fill all required details.');
|
||||
}
|
||||
return ok;
|
||||
|
||||
case 1:
|
||||
// eslint-disable-next-line eqeqeq
|
||||
if (selectedOrg == 8) {
|
||||
if (!formData.orgType || !formData.socialCategory) {
|
||||
return fail('Please enter all valid details.');
|
||||
}
|
||||
} else if (
|
||||
!formData.registrationNumber ||
|
||||
!formData.registrationDetail ||
|
||||
!formData.socialCategory ||
|
||||
!formData.orgType
|
||||
) {
|
||||
return fail('Please enter all valid details.');
|
||||
}
|
||||
return ok;
|
||||
|
||||
case 2:
|
||||
if (
|
||||
!formData.orgPanNumber ||
|
||||
!formData.dateOfIncorp ||
|
||||
!formData.orgPanName ||
|
||||
// eslint-disable-next-line eqeqeq
|
||||
(selectedOrg == 8 && !formData.gstnNo) ||
|
||||
!formData.aadhaarNumber ||
|
||||
!formData.aadhaarOtp
|
||||
) {
|
||||
return fail('Please fill all required KYC and Aadhaar details.');
|
||||
}
|
||||
if (formData.aadhaarNumber.length !== 12) {
|
||||
return fail('Aadhaar number must be 12 digits.');
|
||||
}
|
||||
if (formData.aadhaarOtp.length !== 6) {
|
||||
return fail('OTP must be 6 digits.');
|
||||
}
|
||||
return ok;
|
||||
|
||||
case 3:
|
||||
if (!formData.address || !formData.state || !formData.city || !formData.pinCode) {
|
||||
return fail('Please fill all required address details.');
|
||||
}
|
||||
if (formData.pinCode.length !== 6) {
|
||||
return fail('Please enter a valid 6-digit pin code.');
|
||||
}
|
||||
return ok;
|
||||
|
||||
case 4:
|
||||
// eslint-disable-next-line eqeqeq
|
||||
if (selectedOrg == 8) {
|
||||
if (!documents.panCard || !documents.aadharCard) {
|
||||
return fail('Please upload all required documents.');
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
if ((formData.contractorClass as unknown) !== 1 && !documents.constractorClass) {
|
||||
return fail('Please upload CONTRACTOR CLASS documents.');
|
||||
}
|
||||
if ((formData.registrationDetail as unknown) !== 3 && !documents.msmefile) {
|
||||
return fail('Please upload MSME FILE.');
|
||||
}
|
||||
if (!documents.gstnCertificate || !documents.registrationCertificate) {
|
||||
return fail('Please upload all required documents.');
|
||||
}
|
||||
return ok;
|
||||
|
||||
default:
|
||||
return ok;
|
||||
}
|
||||
};
|
||||
|
||||
/** PAN format gate applied before leaving the KYC stage. */
|
||||
export const validatePanFormat = (pan: string): StageValidationResult => {
|
||||
const panRegex = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
|
||||
if (!panRegex.test(pan.toUpperCase())) {
|
||||
return fail("Invalid PAN format. It should be like 'ABCDE1234F'.");
|
||||
}
|
||||
return ok;
|
||||
};
|
||||
@@ -11,13 +11,13 @@ import {
|
||||
ScrollView,
|
||||
ToastAndroid,
|
||||
} from 'react-native';
|
||||
import { User, Phone, Mail, Lock, CheckSquare, Square, Shield, UserPlus, Contact, Eye, EyeOff } from 'lucide-react-native';
|
||||
import { User, Phone, Mail, CheckSquare, Square, Shield, UserPlus, Contact, Eye, EyeOff } from 'lucide-react-native';
|
||||
import { useGetDesignation, useRegisterContractor } from '../../Services/ApiCall/Hooks';
|
||||
import MyDropdown from '../../Component/Dropdown/MyDropdown';
|
||||
import MyDropdown from '../../components/MyDropdown';
|
||||
import PrimaryButton from '../../components/PrimaryButton';
|
||||
import MyInput from '../../components/MyInput';
|
||||
import InsideHeader from '../../Component/Header/InsideHeader';
|
||||
import TermsAndConditionsModal from '../../Component/TermsAndConditionsModal/TermsAndConditionsModal';
|
||||
import InsideHeader from '../../components/InsideHeader';
|
||||
import TermsAndConditionsModal from '../../components/TermsAndConditionsModal';
|
||||
import AppText from '../../components/AppText';
|
||||
import { Formik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
@@ -111,7 +111,6 @@ const Signup = () => {
|
||||
|
||||
registerContractor(formData, {
|
||||
onSuccess: (res: any) => {
|
||||
console.log('Register Res : ', res)
|
||||
if (res?.status === '201') {
|
||||
ToastAndroid.show(`${res.message}`, ToastAndroid.SHORT);
|
||||
navigation.pop();
|
||||
@@ -120,8 +119,7 @@ const Signup = () => {
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.log('Register Res : ', error)
|
||||
ToastAndroid.show(error.message, ToastAndroid.SHORT);
|
||||
ToastAndroid.show(error?.message || 'Registration failed', ToastAndroid.SHORT);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { Animated, StyleSheet, View } from 'react-native';
|
||||
import { useNavigation, useIsFocused } from '@react-navigation/native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { performLogout } from '../../Services/Auth/session';
|
||||
import { applyRouteDecision, decideRegistrationRoute } from '../../Services/Auth/registrationRouting';
|
||||
import { logger } from '../../core/observability/logger';
|
||||
import { showErrorAlert, showToast } from '../../Constant/ShowDailog';
|
||||
import { useRagistrationState } from '../../Services/ApiCall/Hooks';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
@@ -27,84 +29,37 @@ const Splash: React.FC = () => {
|
||||
const { mutateAsync: registrationStateMutate } = useRagistrationState();
|
||||
const hasSessionToken = Boolean(reduxToken && typeof reduxToken === 'object' && reduxToken.token);
|
||||
|
||||
const routeByOrgType = useCallback((params: { otherId: any; orgType: any }) => {
|
||||
const orgType = String(params.orgType ?? '');
|
||||
if (orgType === '4' || orgType === '5' || orgType === '6') {
|
||||
navigation.replace(NavigationString.StageThreeScreen, params);
|
||||
return;
|
||||
}
|
||||
|
||||
if (orgType === '7') {
|
||||
navigation.replace(NavigationString.Partnership, params);
|
||||
return;
|
||||
}
|
||||
|
||||
navigation.replace('Dashboard');
|
||||
}, [navigation]);
|
||||
|
||||
const handleGetData = useCallback(async () => {
|
||||
try {
|
||||
const res = await registrationStateMutate();
|
||||
console.log("splash", res);
|
||||
if (res?.status === false) {
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [{ name: NavigationString.Login }],
|
||||
});
|
||||
const decision = decideRegistrationRoute(res);
|
||||
if (decision) {
|
||||
applyRouteDecision(navigation, decision);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res?.data?.message === 'No Response From Server' && res?.data?.success === false) {
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [{ name: NavigationString.Login }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (res?.statusCode === 404 || res?.statusCode === 401) {
|
||||
navigation.replace('Registration');
|
||||
return;
|
||||
}
|
||||
|
||||
if (res?.statusCode === 200 && res?.data) {
|
||||
const { registrationStatus, contOtherId, contOrganization } = res.data;
|
||||
const params = { otherId: contOtherId, orgType: contOrganization };
|
||||
switch (registrationStatus) {
|
||||
case null:
|
||||
case undefined:
|
||||
navigation.replace(NavigationString.Registration);
|
||||
break;
|
||||
case 'Pending':
|
||||
routeByOrgType(params);
|
||||
break;
|
||||
case 'Complete':
|
||||
case 'Completed':
|
||||
navigation.replace(NavigationString.Home);
|
||||
break;
|
||||
default:
|
||||
navigation.replace(NavigationString.Login);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error('API error:', res);
|
||||
logger.warn('auth', 'unclassifiable registration-state response on splash', {
|
||||
statusCode: res?.statusCode,
|
||||
});
|
||||
showErrorAlert(res?.error || 'Something went wrong. Please try re-logging in.');
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [{ name: NavigationString.Login }],
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Registration status error:', error);
|
||||
logger.error('auth', 'registration status resolution failed on splash', {
|
||||
error: String(error),
|
||||
});
|
||||
showToast(error?.message || 'Something went wrong');
|
||||
|
||||
if (error?.response?.status === 404) {
|
||||
navigation.replace('Registration');
|
||||
} else {
|
||||
await AsyncStorage.removeItem('persist:root');
|
||||
performLogout();
|
||||
navigation.replace(NavigationString.Login);
|
||||
}
|
||||
}
|
||||
}, [navigation, registrationStateMutate, routeByOrgType]);
|
||||
}, [navigation, registrationStateMutate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused || !isPersistRehydrated) {
|
||||
|
||||
@@ -1,532 +1,78 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import RNFS from 'react-native-fs';
|
||||
import * as XLSX from 'xlsx';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { useSelector } from 'react-redux';
|
||||
import colors from '../../../Constant/Color';
|
||||
import { GET_ENVELOPE_C, SUBMIT_ENVELOPE_C_UPLOAD } from '../../../Services/ApiCall/ApiUrls';
|
||||
/**
|
||||
* EnvelopeCExcelPreview — launcher for the native BOQ (Envelope-C) editor.
|
||||
*
|
||||
* The Excel-like editor is a full-screen, virtualized surface, so instead of
|
||||
* embedding a spreadsheet inside the Tender-Bidding scroll view this presents a
|
||||
* compact card that opens the dedicated `EnvelopeCSpreadsheet` screen. Props are
|
||||
* unchanged from the previous inline preview so the call site keeps working:
|
||||
*
|
||||
* <EnvelopeCExcelPreview tenderId={tenderData} buttonEnable />
|
||||
*/
|
||||
import React from 'react';
|
||||
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { encrypt, encryptWithRSA } from '../../../Services/Encryption/encrypt';
|
||||
import { FileSpreadsheet, ChevronRight } from 'lucide-react-native';
|
||||
|
||||
import AppText from '../../../components/AppText';
|
||||
import { useTheme } from '../../../Hooks/useTheme';
|
||||
import NavigationString from '../../../Constant/NavigationString';
|
||||
|
||||
/* -------------------- UTILS -------------------- */
|
||||
interface OfficerKey {
|
||||
officersId: number;
|
||||
officerPublicKey: string;
|
||||
officerType: string;
|
||||
interface EnvelopeCExcelPreviewProps {
|
||||
/** The full proceed-to-bid tender data object. */
|
||||
tenderId: any;
|
||||
buttonEnable?: boolean;
|
||||
}
|
||||
|
||||
const EnvelopeCExcelPreview: React.FC<EnvelopeCExcelPreviewProps> = ({ tenderId, buttonEnable }) => {
|
||||
const navigation = useNavigation<any>();
|
||||
const { colors, spacing } = useTheme();
|
||||
|
||||
interface SaveEnvelopeOfficerDetail {
|
||||
officerId: number;
|
||||
officerEncryptedKey: string;
|
||||
officerType: string;
|
||||
}
|
||||
|
||||
// Convert ArrayBuffer → Base64
|
||||
const arrayBufferToBase64 = (buffer: ArrayBuffer) => {
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return global.btoa(binary);
|
||||
};
|
||||
|
||||
|
||||
// Find column dynamically by keywords
|
||||
const findColumnKey = (columns: string[], keywords: string[]) =>
|
||||
columns.find(col =>
|
||||
keywords.some(k => col.toLowerCase().includes(k.toLowerCase()))
|
||||
);
|
||||
|
||||
|
||||
// Detect TOTAL row (any column contains TOTAL)
|
||||
const isTotalRow = (row: any) =>
|
||||
Object.values(row).some(v => String(v).toUpperCase() === 'TOTAL');
|
||||
|
||||
|
||||
/* -------------------- COMPONENT -------------------- */
|
||||
|
||||
|
||||
const EnvelopeCExcelPreview = (props: any) => {
|
||||
const tenderData = props;
|
||||
console.log("Tender Data", tenderData)
|
||||
const token = useSelector((state: any) => state.token.token);
|
||||
const navigation = useNavigation() as any;
|
||||
|
||||
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [columns, setColumns] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
loadExcelFromApi();
|
||||
}, []);
|
||||
|
||||
|
||||
/* -------------------- LOAD EXCEL -------------------- */
|
||||
|
||||
|
||||
const loadExcelFromApi = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
console.log('\n📊 LOADING EXCEL FROM API');
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
console.log('🔗 API URL:', `${GET_ENVELOPE_C}${tenderData?.tenderId?.tenderId}`);
|
||||
console.log('🆔 Tender ID:', tenderData?.tenderId?.tenderId);
|
||||
console.log('🔑 Token:', token.token ? 'Present' : 'Missing');
|
||||
|
||||
|
||||
const response = await axios.get(
|
||||
`${GET_ENVELOPE_C}${tenderData?.tenderId?.tenderId}`,
|
||||
{
|
||||
responseType: 'arraybuffer',
|
||||
headers: {
|
||||
Authentication: `Bearer ${token.token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
console.log('\n✅ API RESPONSE RECEIVED');
|
||||
console.log('───────────────────────────────────────────────────────');
|
||||
console.log('📡 Status:', response.status);
|
||||
console.log('📡 Status Text:', response.statusText);
|
||||
console.log('📦 Response Data Size (KB):', (response.data.byteLength / 1024).toFixed(2));
|
||||
|
||||
|
||||
const base64 = arrayBufferToBase64(response.data);
|
||||
const workbook = XLSX.read(base64, { type: 'base64' });
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const rows: any[] = XLSX.utils.sheet_to_json(sheet, { defval: '' });
|
||||
|
||||
|
||||
console.log('📊 PARSED DATA - Total Rows:', rows.length);
|
||||
|
||||
|
||||
setColumns(Object.keys(rows[0] || {}));
|
||||
setData(rows);
|
||||
|
||||
|
||||
console.log('✅ EXCEL LOADED SUCCESSFULLY\n');
|
||||
|
||||
|
||||
} catch (e: any) {
|
||||
console.log('\n❌ ERROR LOADING EXCEL');
|
||||
console.error('Error:', e.message);
|
||||
Alert.alert('Error', 'Failed to load Excel: ' + e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const openEditor = () => {
|
||||
navigation.navigate(NavigationString.EnvelopeCSpreadsheet, {
|
||||
tender: tenderId,
|
||||
buttonEnable: buttonEnable ?? true,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/* -------------------- UPDATE CELL -------------------- */
|
||||
|
||||
|
||||
const updateCell = (rowIndex: number, key: string, value: string) => {
|
||||
const updated = [...data];
|
||||
const row = updated[rowIndex];
|
||||
|
||||
|
||||
if (isTotalRow(row)) return;
|
||||
|
||||
|
||||
const qtyKey: any = findColumnKey(columns, ['qty', 'quantity']);
|
||||
const rateKey: any = findColumnKey(columns, ['rate', 'price']);
|
||||
const valueKey = findColumnKey(columns, ['value', 'amount', 'total']);
|
||||
|
||||
|
||||
if (key === qtyKey || key === rateKey) {
|
||||
row[key] = value;
|
||||
|
||||
|
||||
const qty = Number(row[qtyKey]);
|
||||
const rate = Number(row[rateKey]);
|
||||
|
||||
|
||||
if (valueKey) {
|
||||
row[valueKey] =
|
||||
!isNaN(qty) && !isNaN(rate) ? qty * rate : 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Recalculate TOTAL
|
||||
const totalRowIndex = updated.findIndex(isTotalRow);
|
||||
if (totalRowIndex !== -1 && valueKey) {
|
||||
updated[totalRowIndex][valueKey] = updated.reduce((sum, r) => {
|
||||
if (isTotalRow(r)) return sum;
|
||||
const val = Number(r[valueKey]);
|
||||
return sum + (isNaN(val) ? 0 : val);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
|
||||
setData(updated);
|
||||
};
|
||||
|
||||
|
||||
/* -------------------- SAVE & UPLOAD EXCEL -------------------- */
|
||||
|
||||
|
||||
const buildOfficerEncryptedKeys = (envelopeKey: string): SaveEnvelopeOfficerDetail[] => {
|
||||
const officerKeys: OfficerKey[] = tenderData?.tenderId?.envelopeKeyDetailsResDTO?.departmentOfficerPublicKeys || [];
|
||||
|
||||
|
||||
console.log('\n🔐 ENCRYPTING ENVELOPE KEY FOR OFFICERS');
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
console.log('📌 Envelope Key to encrypt:', envelopeKey);
|
||||
console.log('👥 Number of officers:', officerKeys.length);
|
||||
|
||||
|
||||
const encryptedKeys: SaveEnvelopeOfficerDetail[] = [];
|
||||
|
||||
|
||||
for (let i = 0; i < officerKeys.length; i++) {
|
||||
const officer = officerKeys[i];
|
||||
console.log(`\n🔄 Officer ${i + 1}/${officerKeys.length}`);
|
||||
console.log(' ID:', officer.officersId);
|
||||
console.log(' Type:', officer.officerType);
|
||||
|
||||
|
||||
try {
|
||||
const encryptedKey = encryptWithRSA(envelopeKey, officer.officerPublicKey);
|
||||
|
||||
|
||||
if (!encryptedKey) {
|
||||
console.error(` ❌ FAILED to encrypt key for officer ${officer.officersId}`);
|
||||
} else {
|
||||
console.log(` ✅ SUCCESS - Encrypted key length: ${encryptedKey.length}`);
|
||||
}
|
||||
|
||||
|
||||
encryptedKeys.push({
|
||||
officerId: officer.officersId,
|
||||
officerEncryptedKey: encryptedKey || '',
|
||||
officerType: officer.officerType,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(` ❌ ERROR encrypting for officer ${officer.officersId}:`, error);
|
||||
encryptedKeys.push({
|
||||
officerId: officer.officersId,
|
||||
officerEncryptedKey: '',
|
||||
officerType: officer.officerType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.log('\n✅ ALL OFFICER KEYS ENCRYPTED');
|
||||
console.log('═══════════════════════════════════════════════════════\n');
|
||||
return encryptedKeys;
|
||||
};
|
||||
|
||||
|
||||
// ✅ NEW: Encrypt Excel file content (binary) after creation
|
||||
const encryptExcelFile = async (filePath: string, encryptionKey: string): Promise<string> => {
|
||||
console.log('\n🔐 ENCRYPTING EXCEL FILE CONTENT');
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
console.log('📁 File Path:', filePath);
|
||||
console.log('🔑 Encryption Key:', encryptionKey);
|
||||
|
||||
|
||||
try {
|
||||
// Read the Excel file as Base64
|
||||
const fileContent = await RNFS.readFile(filePath, 'base64');
|
||||
console.log('📖 File read successfully, Base64 length:', fileContent.length);
|
||||
|
||||
|
||||
// Encrypt the entire file content
|
||||
console.log('🔒 Encrypting file content...');
|
||||
const encryptedContent: any = encrypt(fileContent, encryptionKey);
|
||||
console.log('✅ File content encrypted, Encrypted length:', encryptedContent?.length);
|
||||
|
||||
|
||||
// Save encrypted content to a new file
|
||||
const encryptedFilePath = `${RNFS.DocumentDirectoryPath}/envelopeC_encrypted.xlsx`;
|
||||
await RNFS.writeFile(encryptedFilePath, encryptedContent, 'utf8');
|
||||
console.log('💾 Encrypted file saved at:', encryptedFilePath);
|
||||
|
||||
|
||||
console.log('✅ EXCEL FILE ENCRYPTION COMPLETED');
|
||||
console.log('═══════════════════════════════════════════════════════\n');
|
||||
|
||||
|
||||
return encryptedFilePath;
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ ERROR ENCRYPTING EXCEL FILE:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const saveUpdatedExcel = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
console.log('\n💾 SAVING UPDATED EXCEL');
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
|
||||
|
||||
const ENCRYPTION_KEY = tenderData?.tenderId?.envelopeKeyDetailsResDTO?.envelopeCGenerateKey || '';
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
console.error('❌ ENCRYPTION KEY NOT FOUND');
|
||||
Alert.alert('Error', 'Encryption key not available');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
console.log('🔑 Encryption Key:', ENCRYPTION_KEY);
|
||||
|
||||
|
||||
// Step 1: Create normal Excel workbook with user data (NOT ENCRYPTED YET)
|
||||
console.log('\n📝 STEP 1: Creating Excel workbook with user data');
|
||||
console.log('───────────────────────────────────────────────────────');
|
||||
const worksheet = XLSX.utils.json_to_sheet(data);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
|
||||
|
||||
|
||||
const binary = XLSX.write(workbook, {
|
||||
bookType: 'xlsx',
|
||||
type: 'binary',
|
||||
});
|
||||
|
||||
|
||||
const buffer = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
buffer[i] = binary.charCodeAt(i) & 0xff;
|
||||
}
|
||||
|
||||
|
||||
// Step 2: Save the plain Excel file
|
||||
console.log('\n📝 STEP 2: Saving plain Excel file');
|
||||
console.log('───────────────────────────────────────────────────────');
|
||||
const plainFilePath = `${RNFS.DocumentDirectoryPath}/envelopeC_plain.xlsx`;
|
||||
await RNFS.writeFile(plainFilePath, buffer.toString(), 'ascii');
|
||||
console.log('✅ Plain Excel saved at:', plainFilePath);
|
||||
|
||||
|
||||
// Step 3: Encrypt the entire Excel file
|
||||
console.log('\n📝 STEP 3: Encrypting the Excel file');
|
||||
console.log('───────────────────────────────────────────────────────');
|
||||
const encryptedFilePath = await encryptExcelFile(plainFilePath, ENCRYPTION_KEY);
|
||||
console.log('✅ Encrypted Excel file ready');
|
||||
|
||||
|
||||
// Step 4: Build officer encrypted keys
|
||||
console.log('\n📝 STEP 4: Building officer encrypted keys');
|
||||
console.log('───────────────────────────────────────────────────────');
|
||||
const officerEncryptedKeys = buildOfficerEncryptedKeys(ENCRYPTION_KEY);
|
||||
|
||||
|
||||
// Step 5: Prepare FormData for upload
|
||||
console.log('\n📝 STEP 5: Building FormData');
|
||||
console.log('───────────────────────────────────────────────────────');
|
||||
const formData = new FormData();
|
||||
const requestDto = {
|
||||
tenderId: tenderData?.tenderId?.tenderId,
|
||||
deptId: tenderData?.tenderId?.deptId,
|
||||
typesOfTender: tenderData?.tenderId?.typesOfTender,
|
||||
fieldValue: '',
|
||||
fieldValueInWord: 'zero',
|
||||
deviceType: 'MOBILE',
|
||||
fileName: 'envelopeC_encrypted.xlsx',
|
||||
saveEnvelopesOfficersDetailsResDTO: officerEncryptedKeys,
|
||||
randomKeyGenerated: ENCRYPTION_KEY,
|
||||
};
|
||||
|
||||
|
||||
formData.append('requestDto', JSON.stringify(requestDto));
|
||||
|
||||
|
||||
// Use the ENCRYPTED file for upload
|
||||
formData.append('file', {
|
||||
uri: `file://${encryptedFilePath}`,
|
||||
name: 'envelopeC_encrypted.xlsx',
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
} as any);
|
||||
|
||||
|
||||
console.log('📋 Request DTO:', JSON.stringify(requestDto, null, 2));
|
||||
console.log('📎 Encrypted File Path:', encryptedFilePath);
|
||||
|
||||
|
||||
// Step 6: Upload to server
|
||||
console.log('\n📝 STEP 6: Uploading encrypted file to server');
|
||||
console.log('───────────────────────────────────────────────────────');
|
||||
console.log('FormData prepared:', formData);
|
||||
|
||||
|
||||
const response = await axios.post(SUBMIT_ENVELOPE_C_UPLOAD, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authentication: `Bearer ${token.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
console.log('✅ UPLOAD SUCCESSFUL');
|
||||
console.log('Response:', response.data);
|
||||
console.log('═══════════════════════════════════════════════════════\n');
|
||||
|
||||
|
||||
// Clean up temporary files
|
||||
await RNFS.unlink(plainFilePath).catch(e => console.log('Plain file cleanup:', e.message));
|
||||
await RNFS.unlink(encryptedFilePath).catch(e => console.log('Encrypted file cleanup:', e.message));
|
||||
|
||||
|
||||
Alert.alert('Success', 'Excel uploaded successfully');
|
||||
navigation.goBack();
|
||||
|
||||
|
||||
} catch (e: any) {
|
||||
console.error('❌ Error saving/uploading Excel:', {
|
||||
message: e?.message,
|
||||
response: e?.response?.data,
|
||||
status: e?.response?.status,
|
||||
statusText: e?.response?.statusText,
|
||||
});
|
||||
|
||||
|
||||
Alert.alert('Error', e?.response?.data?.message || 'Save failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/* -------------------- UI -------------------- */
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.loader}>
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const qtyKey = findColumnKey(columns, ['qty', 'quantity']);
|
||||
const rateKey = findColumnKey(columns, ['rate', 'price']);
|
||||
const valueKey = findColumnKey(columns, ['value', 'amount', 'total']);
|
||||
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView horizontal contentContainerStyle={{ padding: 10 }}>
|
||||
<View>
|
||||
{/* HEADER */}
|
||||
<View style={styles.row}>
|
||||
{columns.map(col => (
|
||||
<Text key={col} style={styles.headerCell}>{col}</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
|
||||
{/* DATA */}
|
||||
<ScrollView style={{ maxHeight: 420 }}>
|
||||
{data.map((row, rowIndex) => (
|
||||
<View key={rowIndex} style={styles.row}>
|
||||
{columns.map(key => {
|
||||
const editable =
|
||||
!isTotalRow(row) && (key === qtyKey || key === rateKey);
|
||||
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
key={key}
|
||||
value={String(row[key])}
|
||||
editable={editable}
|
||||
keyboardType={editable ? 'numeric' : 'default'}
|
||||
style={[
|
||||
styles.cell,
|
||||
isTotalRow(row) && styles.totalCell,
|
||||
key === valueKey && styles.valueCell,
|
||||
]}
|
||||
onChangeText={text =>
|
||||
updateCell(rowIndex, key, text)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
|
||||
{tenderData?.buttonEnable && (
|
||||
<TouchableOpacity style={styles.button} onPress={saveUpdatedExcel}>
|
||||
<Text style={styles.buttonText}>Save Updated Excel</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={openEditor}
|
||||
activeOpacity={0.85}
|
||||
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.primary }]}
|
||||
>
|
||||
<View style={[styles.iconWrap, { backgroundColor: `${colors.primary}18` }]}>
|
||||
<FileSpreadsheet size={22} color={colors.primary} />
|
||||
</View>
|
||||
<View style={{ flex: 1, marginHorizontal: spacing.s }}>
|
||||
<AppText variant="bodySmall" color={colors.text} style={{ fontWeight: '700' }}>
|
||||
Open Editor
|
||||
</AppText>
|
||||
<AppText variant="caption" color={colors.textSecondary}>
|
||||
Fill the editable cells, totals recalculate automatically. Formulas and locked cells are protected.
|
||||
</AppText>
|
||||
</View>
|
||||
<ChevronRight size={20} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1.5,
|
||||
padding: 14,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default EnvelopeCExcelPreview;
|
||||
|
||||
|
||||
/* -------------------- STYLES -------------------- */
|
||||
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#fff' },
|
||||
loader: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
row: { flexDirection: 'row' },
|
||||
headerCell: {
|
||||
minWidth: 140,
|
||||
padding: 10,
|
||||
backgroundColor: '#f1f5f9',
|
||||
borderWidth: 1,
|
||||
fontWeight: '700',
|
||||
},
|
||||
cell: {
|
||||
minWidth: 140,
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#cbd5e1',
|
||||
},
|
||||
valueCell: {
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
totalCell: {
|
||||
backgroundColor: '#e5e7eb',
|
||||
fontWeight: '700',
|
||||
},
|
||||
button: {
|
||||
margin: 15,
|
||||
backgroundColor: colors.primary,
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import { InsideHeader } from '../../Component/Index';
|
||||
import { InsideHeader } from '../../components';
|
||||
import AppText from '../../components/AppText';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||
@@ -11,10 +11,9 @@ import { BASE_URL } from '../../Services/ApiCall/ApiUrls';
|
||||
import Geolocation from 'react-native-geolocation-service';
|
||||
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
|
||||
import { Platform } from 'react-native';
|
||||
import { clampRGBA } from 'react-native-reanimated/lib/typescript/Colors';
|
||||
|
||||
const DeviceBinding = () => {
|
||||
const { colors, spacing, typography } = useTheme();
|
||||
const { colors, spacing } = useTheme();
|
||||
const navigation = useNavigation<any>();
|
||||
const route = useRoute<any>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { InsideHeader } from '../../Component/Index';
|
||||
import { InsideHeader } from '../../components';
|
||||
import AppText from '../../components/AppText';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||
import { AlertCircle } from 'lucide-react-native';
|
||||
|
||||
const DscBinding = () => {
|
||||
const { colors, spacing, typography } = useTheme();
|
||||
const { colors, spacing } = useTheme();
|
||||
const navigation = useNavigation<any>();
|
||||
const route = useRoute<any>();
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* EnvelopeCSpreadsheetScreen — full-screen native BOQ editor for Envelope-C.
|
||||
*
|
||||
* Opens the tender's BOQ template, renders the Excel-like editor (edit only the
|
||||
* originally-editable cells, live recalculation, validation), and submits the
|
||||
* metadata-preserving workbook through the existing Envelope-C API.
|
||||
*
|
||||
* Route params: { tender: <proceedToBid tender data>, buttonEnable?: boolean }.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, KeyboardAvoidingView, Platform, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||
import { FileUp, Send } from 'lucide-react-native';
|
||||
|
||||
import InsideHeader from '../../../components/InsideHeader';
|
||||
import AppText from '../../../components/AppText';
|
||||
import { useTheme } from '../../../Hooks/useTheme';
|
||||
import SpreadsheetView from '../../../Spreadsheet/components/SpreadsheetView';
|
||||
import useEnvelopeCWorkbook from './useEnvelopeCWorkbook';
|
||||
|
||||
const EnvelopeCSpreadsheetScreen: React.FC = () => {
|
||||
const route = useRoute<any>();
|
||||
const navigation = useNavigation<any>();
|
||||
const { colors, spacing } = useTheme();
|
||||
|
||||
const tender = route.params?.tender;
|
||||
const buttonEnable = route.params?.buttonEnable ?? true;
|
||||
|
||||
const { ss, submitting, submit, importFromDevice } = useEnvelopeCWorkbook(tender, () =>
|
||||
navigation.goBack(),
|
||||
);
|
||||
|
||||
const canSubmit = ss.status === 'ready' && !submitting && ss.errorCount === 0;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<InsideHeader title="Envelope C" showArrow />
|
||||
|
||||
{/* Keep the grid + footer above the keyboard. Android already resizes the
|
||||
window (adjustResize); iOS needs padding behaviour. */}
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<SpreadsheetView ss={ss} emptyHint="Loading the BOQ template…" />
|
||||
</View>
|
||||
|
||||
<View style={[styles.footer, { backgroundColor: colors.surface, borderTopColor: colors.border }]}>
|
||||
|
||||
|
||||
{buttonEnable && (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.primaryBtn,
|
||||
{ backgroundColor: canSubmit ? colors.primary : colors.secondary, marginLeft: spacing.s },
|
||||
]}
|
||||
onPress={submit}
|
||||
disabled={!canSubmit}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator size="small" color={colors.buttonText} />
|
||||
) : (
|
||||
<>
|
||||
<Send size={16} color={colors.buttonText} />
|
||||
<AppText variant="button" color={colors.buttonText}>
|
||||
{ss.errorCount > 0 ? `Fix ${ss.errorCount} error(s)` : 'Submit'}
|
||||
</AppText>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
secondaryBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
primaryBtn: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
},
|
||||
});
|
||||
|
||||
export default EnvelopeCSpreadsheetScreen;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* useEnvelopeCWorkbook — Envelope-C BOQ integration.
|
||||
*
|
||||
* Implements the mobile-bid encryption contract in MOBILE_BID_DSC_API_SPEC.md:
|
||||
* downloads the (plaintext) BOQ template into the editor, then on submit
|
||||
* 1. exports the metadata-preserving workbook bytes,
|
||||
* 2. AES/ECB encrypts the RAW file bytes with a SHA-1(K)-derived key,
|
||||
* 3. RSA-wraps the envelope key K with each officer's public key,
|
||||
* 4. uploads the raw ciphertext file + `randomKeyGenerated=K` + wrapped keys.
|
||||
*
|
||||
* Steps 2–3 are exactly what makes web rebid/opening able to RSA-unwrap the
|
||||
* key; without them the stored `contractor_enc_key` is a raw key and the DSC
|
||||
* unwrap times out.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import axios from 'axios';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import useSpreadsheet from '../../../Spreadsheet/hooks/useSpreadsheet';
|
||||
import FileManager from '../../../Spreadsheet/engine/FileManager';
|
||||
import {
|
||||
encrypt,
|
||||
encryptFileBytesToBase64,
|
||||
encryptWithRSA,
|
||||
sha256HexOfBytes,
|
||||
} from '../../../Services/Encryption/encrypt';
|
||||
import { GET_ENVELOPE_C, SUBMIT_ENVELOPE_C_UPLOAD } from '../../../Services/ApiCall/ApiUrls';
|
||||
|
||||
interface OfficerKey {
|
||||
officersId: number;
|
||||
officerPublicKey: string;
|
||||
officerType: string;
|
||||
}
|
||||
|
||||
interface SaveEnvelopeOfficerDetail {
|
||||
officerId: number;
|
||||
officerEncryptedKey: string;
|
||||
officerType: string;
|
||||
}
|
||||
|
||||
const ENCRYPTED_FILE_NAME = 'envelopeC_encrypted.xlsx';
|
||||
|
||||
export const useEnvelopeCWorkbook = (tender: any, onSubmitted?: () => void) => {
|
||||
const ss = useSpreadsheet();
|
||||
const tokenObj = useSelector((state: any) => state.token?.token);
|
||||
const bearer = tokenObj?.token;
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const tenderId = tender?.tenderId;
|
||||
const encryptionKey: string = tender?.envelopeKeyDetailsResDTO?.envelopeCGenerateKey || '';
|
||||
|
||||
/* ------------------------------- load ------------------------------- */
|
||||
|
||||
const loadFromApi = useCallback(async () => {
|
||||
if (!tenderId) {
|
||||
Alert.alert('Error', 'Missing tender id.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await axios.get(`${GET_ENVELOPE_C}${tenderId}`, {
|
||||
responseType: 'arraybuffer',
|
||||
headers: { Authentication: `Bearer ${bearer}` },
|
||||
});
|
||||
await ss.loadFromBytes(response.data as ArrayBuffer, `envelopeC_${tenderId}.xlsx`);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Import failed', e?.response?.data?.message || e?.message || 'Could not load the BOQ template.');
|
||||
}
|
||||
// ss identity changes across renders; loadFromBytes is stable.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tenderId, bearer]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFromApi();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loadFromApi]);
|
||||
|
||||
/* --------------------------- import from device --------------------- */
|
||||
|
||||
const importFromDevice = useCallback(async () => {
|
||||
try {
|
||||
const picked = await FileManager.pickWorkbook();
|
||||
if (!picked) return;
|
||||
await ss.loadFromBytes(picked.bytes, picked.name);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Import failed', e?.message || 'Could not open the selected file.');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/* ------------------------------- submit ----------------------------- */
|
||||
|
||||
const buildOfficerEncryptedKeys = useCallback((): SaveEnvelopeOfficerDetail[] | null => {
|
||||
const officerKeys: OfficerKey[] = tender?.envelopeKeyDetailsResDTO?.departmentOfficerPublicKeys || [];
|
||||
// RSA/ECB/PKCS1-wrap the plaintext envelope key K with each officer's public
|
||||
// key (Base64 SPKI DER → ~344-char Base64 ciphertext), per
|
||||
// MOBILE_BID_DSC_API_SPEC.md. The web DSC applet unwraps it with the
|
||||
// officer's PRIVATE key during rebid/opening — a raw or empty value makes
|
||||
// the bid unrecoverable, so any wrap failure aborts the submission.
|
||||
// (`officersId` -> `officerId` per the API contract.)
|
||||
const wrapped: SaveEnvelopeOfficerDetail[] = [];
|
||||
for (const o of officerKeys) {
|
||||
const officerEncryptedKey = encryptWithRSA(encryptionKey, o.officerPublicKey);
|
||||
if (!officerEncryptedKey) return null;
|
||||
wrapped.push({
|
||||
officerId: o.officersId,
|
||||
officerEncryptedKey,
|
||||
officerType: o.officerType,
|
||||
});
|
||||
}
|
||||
return wrapped;
|
||||
}, [tender, encryptionKey]);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
// 1) Validation gate — block submission while any editable cell is invalid.
|
||||
const errors = ss.validateAll();
|
||||
if (errors.length > 0) {
|
||||
Alert.alert('Validation failed', `Please fix ${errors.length} highlighted cell(s) before submitting.`);
|
||||
return;
|
||||
}
|
||||
if (!encryptionKey) {
|
||||
// Matches the server's HTTP 400 guard: a mobile bid must be client-
|
||||
// encrypted and echo randomKeyGenerated.
|
||||
Alert.alert('Error', 'Encryption key not available for this tender.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
let encryptedPath: string | null = null;
|
||||
try {
|
||||
// 2) RSA-wrap the envelope key K for every opening officer + contractor.
|
||||
const officerKeys = buildOfficerEncryptedKeys();
|
||||
if (!officerKeys) {
|
||||
throw new Error('Could not encrypt the envelope key for the opening officers.');
|
||||
}
|
||||
|
||||
// 3) Export the metadata-preserving workbook and AES-encrypt the RAW
|
||||
// bytes with the SHA-1(K) file key; upload as raw ciphertext bytes.
|
||||
const bytes = await ss.exportBytes();
|
||||
const cipherBase64 = encryptFileBytesToBase64(bytes, encryptionKey);
|
||||
if (!cipherBase64) throw new Error('Failed to encrypt the workbook.');
|
||||
|
||||
encryptedPath = await FileManager.writeBase64(ENCRYPTED_FILE_NAME, cipherBase64);
|
||||
const hashValue = sha256HexOfBytes(bytes);
|
||||
|
||||
// 4) Build the request exactly as MOBILE_BID_DSC_API_SPEC.md specifies.
|
||||
const requestDto = {
|
||||
tenderId,
|
||||
deptId: tender?.deptId,
|
||||
typesOfTender: tender?.typesOfTender,
|
||||
deviceType: 'Mobile',
|
||||
randomKeyGenerated: encryptionKey, // plaintext K, echoed back
|
||||
// The financial value lives inside the encrypted BOQ file; the text
|
||||
// fields still travel as AES(text) ciphertext per the contract.
|
||||
fieldValue: '',
|
||||
fieldValueInWord: encrypt('zero', encryptionKey) || '',
|
||||
hashValue,
|
||||
fileName: 'boq.xlsx',
|
||||
saveEnvelopesOfficersDetailsResDTO: officerKeys,
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('requestDto', JSON.stringify(requestDto));
|
||||
formData.append('file', {
|
||||
uri: `file://${encryptedPath}`,
|
||||
name: 'boq.xlsx',
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
} as any);
|
||||
|
||||
// 5) Upload.
|
||||
await axios.post(SUBMIT_ENVELOPE_C_UPLOAD, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data', Authentication: `Bearer ${bearer}` },
|
||||
});
|
||||
|
||||
Alert.alert('Success', 'Envelope-C BOQ submitted successfully.');
|
||||
onSubmitted?.();
|
||||
} catch (e: any) {
|
||||
Alert.alert('Save failed', e?.response?.data?.message || e?.message || 'Could not submit the workbook.');
|
||||
} finally {
|
||||
if (encryptedPath) await FileManager.remove(encryptedPath);
|
||||
setSubmitting(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ss, encryptionKey, tenderId, tender, bearer, buildOfficerEncryptedKeys, onSubmitted]);
|
||||
|
||||
return { ss, submitting, submit, importFromDevice, reload: loadFromApi };
|
||||
};
|
||||
|
||||
export default useEnvelopeCWorkbook;
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import React, { useCallback, useState, useEffect } from 'react';
|
||||
import { View, FlatList, ActivityIndicator, RefreshControl, Text, StyleSheet } from 'react-native';
|
||||
import Animated, { FadeInDown, FadeOutUp } from 'react-native-reanimated';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useFetchLiveAuction, useFetchLiveTender } from '../../Services/ApiCall/Hooks';
|
||||
import { SearchInput } from '../../Component/Serach/SeacrhInput';
|
||||
import { Skelton } from '../../Component/Index';
|
||||
import { useFetchLiveTender } from '../../Services/ApiCall/Hooks';
|
||||
import { SearchInput } from '../../components/SearchInput';
|
||||
import { Skeleton } from '../../components';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { togglePurchaseTender } from '../../Redux/Slices/SelectedPurchaseTender';
|
||||
import LiveTenderTile from './LiveTenderTile';
|
||||
@@ -35,6 +35,8 @@ const LiveTender = () => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
// fetchNextPage identity changes each render; gating flags are the real deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const renderFooter = useCallback(() => {
|
||||
@@ -44,11 +46,13 @@ const LiveTender = () => {
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
</View>
|
||||
);
|
||||
// Theme colors object is recreated per render; footer only depends on the fetching flag.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isFetchingNextPage]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: any) => {
|
||||
if (item.tenderStatus === "Cancelled") return null;
|
||||
const isCancelled = item.tenderStatus === "Cancelled";
|
||||
const isPurchase = SelectedPurchaseList.findIndex((prev: any) => prev.tenderId === item.tenderId)
|
||||
return <LiveTenderTile
|
||||
contractorId={user?.id}
|
||||
@@ -57,6 +61,7 @@ const LiveTender = () => {
|
||||
tenderId={item.tenderId}
|
||||
tenderDispatchDate={item.purchaseStart}
|
||||
isPurchased={isPurchase !== -1 ? true : false}
|
||||
isCancelled={isCancelled}
|
||||
purchaseEnd={item.purchaseEnd}
|
||||
tenderOfWork={item.departmentName}
|
||||
tenderFees={item.documentFees}
|
||||
@@ -65,12 +70,14 @@ const LiveTender = () => {
|
||||
onDetailsPress={() => navigation.navigate('LiveTenderDetail', { item })}
|
||||
/>
|
||||
},
|
||||
// Dispatch/navigation are stable; user?.id changes only with re-login.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[SelectedPurchaseList]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<Skelton />
|
||||
<Skeleton />
|
||||
</View>;
|
||||
}
|
||||
return (
|
||||
@@ -126,7 +133,7 @@ const LiveTender = () => {
|
||||
keyExtractor={(item) => String(item.tenderId)}
|
||||
renderItem={renderItem}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 50, paddingTop: 10 }}
|
||||
contentContainerStyle={{ paddingBottom: 120, paddingTop: 10 }}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.2}
|
||||
ListFooterComponent={renderFooter}
|
||||
|
||||
@@ -46,7 +46,7 @@ const Section = ({ title, icon: Icon, children, styles, colors }: any) => (
|
||||
|
||||
const LiveTenderDetail = () => {
|
||||
const route = useRoute<any>();
|
||||
const { colors, spacing, shadows, isDarkMode } = useTheme();
|
||||
const { colors, shadows } = useTheme();
|
||||
const navigation = useNavigation<any>();
|
||||
const tender = route?.params;
|
||||
const { data } = useFetchLiveTenderById(tender?.tenderId);
|
||||
@@ -206,7 +206,7 @@ const LiveTenderDetail = () => {
|
||||
const { dirs } = ReactNativeBlobUtil.fs;
|
||||
const path = `${dirs.DownloadDir}/${doc?.fileName}.pdf`;
|
||||
const downloadUrl = `${GET_DOCID}${doc?.docId}`;
|
||||
const response = await ReactNativeBlobUtil.config({
|
||||
await ReactNativeBlobUtil.config({
|
||||
fileCache: false,
|
||||
addAndroidDownloads: {
|
||||
useDownloadManager: true,
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import React, { memo } from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Dimensions } from 'react-native';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import moment from 'moment';
|
||||
import { LiveTenderTileProps } from './types/types';
|
||||
import { Calendar, Tag, Info, ArrowRight, Clock, MapPin } from 'lucide-react-native';
|
||||
import { Calendar, Tag, ArrowRight, Clock } from 'lucide-react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { format } from '../../Helper/DateFormat';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import AppText from '../../components/AppText';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
const LiveTenderTile: React.FC<LiveTenderTileProps> = ({
|
||||
contractorId,
|
||||
data,
|
||||
isPurchased = false,
|
||||
isCancelled = false,
|
||||
onPurchasePress,
|
||||
tenderDispatchDate,
|
||||
tenderOfWork,
|
||||
@@ -24,7 +22,7 @@ const LiveTenderTile: React.FC<LiveTenderTileProps> = ({
|
||||
tenderNumber,
|
||||
}) => {
|
||||
const navigation: any = useNavigation();
|
||||
const { colors, shadows, spacing } = useTheme();
|
||||
const { colors } = useTheme();
|
||||
|
||||
const purchaseEnded = moment().isAfter(moment(purchaseEnd, 'YYYY-MM-DD HH:mm:ss'));
|
||||
|
||||
@@ -36,23 +34,16 @@ const LiveTenderTile: React.FC<LiveTenderTileProps> = ({
|
||||
|
||||
const purchased = contractorIds.includes(String(contractorId));
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (isPurchased) return colors.error;
|
||||
if (purchaseEnded) return colors.textSecondary;
|
||||
if (purchased) return colors.textSecondary;
|
||||
return colors.success;
|
||||
};
|
||||
|
||||
const statusColor = getStatusColor();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
activeOpacity={isCancelled ? 1 : 0.9}
|
||||
disabled={isCancelled}
|
||||
onPress={() => navigation.navigate('LiveTenderDetail', data)}
|
||||
style={[styles.container, {
|
||||
backgroundColor: colors.surface,
|
||||
shadowColor: colors.shadow,
|
||||
borderColor: colors.border
|
||||
borderColor: isCancelled ? colors.error : colors.border,
|
||||
opacity: isCancelled ? 0.6 : 1,
|
||||
}]}
|
||||
>
|
||||
|
||||
@@ -66,9 +57,17 @@ const LiveTenderTile: React.FC<LiveTenderTileProps> = ({
|
||||
</AppText>
|
||||
</View>
|
||||
|
||||
<AppText variant="body" style={{ color: colors.primary }}>
|
||||
₹{tenderFees}
|
||||
</AppText>
|
||||
{isCancelled ? (
|
||||
<View style={[styles.statusBadge, { backgroundColor: colors.error }]}>
|
||||
<AppText variant="caption" style={{ color: '#fff', fontWeight: '700' }}>
|
||||
Cancelled
|
||||
</AppText>
|
||||
</View>
|
||||
) : (
|
||||
<AppText variant="body" style={{ color: colors.primary }}>
|
||||
₹{tenderFees}
|
||||
</AppText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Title Section */}
|
||||
@@ -113,17 +112,19 @@ const LiveTenderTile: React.FC<LiveTenderTileProps> = ({
|
||||
|
||||
{/* Actions Section */}
|
||||
<View style={styles.actionRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.detailsBtn, { borderColor: colors.border }]}
|
||||
onPress={() => navigation.navigate('LiveTenderDetail', data)}
|
||||
>
|
||||
<AppText variant="bodySmall" style={{ color: colors.textSecondary, fontWeight: '600' }}>
|
||||
View Details
|
||||
</AppText>
|
||||
</TouchableOpacity>
|
||||
{!isCancelled && (
|
||||
<TouchableOpacity
|
||||
style={[styles.detailsBtn, { borderColor: colors.border }]}
|
||||
onPress={() => navigation.navigate('LiveTenderDetail', data)}
|
||||
>
|
||||
<AppText variant="bodySmall" style={{ color: colors.textSecondary, fontWeight: '600' }}>
|
||||
View Details
|
||||
</AppText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
disabled={purchaseEnded || purchased}
|
||||
disabled={isCancelled || purchaseEnded || purchased}
|
||||
onPress={isPurchased ? () => navigation.navigate(NavigationString.PurchaseTenderList) : onPurchasePress}
|
||||
style={styles.actionBtnWrapper}
|
||||
>
|
||||
@@ -131,7 +132,7 @@ const LiveTenderTile: React.FC<LiveTenderTileProps> = ({
|
||||
style={[
|
||||
styles.actionBtn,
|
||||
{
|
||||
backgroundColor: (purchaseEnded || purchased)
|
||||
backgroundColor: (isCancelled || purchaseEnded || purchased)
|
||||
? colors.textSecondary
|
||||
: isPurchased
|
||||
? colors.success // Green for Go to Cart
|
||||
@@ -140,9 +141,9 @@ const LiveTenderTile: React.FC<LiveTenderTileProps> = ({
|
||||
]}
|
||||
>
|
||||
<AppText variant="button" style={{ color: '#fff', fontSize: 12 }}>
|
||||
{purchaseEnded ? (purchased ? 'Purchased' : 'Closed') : (purchased ? 'Purchased' : isPurchased ? 'GO TO CART' : 'Add to Cart')}
|
||||
{isCancelled ? 'Cancelled' : purchaseEnded ? (purchased ? 'Purchased' : 'Closed') : (purchased ? 'Purchased' : isPurchased ? 'GO TO CART' : 'Add to Cart')}
|
||||
</AppText>
|
||||
{(!purchaseEnded && !purchased) && <ArrowRight size={14} color="#fff" />}
|
||||
{(!isCancelled && !purchaseEnded && !purchased) && <ArrowRight size={14} color="#fff" />}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -187,6 +188,11 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 20,
|
||||
gap: 6,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 20,
|
||||
},
|
||||
title: {
|
||||
marginBottom: 16,
|
||||
fontWeight: '700',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useState } from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
Eye,
|
||||
Layers,
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
} from 'lucide-react-native';
|
||||
import colors from '../../../Constant/Color';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { StyleSheet, View, ScrollView, TouchableOpacity, RefreshControl, ActivityIndicator } from 'react-native'
|
||||
import React, { useCallback } from 'react'
|
||||
import { InsideHeader } from '../../Component/Index'
|
||||
import { InsideHeader } from '../../components'
|
||||
import { useFocusEffect, useNavigation, useRoute } from '@react-navigation/native'
|
||||
import { useFetchTenderById, useProceedToBid } from '../../Services/ApiCall/Hooks'
|
||||
import { useTheme } from '../../Hooks/useTheme'
|
||||
import AppText from '../../components/AppText'
|
||||
import { CircleDot, Clock, CheckCircle, XCircle, Calendar, FileText, Shield, AlertCircle } from 'lucide-react-native'
|
||||
import { CircleDot, CheckCircle, XCircle, Calendar, Shield, AlertCircle } from 'lucide-react-native'
|
||||
import Animated, { FadeInUp, FadeInDown, FadeIn, SlideInRight, Layout } from 'react-native-reanimated'
|
||||
import { apiCall } from '../../Axios/Axios'
|
||||
import NavigationString from '../../Constant/NavigationString'
|
||||
@@ -21,7 +21,7 @@ const ProceedToBidDetail = () => {
|
||||
const { colors, spacing, shadows, typography } = useTheme();
|
||||
const route = useRoute<any>();
|
||||
const tenderDataId = route.params?.tender;
|
||||
const { data, refetch, isRefetching } = useProceedToBid(tenderDataId?.tenderId);
|
||||
const { refetch, isRefetching } = useProceedToBid(tenderDataId?.tenderId);
|
||||
const { data: tenderResponse, refetch: tenderRefetch }: any = useFetchTenderById(tenderDataId?.tenderId);
|
||||
const tenderData = tenderResponse?.data;
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
@@ -50,7 +50,7 @@ const ProceedToBidDetail = () => {
|
||||
navigation.navigate(NavigationString.DscBinding, {
|
||||
message: "Your DSC has been expired. Please map new DSC from the web screen"
|
||||
});
|
||||
} else if (deviceBinding && deviceVerification === 'PENDING') {
|
||||
} else if (deviceBinding && deviceVerification !== 'PENDING') {
|
||||
navigation.navigate(NavigationString.DeviceBinding, {
|
||||
message: "Please approve this device to enable bidding access from the web.",
|
||||
showBindButton: false
|
||||
@@ -128,6 +128,8 @@ const ProceedToBidDetail = () => {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
tenderRefetch();
|
||||
// Refetch keyed by tender id only; tenderRefetch identity changes each render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tenderData?.tenderId])
|
||||
);
|
||||
|
||||
@@ -374,7 +376,7 @@ const ProceedToBidDetail = () => {
|
||||
entering={FadeInDown.duration(300)}
|
||||
style={[styles.footer, { borderTopColor: colors.border }]}
|
||||
>
|
||||
{tenderData?.proceedtoBid && !tenderData?.viewBid && tenderData?.biddingStatus && tenderData?.bidWithdrawStatus === false ? (
|
||||
{tenderData?.proceedtoBid && tenderData?.biddingStatus && tenderData?.bidWithdrawStatus === false ? (
|
||||
<TouchableOpacity style={styles.proceedButton} onPress={handleStartBidding} disabled={isRefetching}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={colors.buttonText} />
|
||||
@@ -406,7 +408,7 @@ const ProceedToBidDetail = () => {
|
||||
|
||||
export default ProceedToBidDetail
|
||||
|
||||
const makeStyles = (colors: any, spacing: any, shadows: any) => StyleSheet.create({
|
||||
const makeStyles = (colors: any, spacing: any, _shadows: any) => StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: spacing.m,
|
||||
|
||||
@@ -8,18 +8,14 @@ import {
|
||||
} from 'react-native';
|
||||
import Animated, { FadeInDown, FadeIn } from 'react-native-reanimated';
|
||||
import {
|
||||
CreditCard,
|
||||
Calendar,
|
||||
Hash,
|
||||
IndianRupee,
|
||||
Percent,
|
||||
Server,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Receipt,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react-native';
|
||||
import { InsideHeader, Loader } from '../../Component/Index';
|
||||
import { InsideHeader } from '../../components';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useOfflineTenderPurchase } from '../../Services/ApiCall/Hooks';
|
||||
import { StackActions, useNavigation } from '@react-navigation/native';
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useState, useCallback, memo, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, FlatList, RefreshControl, ActivityIndicator } from 'react-native';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { View, Text, FlatList, ActivityIndicator } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import moment from 'moment';
|
||||
import { useFetchPurchaseTender } from '../../Services/ApiCall/Hooks';
|
||||
import PurchaseTenderTile from './PurchaseTenderTile';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { setTender } from '../../Redux/Slices/SelectedTender';
|
||||
import { SearchInput } from '../../Component/Serach/SeacrhInput';
|
||||
import { Skelton } from '../../Component/Index';
|
||||
import { SearchInput } from '../../components/SearchInput';
|
||||
import { Skeleton } from '../../components';
|
||||
import { allBooleanProps } from './types/types';
|
||||
import FilterModal from './Modal/FilterModal';
|
||||
import colors from '../../Constant/Color';
|
||||
@@ -59,6 +59,8 @@ const PurchaseTenderAllList = () => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
// fetchNextPage identity changes each render; gating flags are the real deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasNextPage, isFetchingNextPage]);
|
||||
|
||||
|
||||
@@ -111,13 +113,13 @@ const PurchaseTenderAllList = () => {
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
{isLoading ? <Skelton />
|
||||
{isLoading ? <Skeleton />
|
||||
: <FlatList
|
||||
data={data?.result ?? []}
|
||||
keyExtractor={(item) => String(item.tenderId || item.id)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 200, paddingHorizontal: 10 }}
|
||||
renderItem={({ item, index }) => (
|
||||
renderItem={({ item }) => (
|
||||
<PurchaseTenderTile
|
||||
item={item}
|
||||
onPressDetails={() => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
@@ -16,7 +15,7 @@ import Animated, {
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { ChevronDown, ChevronRight, Trash2, ShoppingBag, FileText, Tag } from 'lucide-react-native';
|
||||
import { InsideHeader } from '../../Component/Index';
|
||||
import { InsideHeader } from '../../components';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -27,11 +26,9 @@ import { useEffect } from 'react';
|
||||
|
||||
const PurchaseTenderList = () => {
|
||||
const Navigation: any = useNavigation();
|
||||
const Dispatch = useDispatch();
|
||||
const { colors, spacing, shadows, typography } = useTheme();
|
||||
const { colors, spacing, shadows } = useTheme();
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||
const SelectedPurchaseList = useSelector((state: any) => state.selectPurchaseTender);
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
const toggleExpand = (index: number) => {
|
||||
setExpandedIndex(prev => (prev === index ? null : index));
|
||||
@@ -41,6 +38,8 @@ const PurchaseTenderList = () => {
|
||||
if (SelectedPurchaseList.length === 0) {
|
||||
Navigation.goBack();
|
||||
}
|
||||
// Navigation identity is stable; the list is the real dependency.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [SelectedPurchaseList]);
|
||||
|
||||
const styles = makeStyles(colors, spacing, shadows);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Platform } from 'react-native';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import Animated, { FadeInUp, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||
import moment from 'moment';
|
||||
import CountdownTimer from '../../Component/Counter/CountdownTimer';
|
||||
import CountdownTimer from '../../components/CountdownTimer';
|
||||
import { useFetchTenderById } from '../../Services/ApiCall/Hooks';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import TenderFullDetail from './Modal/TenderFullDetail';
|
||||
import AppText from '../../components/AppText';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import { Calendar, Wallet, Banknote, ArrowRight, CircleDot, Clock } from 'lucide-react-native';
|
||||
import { Calendar, Banknote, ArrowRight, CircleDot, Clock } from 'lucide-react-native';
|
||||
|
||||
const PurchaseTenderTile = ({ item, onPressDetails }: any) => {
|
||||
const { colors, spacing, shadows } = useTheme();
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { InsideHeader } from '../../Component/Index';
|
||||
import { InsideHeader } from '../../components';
|
||||
import {
|
||||
useFetchLiveTender,
|
||||
useFetchPurchaseTender,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
||||
import { buildEnvelopePayload, buildQcbsPayload, findEnvelopeFile, validateEnvelope, validateQcbs } from '../envelopePayload';
|
||||
import { convertDigitsToWords } from '../envelopeCrypto';
|
||||
|
||||
jest.mock('../../../../Services/Encryption/encrypt', () => ({
|
||||
encrypt: jest.fn((value: string) => `enc(${value})`),
|
||||
encryptWithRSA: jest.fn(() => 'rsa-wrapped-key'),
|
||||
}));
|
||||
|
||||
const tenderData = {
|
||||
deptId: 7,
|
||||
tenderId: 42,
|
||||
tenderEnvelopeAList: [
|
||||
{
|
||||
tenderEnvelopeASequnceId: 1,
|
||||
labelNameA: 'Bid Amount',
|
||||
labelTypeA: '1',
|
||||
labelMandatoryA: true,
|
||||
tenderEnvelopeANameId: 11,
|
||||
envelopeATemplateName: 'TemplateA',
|
||||
},
|
||||
{
|
||||
tenderEnvelopeASequnceId: 2,
|
||||
labelNameA: 'Supporting Doc',
|
||||
labelTypeA: '3',
|
||||
labelMandatoryA: false,
|
||||
},
|
||||
],
|
||||
envelopeKeyDetailsResDTO: {
|
||||
envelopeAGenerateKey: 'aes-key-A',
|
||||
envelopeQcbsAndLcs: 'aes-key-qcbs',
|
||||
departmentOfficerPublicKeys: [
|
||||
{ officersId: 5, officerPublicKey: 'pub-key', officerType: 'AO' },
|
||||
],
|
||||
},
|
||||
tenderEnvelopeQcbsList: {
|
||||
templateId: 3,
|
||||
qcbsTemplateName: 'QCBS-T',
|
||||
questions: [
|
||||
{ questionId: 100, question: 'Q1?', parameters: [{ option: 'Yes', mark: 5, strUser: '1' }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe('convertDigitsToWords', () => {
|
||||
it('spells out each digit', () => {
|
||||
expect(convertDigitsToWords('120')).toBe('One Two Zero');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEnvelope', () => {
|
||||
it('fails when a mandatory field is empty', () => {
|
||||
expect(validateEnvelope(tenderData, 'A', {})).toBe(false);
|
||||
expect(validateEnvelope(tenderData, 'A', { 1: ' ' })).toBe(false);
|
||||
});
|
||||
|
||||
it('passes when mandatory fields are filled (optional file may be empty)', () => {
|
||||
expect(validateEnvelope(tenderData, 'A', { 1: '5000' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findEnvelopeFile', () => {
|
||||
it('returns the picked file for the file field', () => {
|
||||
const file = { uri: 'file://x.pdf', name: 'x.pdf', type: 'application/pdf' };
|
||||
expect(findEnvelopeFile(tenderData, 'A', { 2: file })).toBe(file);
|
||||
expect(findEnvelopeFile(tenderData, 'A', {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEnvelopePayload', () => {
|
||||
it('returns null when the tender has no encryption key', () => {
|
||||
expect(buildEnvelopePayload({ envelopeKeyDetailsResDTO: {} }, 'A', {})).toBeNull();
|
||||
});
|
||||
|
||||
it('encrypts non-file fields in figures and words, echoing the key per backend contract', () => {
|
||||
const payload = buildEnvelopePayload(tenderData, 'A', { 1: '50' });
|
||||
|
||||
expect(payload.deptId).toBe(7);
|
||||
expect(payload.tenderId).toBe(42);
|
||||
expect(payload.tenderEnvelopeANameId).toBe(11);
|
||||
expect(payload.envelopeATemplateName).toBe('TemplateA');
|
||||
expect(payload.deviceType).toBe('Mobile');
|
||||
expect(payload.randomKeyGenerated).toBe('aes-key-A');
|
||||
|
||||
expect(payload.saveAData).toHaveLength(1); // file fields excluded
|
||||
expect(payload.saveAData[0].fieldValue).toBe('enc(50)');
|
||||
expect(payload.saveAData[0].fieldValueInWord).toBe('enc(Five Zero)');
|
||||
|
||||
expect(payload.saveEnvelopesOfficersDetailsResDTO).toEqual([
|
||||
{ officerId: 5, officerEncryptedKey: 'rsa-wrapped-key', officerType: 'AO' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('filesOnly variant carries only file fields, without values', () => {
|
||||
const payload = buildEnvelopePayload(tenderData, 'A', { 1: '50' }, true);
|
||||
expect(payload.saveAData).toHaveLength(1);
|
||||
expect(payload.saveAData[0].labelTypeA).toBe('3');
|
||||
expect(payload.saveAData[0].fieldValue).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('QCBS', () => {
|
||||
it('validates that at least one option is selected', () => {
|
||||
expect(validateQcbs(tenderData, {})).toBe(false);
|
||||
expect(validateQcbs(tenderData, { 100: { option: 'Yes' } })).toBe(true);
|
||||
});
|
||||
|
||||
it('builds a payload containing only the selected, encrypted option', () => {
|
||||
const payload = buildQcbsPayload(tenderData, { 100: { option: 'Yes', mark: 5, strUser: '1' } });
|
||||
expect(payload.templateId).toBe(3);
|
||||
expect(payload.questions).toHaveLength(1);
|
||||
expect(payload.questions[0].parameters).toEqual([
|
||||
{ option: 'enc(Yes)', mark: 5, strUser: 'enc(1)' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits an empty parameters array for unanswered questions', () => {
|
||||
const payload = buildQcbsPayload(tenderData, {});
|
||||
expect(payload.questions[0].parameters).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { useTheme } from '../../../../Hooks/useTheme';
|
||||
import AppText from '../../../../components/AppText';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
isActive: boolean;
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
const EnvelopeButton = ({ title, isActive, onPress }: Props) => {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.button,
|
||||
{ borderColor: colors.border, backgroundColor: colors.surface },
|
||||
isActive && { backgroundColor: colors.primary, borderColor: colors.primary },
|
||||
]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<AppText
|
||||
variant="bodySmall"
|
||||
color={isActive ? colors.buttonText : colors.primary}
|
||||
style={styles.title as any}
|
||||
>
|
||||
{title}
|
||||
</AppText>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1.5,
|
||||
minWidth: 100,
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: { fontWeight: '700' } as any,
|
||||
});
|
||||
|
||||
export default memo(EnvelopeButton);
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
import { FileUp } from 'lucide-react-native';
|
||||
import MyInput from '../../../../components/MyInput';
|
||||
import AppText from '../../../../components/AppText';
|
||||
import { useTheme } from '../../../../Hooks/useTheme';
|
||||
import type { EnvelopeType } from '../envelopeTypes';
|
||||
|
||||
type Props = {
|
||||
item: any;
|
||||
envelopeType: EnvelopeType;
|
||||
value: any;
|
||||
/** False once the envelope has already been submitted. */
|
||||
showInput: boolean;
|
||||
/** Whole-envelope validation state — drives the mandatory-asterisk color. */
|
||||
envelopeValid: boolean;
|
||||
onChangeText: (text: string) => void;
|
||||
onPickFile: () => void;
|
||||
};
|
||||
|
||||
const keyboardTypeFor = (labelType: string) =>
|
||||
labelType === '1' || labelType === '4' ? 'numeric' : 'default';
|
||||
|
||||
const placeholderFor = (labelType: string, labelName: string) => {
|
||||
if (labelType === '1') { return `Enter ${labelName} numeric value`; }
|
||||
if (labelType === '4') { return `Enter ${labelName} currency value`; }
|
||||
return `Enter ${labelName} Alph-numeric value`;
|
||||
};
|
||||
|
||||
/** One envelope form field: numeric/text/currency input or a PDF picker. */
|
||||
const EnvelopeFieldItem = ({
|
||||
item,
|
||||
envelopeType,
|
||||
value,
|
||||
showInput,
|
||||
envelopeValid,
|
||||
onChangeText,
|
||||
onPickFile,
|
||||
}: Props) => {
|
||||
const { colors, spacing } = useTheme();
|
||||
|
||||
const sequenceId = item?.[`tenderEnvelope${envelopeType}SequnceId`];
|
||||
if (!sequenceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelName = item?.[`labelName${envelopeType}`] || '';
|
||||
const labelType = item?.[`labelType${envelopeType}`] || '';
|
||||
const isMandatory = item?.[`labelMandatory${envelopeType}`];
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeInUp.delay(80).springify()}>
|
||||
<View
|
||||
style={[
|
||||
styles.envelopeItem,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderLeftColor: colors.primary,
|
||||
padding: spacing.m,
|
||||
marginBottom: spacing.s,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Label */}
|
||||
<View style={styles.labelRow}>
|
||||
<AppText variant="caption" color={colors.textSecondary} style={styles.labelText as any}>
|
||||
{labelName || '-'}
|
||||
</AppText>
|
||||
{isMandatory && (
|
||||
<AppText variant="caption" color={envelopeValid ? colors.textSecondary : colors.error}>
|
||||
*
|
||||
</AppText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Input directly below the label, inside the same card */}
|
||||
{showInput ? (
|
||||
<View style={{ marginTop: spacing.s }}>
|
||||
{labelType === '3' ? (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.uploadButton,
|
||||
{ borderColor: colors.primary, backgroundColor: colors.surface, marginTop: spacing.s },
|
||||
]}
|
||||
onPress={onPickFile}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<FileUp size={16} color={colors.primary} />
|
||||
<AppText variant="bodySmall" color={colors.primary} style={styles.uploadText as any}>
|
||||
{value ? 'Change Document' : 'Upload Document'}
|
||||
</AppText>
|
||||
</TouchableOpacity>
|
||||
|
||||
{value && (
|
||||
<AppText variant="caption" color={colors.success} style={styles.fileName as any}>
|
||||
📄 {(value as any)?.name}
|
||||
</AppText>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<MyInput
|
||||
placeholder={placeholderFor(labelType, labelName)}
|
||||
value={value ?? ''}
|
||||
onChangeText={onChangeText}
|
||||
keyboardType={keyboardTypeFor(labelType)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<AppText variant="caption" color={colors.textSecondary} style={{ marginTop: spacing.xs }}>
|
||||
Envelope {envelopeType} unavailable
|
||||
</AppText>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
envelopeItem: {
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderLeftWidth: 3,
|
||||
},
|
||||
labelRow: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
||||
labelText: { fontWeight: '600', textTransform: 'uppercase', letterSpacing: 0.3 } as any,
|
||||
uploadButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1.5,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
uploadText: { fontWeight: '600' } as any,
|
||||
fileName: { marginTop: 4 } as any,
|
||||
});
|
||||
|
||||
export default memo(EnvelopeFieldItem);
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import Animated, { FadeInUp, SlideInRight } from 'react-native-reanimated';
|
||||
import AppText from '../../../../components/AppText';
|
||||
import { useTheme } from '../../../../Hooks/useTheme';
|
||||
|
||||
type Props = {
|
||||
question: any;
|
||||
selectedOption: any;
|
||||
onSelect: (option: any) => void;
|
||||
};
|
||||
|
||||
/** One LCS/QCBS question with its single-select option list. */
|
||||
const QcbsQuestionCard = ({ question, selectedOption, onSelect }: Props) => {
|
||||
const { colors, spacing } = useTheme();
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.questionCard,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderLeftColor: colors.primary,
|
||||
padding: spacing.m,
|
||||
marginBottom: spacing.m,
|
||||
},
|
||||
]}
|
||||
entering={FadeInUp.delay(100).springify()}
|
||||
>
|
||||
<AppText
|
||||
variant="bodySmall"
|
||||
color={colors.text}
|
||||
style={[styles.questionText as any, { marginBottom: spacing.s }]}
|
||||
>
|
||||
{question.question}
|
||||
</AppText>
|
||||
|
||||
{question.parameters?.map((param: any, index: number) => {
|
||||
const isSelected = selectedOption?.option === param.option;
|
||||
|
||||
return (
|
||||
<Animated.View key={index} entering={SlideInRight.delay(index * 80).springify()}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.optionContainer,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.m,
|
||||
marginBottom: spacing.s,
|
||||
},
|
||||
isSelected && {
|
||||
borderColor: colors.primary,
|
||||
borderWidth: 2,
|
||||
backgroundColor: `${colors.primary}08`,
|
||||
},
|
||||
]}
|
||||
onPress={() => onSelect(param)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.optionRow}>
|
||||
<View style={[styles.radioButton, { borderColor: isSelected ? colors.primary : colors.border }]}>
|
||||
{isSelected && <View style={[styles.radioButtonInner, { backgroundColor: colors.primary }]} />}
|
||||
</View>
|
||||
<View style={styles.flex1}>
|
||||
<AppText
|
||||
variant="bodySmall"
|
||||
color={isSelected ? colors.primary : colors.text}
|
||||
style={{ fontWeight: isSelected ? '700' : '500' }}
|
||||
>
|
||||
{param.option}
|
||||
</AppText>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
})}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex1: { flex: 1 },
|
||||
questionCard: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderLeftWidth: 3,
|
||||
},
|
||||
questionText: { fontWeight: '700', lineHeight: 22 } as any,
|
||||
optionContainer: {
|
||||
borderRadius: 10,
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
radioButton: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
borderWidth: 2,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
radioButtonInner: {
|
||||
width: 11,
|
||||
height: 11,
|
||||
borderRadius: 6,
|
||||
},
|
||||
});
|
||||
|
||||
export default memo(QcbsQuestionCard);
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import { CheckSquare, Square } from 'lucide-react-native';
|
||||
import AppText from '../../../../components/AppText';
|
||||
import { useTheme } from '../../../../Hooks/useTheme';
|
||||
|
||||
type Props = {
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
const TermsRow = ({ checked, onToggle }: Props) => {
|
||||
const { colors, spacing } = useTheme();
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.termsRow,
|
||||
{ borderTopColor: colors.border, marginTop: spacing.m, paddingTop: spacing.m },
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={onToggle}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked }}
|
||||
accessibilityLabel="Accept terms and conditions"
|
||||
>
|
||||
{checked ? (
|
||||
<CheckSquare size={22} color={colors.primary} strokeWidth={2.5} />
|
||||
) : (
|
||||
<Square size={22} color={colors.textSecondary} strokeWidth={2} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<AppText variant="bodySmall" color={colors.text} style={styles.termsText as any}>
|
||||
Accept Terms & Conditions
|
||||
</AppText>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
termsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
termsText: { fontWeight: '600', flex: 1 } as any,
|
||||
});
|
||||
|
||||
export default memo(TermsRow);
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Sealed-bid crypto helpers for tender envelopes.
|
||||
*
|
||||
* SECURITY: nothing in this module may ever log key material, plaintext bid
|
||||
* values, or ciphertext — the previous implementation dumped all three to
|
||||
* logcat and that was a reportable incident, not a debugging aid.
|
||||
*/
|
||||
import { encryptWithRSA } from '../../../Services/Encryption/encrypt';
|
||||
import { logger } from '../../../core/observability/logger';
|
||||
import type { OfficerKey, SaveEnvelopeOfficerDetail } from './envelopeTypes';
|
||||
|
||||
const DIGIT_WORDS: Record<string, string> = {
|
||||
'0': 'Zero',
|
||||
'1': 'One',
|
||||
'2': 'Two',
|
||||
'3': 'Three',
|
||||
'4': 'Four',
|
||||
'5': 'Five',
|
||||
'6': 'Six',
|
||||
'7': 'Seven',
|
||||
'8': 'Eight',
|
||||
'9': 'Nine',
|
||||
};
|
||||
|
||||
/** "120" → "One Two Zero" — the backend stores bid figures in words too. */
|
||||
export const convertDigitsToWords = (value: string): string =>
|
||||
value
|
||||
.split('')
|
||||
.map(digit => DIGIT_WORDS[digit] || '')
|
||||
.join(' ') || value;
|
||||
|
||||
/**
|
||||
* Wraps the symmetric envelope key with each department officer's RSA public
|
||||
* key. A failed wrap is recorded as an empty key (matching the backend
|
||||
* contract) and logged WITHOUT any key material.
|
||||
*/
|
||||
export const buildOfficerEncryptedKeys = (
|
||||
envelopeKey: string,
|
||||
officerKeys: OfficerKey[],
|
||||
): SaveEnvelopeOfficerDetail[] => {
|
||||
// Counts only — never log the key being wrapped or the resulting ciphertext.
|
||||
logger.debug('envelope', `[crypto] wrapping envelope key for ${officerKeys.length} officer(s)`);
|
||||
|
||||
let wrapped = 0;
|
||||
const result = officerKeys.map(officer => {
|
||||
try {
|
||||
const officerEncryptedKey = encryptWithRSA(envelopeKey, officer.officerPublicKey) || '';
|
||||
if (!officerEncryptedKey) {
|
||||
logger.warn('envelope', `RSA wrap produced empty key for officer ${officer.officersId}`);
|
||||
} else {
|
||||
wrapped += 1;
|
||||
}
|
||||
return {
|
||||
officerId: officer.officersId,
|
||||
officerEncryptedKey,
|
||||
officerType: officer.officerType,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('envelope', `RSA wrap failed for officer ${officer.officersId}`, {
|
||||
error: String(error),
|
||||
});
|
||||
return {
|
||||
officerId: officer.officersId,
|
||||
officerEncryptedKey: '',
|
||||
officerType: officer.officerType,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
'envelope',
|
||||
`[crypto] officer key wrap complete: ${wrapped}/${officerKeys.length} succeeded`,
|
||||
);
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Envelope payload construction and validation — pure functions over the
|
||||
* tender data and form state. No React, no HTTP, no UI, no key logging.
|
||||
*/
|
||||
import { encrypt } from '../../../Services/Encryption/encrypt';
|
||||
import { logger } from '../../../core/observability/logger';
|
||||
import { buildOfficerEncryptedKeys, convertDigitsToWords } from './envelopeCrypto';
|
||||
import type { EnvelopeType, PickedFile } from './envelopeTypes';
|
||||
|
||||
/** Every mandatory field of the active envelope must be filled. */
|
||||
export const validateEnvelope = (
|
||||
tenderData: any,
|
||||
envelopeType: EnvelopeType,
|
||||
formValues: Record<number, any>,
|
||||
): boolean => {
|
||||
const list = tenderData?.[`tenderEnvelope${envelopeType}List`] || [];
|
||||
|
||||
let missingSeqId: number | null = null;
|
||||
const missing = list.some((item: any) => {
|
||||
const seqId = item[`tenderEnvelope${envelopeType}SequnceId`];
|
||||
const isMandatory = item[`labelMandatory${envelopeType}`];
|
||||
const value = formValues?.[seqId];
|
||||
|
||||
if (!isMandatory) { return false; }
|
||||
const empty = item[`labelType${envelopeType}`] === '3'
|
||||
? !value
|
||||
: !value || value.toString().trim() === '';
|
||||
if (empty) { missingSeqId = seqId; }
|
||||
return empty;
|
||||
});
|
||||
|
||||
logger.debug('envelope', `[validate] envelope ${envelopeType}`, {
|
||||
fields: list.length,
|
||||
valid: !missing,
|
||||
...(missing ? { firstMissingSeqId: missingSeqId } : {}),
|
||||
});
|
||||
|
||||
return !missing;
|
||||
};
|
||||
|
||||
/** LCS/QCBS: at least one question must have a selected option. */
|
||||
export const validateQcbs = (
|
||||
tenderData: any,
|
||||
selectedOptions: Record<number, any>,
|
||||
): boolean => {
|
||||
const questions = tenderData?.tenderEnvelopeQcbsList?.questions || [];
|
||||
const answered = questions.filter((q: any) => selectedOptions[q.questionId]).length;
|
||||
logger.debug('envelope', '[validate] LCS/QCBS', {
|
||||
questions: questions.length,
|
||||
answered,
|
||||
valid: answered > 0,
|
||||
});
|
||||
return answered > 0;
|
||||
};
|
||||
|
||||
/** Returns the picked file for the envelope's file field, if any. */
|
||||
export const findEnvelopeFile = (
|
||||
tenderData: any,
|
||||
envelopeType: EnvelopeType,
|
||||
formValues: Record<number, any>,
|
||||
): PickedFile | null => {
|
||||
const list = tenderData?.[`tenderEnvelope${envelopeType}List`] || [];
|
||||
const fileField = list.find((item: any) => item[`labelType${envelopeType}`] === '3');
|
||||
const seqId = fileField ? fileField[`tenderEnvelope${envelopeType}SequnceId`] : null;
|
||||
const file = (seqId && formValues?.[seqId]) || null;
|
||||
logger.debug('envelope', `[file] locate document field for envelope ${envelopeType}`, {
|
||||
hasFileField: !!fileField,
|
||||
seqId,
|
||||
fileSelected: !!file,
|
||||
fileName: file?.name,
|
||||
fileType: file?.type,
|
||||
});
|
||||
return file;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the save-envelope payload with every field value AES-encrypted under
|
||||
* the envelope key and the key RSA-wrapped per officer.
|
||||
*
|
||||
* @param filesOnly when true, builds the variant used as the multipart
|
||||
* `requestDto` for the file upload (file fields only); otherwise the
|
||||
* data-submission variant (non-file fields only).
|
||||
* @returns the payload, or null when the tender carries no encryption key.
|
||||
*/
|
||||
export const buildEnvelopePayload = (
|
||||
tenderData: any,
|
||||
envelopeType: EnvelopeType,
|
||||
formValues: Record<number, any>,
|
||||
filesOnly: boolean = false,
|
||||
): any | null => {
|
||||
const list = tenderData?.[`tenderEnvelope${envelopeType}List`] || [];
|
||||
const encryptionKey =
|
||||
tenderData?.envelopeKeyDetailsResDTO?.[`envelope${envelopeType}GenerateKey`] || '';
|
||||
|
||||
logger.info('envelope', `[payload] building envelope ${envelopeType} payload`, {
|
||||
filesOnly,
|
||||
totalFields: list.length,
|
||||
hasEncryptionKey: !!encryptionKey,
|
||||
});
|
||||
|
||||
if (!encryptionKey) {
|
||||
logger.error('envelope', `no encryption key available for envelope ${envelopeType}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const combinedData = list
|
||||
.filter((item: any) => {
|
||||
const labelType = item[`labelType${envelopeType}`];
|
||||
return filesOnly ? labelType === '3' : labelType !== '3';
|
||||
})
|
||||
.map((item: any) => {
|
||||
const seqId = item[`tenderEnvelope${envelopeType}SequnceId`];
|
||||
const labelType = item[`labelType${envelopeType}`];
|
||||
const labelName = item[`labelName${envelopeType}`];
|
||||
const isMandatory = item[`labelMandatory${envelopeType}`];
|
||||
const rawValue = formValues[seqId];
|
||||
|
||||
if (labelType === '3') {
|
||||
return {
|
||||
[`tenderEnvelope${envelopeType}SequnceId`]: seqId,
|
||||
[`labelName${envelopeType}`]: labelName,
|
||||
[`labelType${envelopeType}`]: labelType,
|
||||
[`labelMandatory${envelopeType}`]: isMandatory,
|
||||
};
|
||||
}
|
||||
|
||||
const encryptedValue = rawValue ? encrypt(String(rawValue), encryptionKey) : '';
|
||||
const valueInWords = rawValue ? convertDigitsToWords(String(rawValue)) : '';
|
||||
const encryptedValueInWord = valueInWords ? encrypt(valueInWords, encryptionKey) : '';
|
||||
|
||||
return {
|
||||
[`tenderEnvelope${envelopeType}SequnceId`]: seqId,
|
||||
[`labelName${envelopeType}`]: labelName,
|
||||
[`labelType${envelopeType}`]: labelType,
|
||||
[`labelMandatory${envelopeType}`]: isMandatory,
|
||||
fieldValue: encryptedValue,
|
||||
fieldValueInWord: encryptedValueInWord,
|
||||
};
|
||||
});
|
||||
|
||||
logger.debug('envelope', `[payload] envelope ${envelopeType} fields encrypted`, {
|
||||
filesOnly,
|
||||
encryptedFields: combinedData.length,
|
||||
});
|
||||
|
||||
const officerEncryptedKeys = buildOfficerEncryptedKeys(
|
||||
encryptionKey,
|
||||
tenderData?.envelopeKeyDetailsResDTO?.departmentOfficerPublicKeys || [],
|
||||
);
|
||||
|
||||
logger.info('envelope', `[payload] envelope ${envelopeType} payload ready`, {
|
||||
filesOnly,
|
||||
savedFields: combinedData.length,
|
||||
officers: officerEncryptedKeys.length,
|
||||
});
|
||||
|
||||
return {
|
||||
deptId: tenderData?.deptId,
|
||||
tenderId: tenderData?.tenderId,
|
||||
[`tenderEnvelope${envelopeType}NameId`]:
|
||||
tenderData?.[`tenderEnvelope${envelopeType}List`]?.[0]?.[
|
||||
`tenderEnvelope${envelopeType}NameId`
|
||||
],
|
||||
[`envelope${envelopeType}TemplateName`]:
|
||||
tenderData?.[`tenderEnvelope${envelopeType}List`]?.[0]?.[
|
||||
`envelope${envelopeType}TemplateName`
|
||||
],
|
||||
[`save${envelopeType}Data`]: combinedData,
|
||||
deviceType: 'Mobile',
|
||||
// NOTE: echoing the symmetric key back to the server is a weakness of the
|
||||
// current backend contract (see the architecture audit §11) — it cannot be
|
||||
// removed client-side without a coordinated backend change.
|
||||
randomKeyGenerated: encryptionKey,
|
||||
saveEnvelopesOfficersDetailsResDTO: officerEncryptedKeys,
|
||||
};
|
||||
};
|
||||
|
||||
/** Builds the LCS/QCBS payload — only the selected option per question, encrypted. */
|
||||
export const buildQcbsPayload = (
|
||||
tenderData: any,
|
||||
selectedOptions: Record<number, any>,
|
||||
): any | null => {
|
||||
const questions = tenderData?.tenderEnvelopeQcbsList?.questions || [];
|
||||
const encryptionKey = tenderData?.envelopeKeyDetailsResDTO?.envelopeQcbsAndLcs || '';
|
||||
|
||||
const answered = questions.filter((q: any) => selectedOptions[q.questionId]).length;
|
||||
logger.info('envelope', '[payload] building LCS/QCBS payload', {
|
||||
questions: questions.length,
|
||||
answered,
|
||||
hasEncryptionKey: !!encryptionKey,
|
||||
});
|
||||
|
||||
if (!encryptionKey) {
|
||||
logger.error('envelope', 'no encryption key available for LCS/QCBS');
|
||||
return null;
|
||||
}
|
||||
|
||||
const officerEncryptedKeys = buildOfficerEncryptedKeys(
|
||||
encryptionKey,
|
||||
tenderData?.envelopeKeyDetailsResDTO?.departmentOfficerPublicKeys || [],
|
||||
);
|
||||
|
||||
const questionPayload = questions.map((question: any) => {
|
||||
const selected = selectedOptions[question.questionId];
|
||||
const parameters = selected
|
||||
? [{
|
||||
option: encrypt(String(selected.option), encryptionKey),
|
||||
mark: selected.mark,
|
||||
strUser: encrypt(String(selected.strUser || '1'), encryptionKey),
|
||||
}]
|
||||
: [];
|
||||
|
||||
return {
|
||||
questionId: question.questionId,
|
||||
question: question.question,
|
||||
parameters,
|
||||
};
|
||||
});
|
||||
|
||||
logger.info('envelope', '[payload] LCS/QCBS payload ready', {
|
||||
questions: questionPayload.length,
|
||||
officers: officerEncryptedKeys.length,
|
||||
});
|
||||
|
||||
return {
|
||||
tenderId: tenderData?.tenderId,
|
||||
templateId: tenderData?.tenderEnvelopeQcbsList?.templateId,
|
||||
qcbsTemplateName: tenderData?.tenderEnvelopeQcbsList?.qcbsTemplateName || 'string',
|
||||
questions: questionPayload,
|
||||
deviceType: 'Mobile',
|
||||
saveEnvelopesOfficersDetailsResDTO: officerEncryptedKeys,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Envelope submission API calls. Uses the legacy `apiCall` envelope contract
|
||||
* (screens branch on `res?.statusCode === 200`) until this feature migrates
|
||||
* to the throwing core/http client end to end.
|
||||
*/
|
||||
import { apiCall } from '../../../Axios/Axios';
|
||||
import {
|
||||
SUBMIT_ENVELOPE_A,
|
||||
SUBMIT_ENVELOPE_B,
|
||||
SUBMIT_ENVELOPE_FILE_A,
|
||||
SUBMIT_ENVELOPE_FILE_B,
|
||||
SUBMIT_QCBS_AND_LCS,
|
||||
} from '../../../Services/ApiCall/ApiUrls';
|
||||
import { logger } from '../../../core/observability/logger';
|
||||
import type { PickedFile } from './envelopeTypes';
|
||||
|
||||
/** A and B share identical flows; only the endpoints differ. */
|
||||
const ENVELOPE_URLS: Record<'A' | 'B', { data: string; file: string }> = {
|
||||
A: { data: SUBMIT_ENVELOPE_A, file: SUBMIT_ENVELOPE_FILE_A },
|
||||
B: { data: SUBMIT_ENVELOPE_B, file: SUBMIT_ENVELOPE_FILE_B },
|
||||
};
|
||||
|
||||
export const uploadEnvelopeDocument = async (
|
||||
envelopeType: 'A' | 'B',
|
||||
requestDto: any,
|
||||
file: PickedFile,
|
||||
): Promise<any | null> => {
|
||||
const url = ENVELOPE_URLS[envelopeType].file;
|
||||
const formData = new FormData();
|
||||
formData.append('files', {
|
||||
uri: file.uri,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
} as any);
|
||||
formData.append('requestDto', JSON.stringify(requestDto));
|
||||
|
||||
logger.info('envelope', `[api] uploading envelope ${envelopeType} document`, {
|
||||
url,
|
||||
fileName: file.name,
|
||||
fileType: file.type,
|
||||
fileSize: file.size,
|
||||
});
|
||||
|
||||
try {
|
||||
const res: any = await apiCall('post', url, formData, {}, 'multipart/form-data');
|
||||
logger.info('envelope', `[api] envelope ${envelopeType} document upload responded`, {
|
||||
statusCode: res?.statusCode,
|
||||
});
|
||||
return res;
|
||||
} catch (error) {
|
||||
logger.error('envelope', `file upload failed for envelope ${envelopeType}`, {
|
||||
error: String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const submitEnvelopeData = async (envelopeType: 'A' | 'B', payload: any): Promise<any> => {
|
||||
const url = ENVELOPE_URLS[envelopeType].data;
|
||||
logger.info('envelope', `[api] submitting envelope ${envelopeType} data`, { url });
|
||||
const res: any = await apiCall('post', url, payload);
|
||||
logger.info('envelope', `[api] envelope ${envelopeType} data submit responded`, {
|
||||
statusCode: res?.statusCode,
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
export const submitQcbsData = async (payload: any): Promise<any> => {
|
||||
logger.info('envelope', '[api] submitting LCS/QCBS data', { url: SUBMIT_QCBS_AND_LCS });
|
||||
const res: any = await apiCall('post', SUBMIT_QCBS_AND_LCS, payload);
|
||||
logger.info('envelope', '[api] LCS/QCBS data submit responded', {
|
||||
statusCode: res?.statusCode,
|
||||
});
|
||||
return res;
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/** Shared types for the tender envelope bidding feature. */
|
||||
|
||||
export type EnvelopeType = 'A' | 'B' | 'C' | 'LCS' | 'QCBS';
|
||||
|
||||
/** Per-envelope form values keyed by the backend sequence id. */
|
||||
export type EnvelopeFormState = {
|
||||
A: Record<number, any>;
|
||||
B: Record<number, any>;
|
||||
C: Record<number, any>;
|
||||
LCS: Record<number, any>;
|
||||
QCBS: Record<number, any>;
|
||||
};
|
||||
|
||||
export interface OfficerKey {
|
||||
officersId: number;
|
||||
officerPublicKey: string;
|
||||
officerType: string;
|
||||
}
|
||||
|
||||
export interface SaveEnvelopeOfficerDetail {
|
||||
officerId: number;
|
||||
officerEncryptedKey: string;
|
||||
officerType: string;
|
||||
}
|
||||
|
||||
/** A picked document ready for multipart upload. */
|
||||
export type PickedFile = {
|
||||
uri: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size?: number | null;
|
||||
};
|
||||
@@ -9,6 +9,7 @@ export interface LiveTenderTileProps {
|
||||
purchaseEnd?: string;
|
||||
tenderFees?: string | number;
|
||||
isPurchased?: boolean;
|
||||
isCancelled?: boolean;
|
||||
tenderId?: any;
|
||||
tenderNumber?: any;
|
||||
}
|
||||
|
||||
@@ -1,449 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { decrypt } from '../../Services/Encryption/encrypt';
|
||||
import { Clipboard, Copy, Eye, EyeOff, RefreshCw } from 'lucide-react-native';
|
||||
import colors from '../../Constant/Color';
|
||||
import { InsideHeader } from '../../Component/Index';
|
||||
|
||||
|
||||
const DecryptionScreen = () => {
|
||||
const navigation = useNavigation();
|
||||
|
||||
// ==========================================
|
||||
// 🔹 HARDCODED INPUT - CHANGE VALUES HERE
|
||||
// ==========================================
|
||||
const CIPHER_TEXT = 'xoqT29mkmo0m4O5dmYIc+akKJO8oXD7tTUCWyLKLbF3dXS/PFReGRMgOXDsENnwtrjspBSNL7eoGYUrxi/gUbQ==';
|
||||
const ENCRYPTION_KEY = "Lv7GctxTJZWFxM7"
|
||||
// ==========================================
|
||||
|
||||
const [decryptedText, setDecryptedText] = useState('');
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [showCipher, setShowCipher] = useState(true);
|
||||
const [showDecrypted, setShowDecrypted] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-decrypt on component mount
|
||||
handleDecrypt();
|
||||
}, []);
|
||||
|
||||
const handleDecrypt = () => {
|
||||
console.log('\n🔓 DECRYPTION INITIATED');
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
console.log('📝 Cipher Text:', CIPHER_TEXT);
|
||||
console.log('🔑 Encryption Key:', ENCRYPTION_KEY);
|
||||
console.log('📏 Cipher Text Length:', CIPHER_TEXT.length);
|
||||
|
||||
try {
|
||||
const result = decrypt(CIPHER_TEXT, ENCRYPTION_KEY);
|
||||
|
||||
if (result) {
|
||||
console.log('✅ Decryption successful');
|
||||
console.log('📄 Decrypted Text:', result);
|
||||
console.log('📏 Decrypted Length:', result.length);
|
||||
setDecryptedText(result);
|
||||
} else {
|
||||
console.log('❌ Decryption failed - returned null');
|
||||
Alert.alert('Error', 'Decryption failed. Please check your encrypted text and key.');
|
||||
setDecryptedText('Decryption failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Decryption error:', error);
|
||||
Alert.alert('Error', 'An error occurred during decryption');
|
||||
setDecryptedText('Error occurred');
|
||||
} finally {
|
||||
console.log('═══════════════════════════════════════════════════════\n');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyDecrypted = () => {
|
||||
if (decryptedText) {
|
||||
Clipboard.setString(decryptedText);
|
||||
Alert.alert('Copied', 'Decrypted text copied to clipboard');
|
||||
console.log('📋 Decrypted text copied to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCipher = () => {
|
||||
Clipboard.setString(CIPHER_TEXT);
|
||||
Alert.alert('Copied', 'Encrypted text copied to clipboard');
|
||||
console.log('📋 Encrypted text copied to clipboard');
|
||||
};
|
||||
|
||||
const handleCopyKey = () => {
|
||||
Clipboard.setString(ENCRYPTION_KEY);
|
||||
Alert.alert('Copied', 'Encryption key copied to clipboard');
|
||||
console.log('📋 Encryption key copied to clipboard');
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<InsideHeader title="Decrypt Data" showArrow />
|
||||
|
||||
<ScrollView contentContainerStyle={styles.scrollContainer}>
|
||||
{/* Info Card */}
|
||||
<View style={styles.infoCard}>
|
||||
<Text style={styles.infoTitle}>🔓 AES Decryption Tool</Text>
|
||||
<Text style={styles.infoText}>
|
||||
Decryption performed automatically on screen load
|
||||
</Text>
|
||||
<Text style={styles.infoSubText}>
|
||||
Mode: AES/ECB/PKCS7Padding
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Encrypted Text Display */}
|
||||
<View style={styles.card}>
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={styles.label}>📝 Encrypted Text</Text>
|
||||
<View style={styles.iconRow}>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowCipher(!showCipher)}
|
||||
style={styles.iconButton}
|
||||
>
|
||||
{showCipher ? (
|
||||
<EyeOff size={18} color={colors.primary} />
|
||||
) : (
|
||||
<Eye size={18} color={colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleCopyCipher}
|
||||
style={styles.iconButton}
|
||||
>
|
||||
<Copy size={18} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{showCipher ? (
|
||||
<View style={styles.dataBox}>
|
||||
<Text style={styles.dataText}>{CIPHER_TEXT}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.dataBox}>
|
||||
<Text style={styles.hiddenText}>
|
||||
{'•'.repeat(Math.min(CIPHER_TEXT.length, 50))}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.infoLabel}>Length:</Text>
|
||||
<Text style={styles.infoValue}>{CIPHER_TEXT.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Encryption Key Display */}
|
||||
<View style={styles.card}>
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={styles.label}>🔑 Encryption Key</Text>
|
||||
<View style={styles.iconRow}>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowKey(!showKey)}
|
||||
style={styles.iconButton}
|
||||
>
|
||||
{showKey ? (
|
||||
<EyeOff size={18} color={colors.primary} />
|
||||
) : (
|
||||
<Eye size={18} color={colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleCopyKey}
|
||||
style={styles.iconButton}
|
||||
>
|
||||
<Copy size={18} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{showKey ? (
|
||||
<View style={styles.dataBox}>
|
||||
<Text style={styles.dataText}>{ENCRYPTION_KEY}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.dataBox}>
|
||||
<Text style={styles.hiddenText}>
|
||||
{'•'.repeat(ENCRYPTION_KEY.length)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.infoLabel}>Length:</Text>
|
||||
<Text style={styles.infoValue}>{ENCRYPTION_KEY.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Decrypt Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.decryptButton}
|
||||
onPress={handleDecrypt}
|
||||
>
|
||||
<RefreshCw size={20} color="#fff" />
|
||||
<Text style={styles.decryptButtonText}>Re-Decrypt</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Decrypted Result */}
|
||||
{decryptedText !== '' && (
|
||||
<View style={[styles.card, styles.resultCard]}>
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={styles.label}>✅ Decrypted Text</Text>
|
||||
<View style={styles.iconRow}>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowDecrypted(!showDecrypted)}
|
||||
style={styles.iconButton}
|
||||
>
|
||||
{showDecrypted ? (
|
||||
<EyeOff size={18} color={colors.primary} />
|
||||
) : (
|
||||
<Eye size={18} color={colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleCopyDecrypted}
|
||||
style={styles.iconButton}
|
||||
>
|
||||
<Copy size={18} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{showDecrypted ? (
|
||||
<View style={styles.resultBox}>
|
||||
<Text style={styles.resultText}>
|
||||
{decryptedText}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.resultBox}>
|
||||
<Text style={styles.hiddenText}>
|
||||
{'•'.repeat(Math.min(decryptedText.length, 50))}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statLabel}>Length</Text>
|
||||
<Text style={styles.statValue}>{decryptedText.length}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statLabel}>Status</Text>
|
||||
<Text style={[styles.statValue, styles.successText]}>
|
||||
✓ Success
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statLabel}>Type</Text>
|
||||
<Text style={styles.statValue}>
|
||||
{isNaN(Number(decryptedText)) ? 'Text' : 'Number'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<View style={styles.instructionCard}>
|
||||
<Text style={styles.instructionTitle}>📌 How to Use</Text>
|
||||
<Text style={styles.instructionText}>
|
||||
1. Open this file in your code editor{'\n'}
|
||||
2. Find the "HARDCODED INPUT" section at the top{'\n'}
|
||||
3. Change CIPHER_TEXT and ENCRYPTION_KEY values{'\n'}
|
||||
4. Save the file and reload the app{'\n'}
|
||||
5. Screen will auto-decrypt on load
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default DecryptionScreen;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.white,
|
||||
},
|
||||
scrollContainer: {
|
||||
padding: 16,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
infoCard: {
|
||||
backgroundColor: '#E8F4FD',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: colors.primary,
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#333',
|
||||
marginBottom: 8,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 14,
|
||||
color: '#555',
|
||||
lineHeight: 20,
|
||||
marginBottom: 4,
|
||||
},
|
||||
infoSubText: {
|
||||
fontSize: 12,
|
||||
color: '#777',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.primaryHighlight,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
elevation: 2,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
},
|
||||
resultCard: {
|
||||
backgroundColor: '#F0FDF4',
|
||||
borderWidth: 2,
|
||||
borderColor: '#10B981',
|
||||
},
|
||||
labelRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
label: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
iconButton: {
|
||||
padding: 4,
|
||||
},
|
||||
iconRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
dataBox: {
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E0E0E0',
|
||||
marginBottom: 10,
|
||||
},
|
||||
dataText: {
|
||||
fontSize: 14,
|
||||
color: '#222',
|
||||
lineHeight: 20,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
hiddenText: {
|
||||
fontSize: 18,
|
||||
color: '#999',
|
||||
letterSpacing: 2,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 8,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E0E0E0',
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 13,
|
||||
color: '#777',
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
decryptButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
backgroundColor: colors.primary,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 16,
|
||||
elevation: 3,
|
||||
},
|
||||
decryptButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
resultBox: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
borderWidth: 2,
|
||||
borderColor: '#10B981',
|
||||
minHeight: 80,
|
||||
marginBottom: 12,
|
||||
},
|
||||
resultText: {
|
||||
fontSize: 16,
|
||||
color: '#222',
|
||||
lineHeight: 24,
|
||||
fontWeight: '500',
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#D1FAE5',
|
||||
},
|
||||
statItem: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#059669',
|
||||
marginBottom: 4,
|
||||
fontWeight: '600',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: '#333',
|
||||
},
|
||||
successText: {
|
||||
color: '#10B981',
|
||||
},
|
||||
instructionCard: {
|
||||
backgroundColor: '#FFFBEB',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#FCD34D',
|
||||
},
|
||||
instructionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#92400E',
|
||||
marginBottom: 10,
|
||||
},
|
||||
instructionText: {
|
||||
fontSize: 13,
|
||||
color: '#78350F',
|
||||
lineHeight: 22,
|
||||
},
|
||||
});
|
||||
@@ -2,8 +2,7 @@ import Auction from "./Auction/Auction";
|
||||
import PurchaseAuctionList from "./Auction/PurchaseAuctionList";
|
||||
import PurchaseList from "./Auction/PurchaseList";
|
||||
import Emd from "./Bidding/Emd";
|
||||
import StartBidding from "./Bidding/StartBidding";
|
||||
import StartReverseBidding from "./Bidding/StartReverseBidding";
|
||||
import AuctionBiddingScreen from "./Bidding/AuctionBiddingScreen";
|
||||
import Home from "./Home/Home";
|
||||
import KycWebViewScreen from "./Home/KycWebViewScreen";
|
||||
import Login from "./Login/Login";
|
||||
@@ -21,9 +20,9 @@ import ProceedToPurTender from "./Tender/ProceedToPurTender";
|
||||
import PurchaseTenderList from "./Tender/PurchaseTenderList";
|
||||
import Tender from "./Tender/Tender";
|
||||
import TenderBidding from "./Tender/tenderBidding/TenderBidding";
|
||||
import DecryptionScreen from "./TestScreen/DecryptionScreen";
|
||||
import DeviceBinding from "./Tender/DeviceBinding";
|
||||
import DscBinding from "./Tender/DscBinding";
|
||||
import EnvelopeCSpreadsheet from "./Tender/EnvelopeC/EnvelopeCSpreadsheetScreen";
|
||||
|
||||
|
||||
const Screens = {
|
||||
@@ -35,9 +34,11 @@ const Screens = {
|
||||
Tender,
|
||||
PurchaseList,
|
||||
Emd,
|
||||
StartBidding,
|
||||
// Both bidding routes render the same pattern-driven screen; the route
|
||||
// names are kept so existing navigation calls stay valid.
|
||||
StartBidding: AuctionBiddingScreen,
|
||||
StartReverseBidding: AuctionBiddingScreen,
|
||||
PurchaseAuctionList,
|
||||
StartReverseBidding,
|
||||
PurchaseTenderList,
|
||||
ProceedToPurTender,
|
||||
TenderBidding,
|
||||
@@ -50,8 +51,8 @@ const Screens = {
|
||||
ResetPassword,
|
||||
KycWebViewScreen,
|
||||
StageThreeScreen,
|
||||
DecryptionScreen,
|
||||
DeviceBinding,
|
||||
DscBinding
|
||||
DscBinding,
|
||||
EnvelopeCSpreadsheet
|
||||
}
|
||||
export default Screens
|
||||
export default Screens
|
||||
|
||||
Reference in New Issue
Block a user