perf: project-wide optimization pass + UI/theme fixes
Optimization (audited across src/, typecheck green): - Crash guards: PurchaseAuction data?.result + stray text-node, AuctionItemDetail length>0, LiveAuction user?.contractorId, KYC params guard - Hoisted render-defined components: Login MinimalInput (keyboard-focus bug), TenderFullDetail Section/InfoBox (subtree remount) - Memoized hot paths: Spreadsheet ColumnHeaderRow/DataRow, list makeStyles, shared infinite-query select (stops per-render flatMap) - Removed hot-path/in-list console logs; SkeletonList 10->1 shimmer UI/theme: themed AppToast, redesigned sidebar drawer, list empty states, KYC WebView cleanup Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7853bab639
commit
95d8e0a925
@@ -28,7 +28,6 @@ const Auction = () => {
|
||||
const { data: liveAuctionData = {} }: any = useFetchLiveAuction();
|
||||
const { data: purchaseAuctionData = {} } = useFetchPurchaseAuction();
|
||||
const SelectedPurchaseList = useSelector((state: any) => state.selectPurchaseAuction);
|
||||
useSelector((state: any) => state.token?.token);
|
||||
|
||||
const liveCount = Array.isArray(liveAuctionData?.result)
|
||||
? liveAuctionData?.pages?.[0]?.totalCount
|
||||
@@ -58,7 +57,10 @@ const Auction = () => {
|
||||
|
||||
const cartCount = SelectedPurchaseList.length;
|
||||
|
||||
const styles = makeStyles(colors, spacing, shadows, TAB_WIDTH, TAB_HEIGHT);
|
||||
const styles = useMemo(
|
||||
() => makeStyles(colors, spacing, shadows, TAB_WIDTH, TAB_HEIGHT),
|
||||
[colors, spacing, shadows, TAB_WIDTH],
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import Animated, { FadeInDown, FadeOutUp } from 'react-native-reanimated';
|
||||
import LiveAuctionTile from './LiveAuctionTile';
|
||||
import { useFetchLiveAuction } from '../../Services/ApiCall/Hooks';
|
||||
import { SearchInput } from '../../components/SearchInput';
|
||||
import { Skeleton } from '../../components';
|
||||
import { Skeleton, EmptyState } from '../../components';
|
||||
import SkeletonList from '../../components/SkeletonList';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { togglePurchaseAuction } from '../../Redux/Slices/selectPurchaseAuction';
|
||||
@@ -46,7 +46,7 @@ const LiveAuction = () => {
|
||||
data={item}
|
||||
index={index}
|
||||
isPurchasedByContractor={item.contractorId?.includes(
|
||||
String(user.contractorId),
|
||||
String(user?.contractorId),
|
||||
)}
|
||||
auctionId={item.auctionId}
|
||||
auctionDispatchDate={item.auctionDispatchDate}
|
||||
@@ -56,7 +56,6 @@ const LiveAuction = () => {
|
||||
auctionFees={item.auctionFees}
|
||||
auctionNumber={item.auctionNumber}
|
||||
onPurchasePress={() => {
|
||||
console.log('[LiveAuction] purchase toggled:', item?.auctionNumber);
|
||||
Dispatch(togglePurchaseAuction(item));
|
||||
}}
|
||||
onDetailsPress={() => {}}
|
||||
@@ -71,7 +70,6 @@ const LiveAuction = () => {
|
||||
const loadMore = useCallback(() => {
|
||||
if (onEndReachedCalledDuringMomentum.current) return;
|
||||
if (!hasNextPage || isFetchingNextPage) return;
|
||||
console.log('[LiveAuction] loadMore: fetching next page');
|
||||
fetchNextPage();
|
||||
onEndReachedCalledDuringMomentum.current = true;
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
@@ -90,24 +88,13 @@ const LiveAuction = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (isRefetching) {
|
||||
console.log('[LiveAuction] refetch started');
|
||||
setShowRefreshBanner(true);
|
||||
} else {
|
||||
console.log('[LiveAuction] refetch completed');
|
||||
const timer = setTimeout(() => setShowRefreshBanner(false), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isRefetching]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[LiveAuction] data updated:', {
|
||||
count: data?.result?.length ?? 0,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isPending,
|
||||
});
|
||||
}, [data, hasNextPage, isFetchingNextPage, isPending]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
@@ -153,6 +140,12 @@ const LiveAuction = () => {
|
||||
onEndReachedCalledDuringMomentum.current = false;
|
||||
}}
|
||||
ListFooterComponent={renderFooter}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
title="No live auctions found"
|
||||
subtitle="New live auctions will appear here as they open."
|
||||
/>
|
||||
}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefetching}
|
||||
@@ -178,6 +171,7 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 60,
|
||||
paddingTop: 10,
|
||||
|
||||
@@ -23,6 +23,8 @@ import ReactNativeBlobUtil from 'react-native-blob-util';
|
||||
import AuctionItemDetail from './AuctionItemDetail';
|
||||
import { useTheme } from '../../../Hooks/useTheme';
|
||||
import AppText from '../../../components/AppText';
|
||||
import { showToast } from '../../../Constant/ShowDailog';
|
||||
import { logger } from '../../../core/observability/logger';
|
||||
import moment from 'moment';
|
||||
|
||||
interface Props { visible: boolean; onClose: () => void; data?: any }
|
||||
@@ -36,8 +38,11 @@ const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
const [allBoolean, setAllBoolean] = useState({
|
||||
showItemDetail: false,
|
||||
selectedItem: {},
|
||||
fileLoading: false,
|
||||
});
|
||||
// Tracks WHICH document is downloading, so only that row shows a spinner.
|
||||
// A shared boolean lit up every row's spinner at once, making a single
|
||||
// per-document download look like it was downloading all of them.
|
||||
const [downloadingId, setDownloadingId] = useState<number | null>(null);
|
||||
const { data: auctionDocuments } = useGetAucitonDocument({ auctionId: data?.auctionId });
|
||||
const { mutate } = useAuctionItem();
|
||||
|
||||
@@ -46,13 +51,10 @@ const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
return moment(date, 'DD-MM-YYYY HH:mm:ss').format('DD MMM YYYY, hh:mm A');
|
||||
};
|
||||
|
||||
const isLive = data?.purchaseEnd
|
||||
? moment().isBefore(moment(data.purchaseEnd, 'DD-MM-YYYY HH:mm:ss'))
|
||||
: false;
|
||||
|
||||
const handleDownload = async (id: number, fileName: string) => {
|
||||
if (downloadingId !== null) { return; } // one download at a time
|
||||
try {
|
||||
setAllBoolean(prev => ({ ...prev, fileLoading: true }));
|
||||
setDownloadingId(id);
|
||||
const { dirs } = ReactNativeBlobUtil.fs;
|
||||
const path = `${dirs.DownloadDir}/${fileName}.pdf`;
|
||||
const downloadUrl = `${AUCTION_DOCUMENT_DOWNLOAD}?documentId=${id}&isAmendment=false`;
|
||||
@@ -68,10 +70,12 @@ const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
mediaScannable: true,
|
||||
},
|
||||
}).fetch('GET', downloadUrl, { Accept: 'application/pdf' });
|
||||
showToast('Download complete');
|
||||
} catch (error: any) {
|
||||
console.log('Error:', error?.message || error);
|
||||
logger.error('auction', 'document download failed', { error: String(error?.message || error) });
|
||||
showToast('Download failed. Please try again.');
|
||||
} finally {
|
||||
setAllBoolean(prev => ({ ...prev, fileLoading: false }));
|
||||
setDownloadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -120,12 +124,6 @@ const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
</AppText>
|
||||
</View>
|
||||
|
||||
<View style={[styles.statusChip, { borderColor: isLive ? `${colors.success}50` : colors.border }]}>
|
||||
<View style={[styles.statusDot, { backgroundColor: isLive ? colors.success : colors.textSecondary }]} />
|
||||
<AppText variant="caption" color={isLive ? colors.success : colors.textSecondary} style={styles.statusText as any}>
|
||||
{isLive ? 'LIVE' : 'CLOSED'}
|
||||
</AppText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ── Body ── */}
|
||||
@@ -204,7 +202,10 @@ const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
index === aucitonDetail.auctionDocuments.length - 1 && { borderBottomWidth: 0 },
|
||||
]}
|
||||
activeOpacity={0.7}
|
||||
disabled={downloadingId !== null}
|
||||
onPress={() => handleDownload(doc.auctionDocumentId, doc.fileName.replace('.pdf', ''))}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Download ${doc.fileName}`}
|
||||
>
|
||||
<View style={[styles.docIcon, { backgroundColor: `${colors.error}15` }]}>
|
||||
<FileText size={16} color={colors.error} strokeWidth={2} />
|
||||
@@ -212,7 +213,7 @@ const AuctionFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
<AppText variant="bodySmall" color={colors.text} style={styles.docName as any} numberOfLines={1}>
|
||||
{doc.fileName}
|
||||
</AppText>
|
||||
{allBoolean.fileLoading
|
||||
{downloadingId === doc.auctionDocumentId
|
||||
? <ActivityIndicator size="small" color={colors.primary} />
|
||||
: <Download size={18} color={colors.textSecondary} strokeWidth={2} />
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ const AuctionItemDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
<AppText variant="bodySmall" style={styles.descContent}>{data?.briefDescription}</AppText>
|
||||
</Section>
|
||||
|
||||
{itemData?.length && <Section title="View Documents" icon={FileText} styles={styles} colors={colors}>
|
||||
{itemData?.length > 0 && <Section title="View Documents" icon={FileText} styles={styles} colors={colors}>
|
||||
<View style={styles.docTableHead}>
|
||||
<AppText variant="caption" bold style={styles.tableLabel}>Sr No.</AppText>
|
||||
<AppText variant="caption" bold style={[styles.tableLabel, { flex: 2 }]}>Document Name</AppText>
|
||||
|
||||
@@ -7,7 +7,7 @@ import PurchaseAuctionTile from './PurchaseAuctionTile';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { setAuction } from '../../Redux/Slices/SelectedAuction';
|
||||
import { SearchInput } from '../../components/SearchInput';
|
||||
import { Skeleton } from '../../components';
|
||||
import { Skeleton, EmptyState } from '../../components';
|
||||
import { allBooleanProps } from './types/types';
|
||||
import FilterModal from './Modal/FilterModal';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
@@ -25,8 +25,11 @@ const PurchaseAuction = () => {
|
||||
|
||||
function isAuctionActive(item: any): boolean {
|
||||
const now = moment();
|
||||
const start = moment(item?.keyDates?.auctionBidding?.startDateTime, format);
|
||||
const end = moment(item?.keyDates?.auctionBidding?.endDateTime, format);
|
||||
const bidding = item?.auctionStatus === 'Amended'
|
||||
? item?.amendedkeyDates?.auctionBidding
|
||||
: item?.keyDates?.auctionBidding;
|
||||
const start = moment(bidding?.startDateTime, format);
|
||||
const end = moment(bidding?.endDateTime, format);
|
||||
|
||||
if (!start.isValid() || !end.isValid()) return false;
|
||||
|
||||
@@ -67,7 +70,7 @@ const PurchaseAuction = () => {
|
||||
{isPending ? <Skeleton />
|
||||
:
|
||||
<FlatList
|
||||
data={data.result ?? []}
|
||||
data={data?.result ?? []}
|
||||
renderItem={({ item }) => (
|
||||
<PurchaseAuctionTile
|
||||
item={item}
|
||||
@@ -82,7 +85,7 @@ const PurchaseAuction = () => {
|
||||
/>
|
||||
)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 50, paddingTop: 10 }}
|
||||
contentContainerStyle={{ flexGrow: 1, paddingBottom: 50, paddingTop: 10 }}
|
||||
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.1}
|
||||
@@ -92,6 +95,12 @@ const PurchaseAuction = () => {
|
||||
}}
|
||||
|
||||
ListFooterComponent={renderFooter}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
title="No purchased auctions found"
|
||||
subtitle="Auctions you purchase will appear here."
|
||||
/>
|
||||
}
|
||||
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
@@ -110,7 +119,7 @@ const PurchaseAuction = () => {
|
||||
}
|
||||
{allBoolean.isShowFilter && <View style={{ flex: 1 }}>
|
||||
<FilterModal visible={allBoolean.isShowFilter} onClose={() => { setAllBoolean((prev: any) => ({ ...prev, isShowFilter: false })) }} setFilter={setFilter} filter={filter} />
|
||||
</View>}.
|
||||
</View>}
|
||||
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -95,6 +95,21 @@ const PurchaseAuctionList = () => {
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
const goHome = () => {
|
||||
Dispatch(clearPurchaseAuction());
|
||||
Navigation.dispatch(
|
||||
CommonActions.reset({ index: 0, routes: [{ name: NavigationString.Home }] }),
|
||||
);
|
||||
};
|
||||
|
||||
// The auction was already purchased — proceedToPurchase then has no cart to
|
||||
// show, so bounce back to Home instead of leaving a blank list. (The backend
|
||||
// message is surfaced by the global API toast.)
|
||||
const isAlreadyPurchased = (res: any): boolean => {
|
||||
const message = String(res?.message ?? '').toLowerCase();
|
||||
return res?.statusCode === 409 || message.includes('already');
|
||||
};
|
||||
|
||||
const getPurchaseList = () => {
|
||||
const auctionNumbers: string[] = SelectedPurchaseList.map(
|
||||
(item: { auctionNumber: string }) => item.auctionNumber,
|
||||
@@ -103,9 +118,19 @@ const PurchaseAuctionList = () => {
|
||||
{ auctionNumbers, contractorId: LoginUser.contractorId },
|
||||
{
|
||||
onSuccess: res => {
|
||||
if (res.statusCode === 200) setList(res.data);
|
||||
if (res.statusCode === 200) {
|
||||
setList(res.data);
|
||||
} else if (isAlreadyPurchased(res)) {
|
||||
goHome();
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (isAlreadyPurchased(error?.response?.data)) {
|
||||
goHome();
|
||||
} else {
|
||||
console.log(error, 'purchase list error');
|
||||
}
|
||||
},
|
||||
onError: error => console.log(error, 'purchase list error'),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -42,12 +42,15 @@ const AuctionBiddingScreen = () => {
|
||||
const { colors, spacing, shadows } = useTheme();
|
||||
const SelectedAuction = useSelector((state: any) => state.auction.auction);
|
||||
const loginUser = useSelector((state: any) => state.token.token);
|
||||
// The login session stores the contractor id under `id` (see ProfileTab /
|
||||
// LiveTender); the older `contractorId` key is absent, which is why bid
|
||||
// payloads and "me"/LEADING detection were getting undefined.
|
||||
const contractorId = loginUser?.contractorId ?? loginUser?.id;
|
||||
const Navigation: any = useNavigation();
|
||||
const config = useMemo(
|
||||
() => getBiddingConfig(SelectedAuction?.auctionPattern),
|
||||
[SelectedAuction?.auctionPattern],
|
||||
);
|
||||
|
||||
const [items, setItems] = useState<any>([]);
|
||||
const [bidValues, setBidValues] = useState<Record<number, number>>({});
|
||||
const [allBoolean, setAllBoolean] = useState({
|
||||
@@ -104,11 +107,11 @@ const AuctionBiddingScreen = () => {
|
||||
...item,
|
||||
bidAmount: bidData?.bidAmount ?? config.startValue(item),
|
||||
bidAvilable: bidData?.bidAmount ?? 0,
|
||||
me: bidData?.contractorId === loginUser.contractorId,
|
||||
me: bidData?.contractorId === contractorId,
|
||||
};
|
||||
});
|
||||
const initialBids: Record<number, number> = {};
|
||||
updatedFilteredItems.forEach(item => { initialBids[item.id] = item.bidAmount; });
|
||||
updatedFilteredItems.forEach(item => { initialBids[item.id] = Number(item.bidAmount); });
|
||||
setBidValues(initialBids);
|
||||
setItems(updatedFilteredItems);
|
||||
} else {
|
||||
@@ -125,12 +128,12 @@ const AuctionBiddingScreen = () => {
|
||||
bidId: bid.bidId,
|
||||
contractorId: bid.contractorId,
|
||||
contractorName: bid.contractorName,
|
||||
me: bid.contractorId === loginUser.contractorId,
|
||||
me: bid.contractorId === contractorId,
|
||||
bidTime: bid.bidTime,
|
||||
};
|
||||
});
|
||||
const initialBids: Record<number, number> = {};
|
||||
mergedData.forEach(item => { initialBids[item.id] = item.bidAmount; });
|
||||
mergedData.forEach(item => { initialBids[item.id] = Number(item.bidAmount); });
|
||||
setBidValues(initialBids);
|
||||
setItems(mergedData);
|
||||
}
|
||||
@@ -152,7 +155,7 @@ const AuctionBiddingScreen = () => {
|
||||
: item,
|
||||
),
|
||||
);
|
||||
setBidValues(prev => ({ ...prev, [result.auctionItemId]: result.bidAmount }));
|
||||
setBidValues(prev => ({ ...prev, [result.auctionItemId]: Number(result.bidAmount) }));
|
||||
});
|
||||
return () => unsubscribe(destination);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -170,8 +173,19 @@ const AuctionBiddingScreen = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allBoolean.reload]);
|
||||
|
||||
const handleUpdate = (id: number, val: number) => {
|
||||
setBidValues(prev => ({ ...prev, [id]: prev[id] + val }));
|
||||
// Move a bid one step in the given direction. The next value is computed from
|
||||
// the LIVE `prev` state (no stale closure on rapid taps) and clamped to the
|
||||
// pattern's [floor, ceil] by the config, so the button always moves the value
|
||||
// by a real amount and can never overshoot a bound.
|
||||
const stepBid = (item: any, direction: 'up' | 'down') => {
|
||||
setBidValues(prev => {
|
||||
let current = Number(prev[item.id]);
|
||||
if (!Number.isFinite(current)) { current = config.startValue(item); }
|
||||
const next = direction === 'up'
|
||||
? config.stepUp(current, item)
|
||||
: config.stepDown(current, item);
|
||||
return { ...prev, [item.id]: next };
|
||||
});
|
||||
};
|
||||
|
||||
const submitBid = async (item: any) => {
|
||||
@@ -179,7 +193,7 @@ const AuctionBiddingScreen = () => {
|
||||
itemId: item.id,
|
||||
bidAmount: bidValues[item.id],
|
||||
auctionId: SelectedAuction.auctionId,
|
||||
contractorId: loginUser.contractorId,
|
||||
contractorId,
|
||||
fullName: loginUser.fullName,
|
||||
roomId: SelectedAuction.auctionNumber,
|
||||
};
|
||||
@@ -212,7 +226,7 @@ const AuctionBiddingScreen = () => {
|
||||
}, []),
|
||||
);
|
||||
|
||||
const styles = makeStyles(colors, spacing, shadows);
|
||||
const styles = useMemo(() => makeStyles(colors, spacing, shadows), [colors, spacing, shadows]);
|
||||
|
||||
const biddingEndDate =
|
||||
SelectedAuction?.auctionStatus === 'Amended'
|
||||
@@ -280,8 +294,8 @@ const AuctionBiddingScreen = () => {
|
||||
<AuctionBiddingCard
|
||||
item={{ ...item, index }}
|
||||
bidValue={bidValues[item.id] || 0}
|
||||
onIncrease={() => handleUpdate(item.id, config.step(item))}
|
||||
onDecrease={() => handleUpdate(item.id, -config.step(item))}
|
||||
onIncrease={() => stepBid(item, 'up')}
|
||||
onDecrease={() => stepBid(item, 'down')}
|
||||
onPlaceBid={() => submitBid(item)}
|
||||
SelectedAuction={SelectedAuction}
|
||||
allBoolean={allBoolean}
|
||||
|
||||
@@ -43,7 +43,6 @@ const AuctionBiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid
|
||||
const canDecrease = config.canDecrease(bidValue, item);
|
||||
const bodyFull = config.isBodyFull(allBoolean, item);
|
||||
const submitVisible = config.showSubmit(allBoolean, item);
|
||||
|
||||
// 🔒 Persist expand state safely
|
||||
const expandedRef = useRef(false);
|
||||
const animation = useSharedValue(0);
|
||||
@@ -98,7 +97,8 @@ const AuctionBiddingCard = ({ item, bidValue, onIncrease, onDecrease, onPlaceBid
|
||||
itemId: item.id,
|
||||
bidAmount: Number(value),
|
||||
auctionId: SelectedAuction.auctionId,
|
||||
contractorId: Token.contractorId,
|
||||
// Session stores the contractor id as `id` (contractorId is absent).
|
||||
contractorId: Token?.contractorId ?? Token?.id,
|
||||
fullName: Token.fullName,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -108,7 +108,6 @@ const Emd = () => {
|
||||
|
||||
/* ── Render Item ── */
|
||||
const renderItem = ({ item }: any) => {
|
||||
console.log(item, "item");
|
||||
const selected = isSelected(item.id);
|
||||
const selectedData = selectedItems.find(i => i.auctionItemId === item.id);
|
||||
|
||||
@@ -352,7 +351,7 @@ const Emd = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { AlreadyEmdCheck(); }, []);
|
||||
|
||||
const styles = makeStyles(colors, spacing, shadows, typography);
|
||||
const styles = useMemo(() => makeStyles(colors, spacing, shadows, typography), [colors, spacing, shadows, typography]);
|
||||
|
||||
/* ── Policy + Submit footer ── */
|
||||
const BottomBar = ({ onSubmit }: { onSubmit: () => void }) => (
|
||||
|
||||
@@ -53,17 +53,24 @@ describe('forward pattern', () => {
|
||||
|
||||
describe('reverse pattern', () => {
|
||||
const cfg = getBiddingConfig('Reverse');
|
||||
const item = { auctionStartValue: 1000, emdAmount: 50, bidAmount: 800 };
|
||||
const item = { auctionStartValue: 1000, emdAmount: 50, bidAmount: 800, bidVariationValue: 50 };
|
||||
|
||||
it('reads the reverse start-value and step fields', () => {
|
||||
expect(cfg.itemsUrl).toBe(REVERSE_AUCTION_ITEM);
|
||||
expect(cfg.startValue(item)).toBe(1000);
|
||||
expect(cfg.step(item)).toBe(50);
|
||||
expect(cfg.step(item)).toBe(50); // the configured bid-variation
|
||||
expect(cfg.stepLabel).toBe('Decrement');
|
||||
expect(cfg.stepPrefix).toBe('-');
|
||||
expect(cfg.inlineManualInput).toBe(true);
|
||||
});
|
||||
|
||||
it('decrements by bidVariationValue, not the EMD deposit', () => {
|
||||
// Real payloads send amounts as strings; the decrement is the bid variation.
|
||||
expect(cfg.step({ bidVariationValue: '11', emdAmount: '0' })).toBe(11);
|
||||
// With no bid-variation, it does NOT use the EMD deposit — it steps by ₹1.
|
||||
expect(cfg.step({ emdAmount: '50' })).toBe(1);
|
||||
});
|
||||
|
||||
it('caps increases at the current bid and decreases at the EMD floor', () => {
|
||||
expect(cfg.canIncrease(700, item)).toBe(true); // 700 < 800
|
||||
expect(cfg.canIncrease(800, item)).toBe(false);
|
||||
@@ -84,3 +91,44 @@ describe('reverse pattern', () => {
|
||||
expect(cfg.validateManualBid(123, item)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stepper robustness (both patterns)', () => {
|
||||
const rev = getBiddingConfig('Reverse');
|
||||
const fwd = getBiddingConfig('Forward');
|
||||
|
||||
it('never resolves a zero/NaN step, even when every amount field is missing', () => {
|
||||
// This is the exact "DECREMENT -₹0 / button does nothing" bug.
|
||||
expect(rev.step({ auctionStartValue: '550', bidAmount: '539', emdAmount: '0' })).toBeGreaterThan(0);
|
||||
expect(rev.step({})).toBeGreaterThanOrEqual(1);
|
||||
expect(fwd.step({})).toBeGreaterThanOrEqual(1);
|
||||
// With no configured bid-variation the step is a flat ₹1.
|
||||
expect(rev.step({ auctionStartValue: 1000 })).toBe(1);
|
||||
expect(fwd.step({ auctionStartValueFigure: 200 })).toBe(1);
|
||||
});
|
||||
|
||||
it('reverse stepDown moves and clamps to the EMD floor without overshooting', () => {
|
||||
const it = { auctionStartValue: 1000, emdAmount: 50, bidAmount: 800, bidVariationValue: 50 };
|
||||
expect(rev.stepDown(800, it)).toBe(750);
|
||||
expect(rev.stepDown(60, it)).toBe(50); // 60-50=10 would undershoot -> clamped to floor
|
||||
expect(rev.stepDown(50, it)).toBe(50); // already at floor
|
||||
});
|
||||
|
||||
it('reverse stepUp clamps to the current bid (ceiling)', () => {
|
||||
const it = { auctionStartValue: 1000, emdAmount: 50, bidAmount: 800, bidVariationValue: 50 };
|
||||
expect(rev.stepUp(760, it)).toBe(800); // 760+50=810 would exceed -> clamped to ceil
|
||||
expect(rev.stepUp(800, it)).toBe(800); // already at ceil
|
||||
});
|
||||
|
||||
it('forward stepUp is unbounded and stepDown floors at the current bid', () => {
|
||||
const it = { auctionStartValueFigure: 100, bidVariationValue: 10, bidAmount: 150 };
|
||||
expect(fwd.stepUp(150, it)).toBe(160);
|
||||
expect(fwd.stepDown(160, it)).toBe(150);
|
||||
expect(fwd.stepDown(150, it)).toBe(150); // can't undercut the current bid
|
||||
});
|
||||
|
||||
it('a reverse item with no variation still decrements (regression for -₹0)', () => {
|
||||
const it = { auctionStartValue: '550', bidAmount: '539', emdAmount: '0' };
|
||||
expect(rev.canDecrease(539, it)).toBe(true);
|
||||
expect(rev.stepDown(539, it)).toBeLessThan(539);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
* Every behavioral difference between them is captured HERE, explicitly, so
|
||||
* the screen and card contain zero `if (pattern === ...)` logic and a bidding
|
||||
* fix can never again land in one pattern but not the other.
|
||||
*
|
||||
* The stepper is modelled as a bounded number line per pattern:
|
||||
* - Forward: bid UP. floor = current bid (never undercut it), no ceiling.
|
||||
* - Reverse: bid DOWN. ceiling = current bid (never exceed it), floor = EMD.
|
||||
* `stepUp`/`stepDown` return the next value already clamped to [floor, ceil],
|
||||
* and `canIncrease`/`canDecrease` are derived from them, so the buttons can
|
||||
* never overshoot a bound and can never be "enabled but do nothing".
|
||||
*/
|
||||
import { FORWARD_AUCTION_ITEM, REVERSE_AUCTION_ITEM } from '../../Services/ApiCall/ApiUrls';
|
||||
|
||||
@@ -21,13 +28,19 @@ export type BiddingPatternConfig = {
|
||||
itemsUrl: string;
|
||||
/** Opening amount — the backend uses a different field per pattern. */
|
||||
startValue: (item: any) => number;
|
||||
/** Stepper step size. */
|
||||
/** Stepper step size — always a positive number (see resolveStep). */
|
||||
step: (item: any) => number;
|
||||
stepLabel: 'Increment' | 'Decrement';
|
||||
stepPrefix: '+' | '-';
|
||||
/** Forward bids may rise without bound; reverse bids may not exceed the current bid. */
|
||||
/** Lowest value the working bid may reach. */
|
||||
floor: (item: any) => number;
|
||||
/** Highest value the working bid may reach. */
|
||||
ceil: (item: any) => number;
|
||||
/** Next value after pressing +/-, already clamped into [floor, ceil]. */
|
||||
stepUp: (bidValue: number, item: any) => number;
|
||||
stepDown: (bidValue: number, item: any) => number;
|
||||
/** True only when the corresponding button would actually move the value. */
|
||||
canIncrease: (bidValue: number, item: any) => boolean;
|
||||
/** Forward bids may not drop below the current bid; reverse not below the EMD amount. */
|
||||
canDecrease: (bidValue: number, item: any) => boolean;
|
||||
/** Gate applied while typing a manual (one-time/rebid) amount. */
|
||||
acceptManualInput: (value: number, item: any) => boolean;
|
||||
@@ -41,18 +54,67 @@ export type BiddingPatternConfig = {
|
||||
showSubmit: (flags: BiddingFlags, item: any) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Amounts arrive from the backend as strings (e.g. auctionFees: "400"), so any
|
||||
* bare `>`/`<`/`+` against them silently string-compares or concatenates. Every
|
||||
* numeric read below goes through `num` so steps, bounds and comparisons are
|
||||
* always real arithmetic; a missing/non-numeric field degrades to 0.
|
||||
*/
|
||||
const num = (v: any): number => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
/** First positive numeric value among the given keys, else 0. */
|
||||
const firstPositive = (item: any, keys: string[]): number => {
|
||||
for (const k of keys) {
|
||||
const n = num(item?.[k]);
|
||||
if (n > 0) return n;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// The bid-variation amount can arrive under any of these keys depending on the
|
||||
// endpoint; whichever is present and positive is the authoritative step.
|
||||
const STEP_KEYS = [
|
||||
'bidVariationValue',
|
||||
'bidVariation',
|
||||
'variationValue',
|
||||
'bidVariationFigure',
|
||||
'incrementValue',
|
||||
'decrementValue',
|
||||
];
|
||||
|
||||
/**
|
||||
* The +/- step for an item:
|
||||
* - the configured bid-variation amount when the backend provides one,
|
||||
* - otherwise a flat ₹1, so a misconfigured auction (variation 0/absent) can
|
||||
* never leave the stepper frozen and the user can still nudge the bid.
|
||||
*/
|
||||
const resolveStep = (item: any): number => {
|
||||
const configured = firstPositive(item, STEP_KEYS);
|
||||
return configured > 0 ? configured : 1;
|
||||
};
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number): number => Math.min(Math.max(v, lo), hi);
|
||||
|
||||
const forward: BiddingPatternConfig = {
|
||||
pattern: 'Forward',
|
||||
itemsUrl: FORWARD_AUCTION_ITEM,
|
||||
startValue: item => item?.auctionStartValueFigure,
|
||||
step: item => item?.bidVariationValue,
|
||||
startValue: item => num(item?.auctionStartValueFigure),
|
||||
step: item => resolveStep(item),
|
||||
stepLabel: 'Increment',
|
||||
stepPrefix: '+',
|
||||
canIncrease: () => true,
|
||||
canDecrease: (bidValue, item) => bidValue > item.bidAmount,
|
||||
// Forward: outbid upward. Never below the current bid; no upper limit.
|
||||
floor: item => num(item?.bidAmount),
|
||||
ceil: () => Number.POSITIVE_INFINITY,
|
||||
stepUp: (bidValue, item) => clamp(num(bidValue) + forward.step(item), forward.floor(item), forward.ceil(item)),
|
||||
stepDown: (bidValue, item) => clamp(num(bidValue) - forward.step(item), forward.floor(item), forward.ceil(item)),
|
||||
canIncrease: (bidValue, item) => forward.stepUp(bidValue, item) > num(bidValue),
|
||||
canDecrease: (bidValue, item) => forward.stepDown(bidValue, item) < num(bidValue),
|
||||
acceptManualInput: () => true,
|
||||
validateManualBid: (value, item) =>
|
||||
value < Number(item.bidAmount) ? 'Enter a value greater than the current bid value' : null,
|
||||
num(value) < num(item?.bidAmount) ? 'Enter a value greater than the current bid value' : null,
|
||||
inlineManualInput: false,
|
||||
isBodyFull: (flags, item) => !flags.rebid && item.contractorId == undefined,
|
||||
showSubmit: (flags, item) => !flags.rebid && item.contractorId == undefined,
|
||||
@@ -61,13 +123,18 @@ const forward: BiddingPatternConfig = {
|
||||
const reverse: BiddingPatternConfig = {
|
||||
pattern: 'Reverse',
|
||||
itemsUrl: REVERSE_AUCTION_ITEM,
|
||||
startValue: item => item?.auctionStartValue,
|
||||
step: item => item?.emdAmount,
|
||||
startValue: item => num(item?.auctionStartValue),
|
||||
step: item => resolveStep(item),
|
||||
stepLabel: 'Decrement',
|
||||
stepPrefix: '-',
|
||||
canIncrease: (bidValue, item) => bidValue < item.bidAmount,
|
||||
canDecrease: (bidValue, item) => bidValue > item.emdAmount,
|
||||
acceptManualInput: (value, item) => value >= 0 && value <= Number(item?.auctionStartValue),
|
||||
// Reverse: undercut downward. Never above the current bid; never below EMD.
|
||||
floor: item => num(item?.emdAmount),
|
||||
ceil: item => num(item?.bidAmount) || num(item?.auctionStartValue),
|
||||
stepUp: (bidValue, item) => clamp(num(bidValue) + reverse.step(item), reverse.floor(item), reverse.ceil(item)),
|
||||
stepDown: (bidValue, item) => clamp(num(bidValue) - reverse.step(item), reverse.floor(item), reverse.ceil(item)),
|
||||
canIncrease: (bidValue, item) => reverse.stepUp(bidValue, item) > num(bidValue),
|
||||
canDecrease: (bidValue, item) => reverse.stepDown(bidValue, item) < num(bidValue),
|
||||
acceptManualInput: (value, item) => num(value) >= 0 && num(value) <= num(item?.auctionStartValue),
|
||||
validateManualBid: () => null,
|
||||
inlineManualInput: true,
|
||||
isBodyFull: () => true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { View, StyleSheet, Dimensions, ActivityIndicator, ScrollView, TouchableOpacity } from 'react-native';
|
||||
import { View, StyleSheet, Dimensions, ActivityIndicator, ScrollView, TouchableOpacity, ImageBackground } from 'react-native';
|
||||
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
|
||||
import { Layers, ShoppingCart, X, FileText, Menu, Radio, Clock, ChevronRight } from 'lucide-react-native';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
@@ -9,12 +9,18 @@ import { getToken } from '../../Redux/Slices/Token';
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import AppText from '../../components/AppText';
|
||||
import SidebarModal from '../../components/SidebarModal';
|
||||
import ImagePath from '../../Constant/ImagePath';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
const Home = () => {
|
||||
const { colors } = useTheme();
|
||||
const { colors, isDarkMode } = useTheme();
|
||||
|
||||
// Light mode shows the constellation wallpaper behind the content, so the
|
||||
// cards are made more translucent to let the pattern read through. Dark mode
|
||||
// has no wallpaper, so it keeps the standard glass surface.
|
||||
const cardBg = isDarkMode ? colors.glass : 'rgba(255, 255, 255, 0.45)';
|
||||
|
||||
const [auctionStats, setAuctionStats] = useState<any>({});
|
||||
const [tenderStats, setTenderStats] = useState<any>({});
|
||||
@@ -54,7 +60,7 @@ const Home = () => {
|
||||
styles.glassCard,
|
||||
styles.halfWidthCard,
|
||||
{
|
||||
backgroundColor: colors.glass,
|
||||
backgroundColor: cardBg,
|
||||
borderColor: colors.overlay,
|
||||
}
|
||||
]}
|
||||
@@ -72,8 +78,13 @@ const Home = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
|
||||
<ImageBackground
|
||||
source={ImagePath.HomeBg}
|
||||
style={[styles.container, { backgroundColor: colors.background }]}
|
||||
// Dark mode has no wallpaper — hide the cream image, keep the solid bg.
|
||||
imageStyle={isDarkMode ? styles.hiddenBg : undefined}
|
||||
resizeMode="cover"
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
|
||||
{/* Top Bar */}
|
||||
@@ -145,7 +156,7 @@ const Home = () => {
|
||||
{/* Recent Activity */}
|
||||
<View style={styles.sectionContainer}>
|
||||
<AppText variant="body" color={colors.text} style={{ fontWeight: 'bold', marginBottom: 10 }}>Recent Activity</AppText>
|
||||
<View style={[styles.activityCard, { backgroundColor: colors.glass, borderColor: colors.overlay }]}>
|
||||
<View style={[styles.activityCard, { backgroundColor: cardBg, borderColor: colors.overlay }]}>
|
||||
<View style={[styles.activityIcon, { backgroundColor: colors.overlay }]}>
|
||||
<Clock size={20} color={colors.primary} />
|
||||
</View>
|
||||
@@ -157,7 +168,7 @@ const Home = () => {
|
||||
</View>
|
||||
|
||||
</ScrollView>
|
||||
</View >
|
||||
</ImageBackground>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -167,6 +178,10 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
// Hides the ImageBackground's image layer in dark mode (no wallpaper there).
|
||||
hiddenBg: {
|
||||
opacity: 0,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingTop: 40,
|
||||
paddingHorizontal: 16,
|
||||
|
||||
@@ -1,32 +1,42 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { View, ActivityIndicator, StyleSheet, Text } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { WebView } from "react-native-webview";
|
||||
import { WebView, WebViewNavigation } from "react-native-webview";
|
||||
import { CommonActions } from "@react-navigation/native";
|
||||
import colors from "../../Constant/Color";
|
||||
|
||||
const SUCCESS_DELAY_MS = 5000; // 5 seconds
|
||||
const SUCCESS_DELAY_MS = 5000;
|
||||
const COUNTDOWN_SECONDS = SUCCESS_DELAY_MS / 1000;
|
||||
|
||||
/** Hoisted out of the screen so it keeps a stable component identity across
|
||||
* renders (defining it inline remounts the loader on every render). */
|
||||
const LoadingIndicator = () => (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={styles.loadingText}>Loading KYC form...</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
export const KycWebViewScreen = ({ route, navigation }: any) => {
|
||||
const webRef = useRef<WebView>(null);
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const params = route.params ?? {};
|
||||
// Guarded: a missing `res` used to crash the screen on mount (`data.res.data`).
|
||||
const html: string = params?.res?.data ?? "<h3>No content</h3>";
|
||||
|
||||
const hasCompletedRef = useRef(false);
|
||||
const countdownIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const data = route.params ?? {};
|
||||
const html = data.res.data;
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [loadError, setLoadError] = useState<boolean>(false);
|
||||
const [showSuccessMessage, setShowSuccessMessage] = useState<boolean>(false);
|
||||
const [countdown, setCountdown] = useState<number>(5);
|
||||
const url = false;
|
||||
const [countdown, setCountdown] = useState<number>(COUNTDOWN_SECONDS);
|
||||
|
||||
// Clear BOTH timers on unmount — the countdown interval previously leaked and
|
||||
// called setState on an unmounted component if the user left mid-countdown.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollTimerRef.current) {
|
||||
// Intentionally reads the ref at cleanup; the timer id stored on the ref is exactly what must be cleared.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
clearInterval(pollTimerRef.current);
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
}
|
||||
if (successTimeoutRef.current) {
|
||||
clearTimeout(successTimeoutRef.current);
|
||||
@@ -34,118 +44,79 @@ export const KycWebViewScreen = ({ route, navigation }: any) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleNavigationBack = () => {
|
||||
if (data.returnRoute) {
|
||||
navigation.replace(data.returnRoute, data.returnParams);
|
||||
const handleNavigationBack = useCallback(() => {
|
||||
if (params.returnRoute) {
|
||||
navigation.replace(params.returnRoute, params.returnParams);
|
||||
} else {
|
||||
navigation.dispatch(
|
||||
CommonActions.reset({
|
||||
index: 0,
|
||||
routes: [{ name: 'Login' }],
|
||||
})
|
||||
CommonActions.reset({ index: 0, routes: [{ name: "Login" }] }),
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [navigation, params.returnRoute, params.returnParams]);
|
||||
|
||||
const startSuccessCountdown = () => {
|
||||
const startSuccessCountdown = useCallback(() => {
|
||||
setShowSuccessMessage(true);
|
||||
setCountdown(5);
|
||||
setCountdown(COUNTDOWN_SECONDS);
|
||||
|
||||
let remainingSeconds = 5;
|
||||
const countdownInterval = setInterval(() => {
|
||||
remainingSeconds -= 1;
|
||||
setCountdown(remainingSeconds);
|
||||
|
||||
if (remainingSeconds <= 0) {
|
||||
clearInterval(countdownInterval);
|
||||
let remaining = COUNTDOWN_SECONDS;
|
||||
countdownIntervalRef.current = setInterval(() => {
|
||||
remaining -= 1;
|
||||
setCountdown(remaining);
|
||||
if (remaining <= 0 && countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
countdownIntervalRef.current = null;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
successTimeoutRef.current = setTimeout(() => {
|
||||
clearInterval(countdownInterval);
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
countdownIntervalRef.current = null;
|
||||
}
|
||||
handleNavigationBack();
|
||||
}, SUCCESS_DELAY_MS);
|
||||
};
|
||||
}, [handleNavigationBack]);
|
||||
|
||||
const handleLoadStart = (_event: any) => {
|
||||
const handleLoadStart = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
setLoadError(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLoadEnd = (_event: any) => {
|
||||
const handleLoadEnd = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleError = (_event: any) => {
|
||||
const handleError = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
setLoadError(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleNavigationStateChange = (navState: any) => {
|
||||
if (
|
||||
navState.title &&
|
||||
navState.title.includes('E-KYC SETU - Verification Success') &&
|
||||
!hasCompletedRef.current
|
||||
) {
|
||||
hasCompletedRef.current = true;
|
||||
startSuccessCountdown();
|
||||
}
|
||||
};
|
||||
|
||||
const LoadingIndicator = () => (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary || '#2563EB'} />
|
||||
<Text style={styles.loadingText}>Loading KYC form...</Text>
|
||||
</View>
|
||||
const handleNavigationStateChange = useCallback(
|
||||
(navState: WebViewNavigation) => {
|
||||
if (
|
||||
navState.title?.includes("E-KYC SETU - Verification Success") &&
|
||||
!hasCompletedRef.current
|
||||
) {
|
||||
hasCompletedRef.current = true;
|
||||
startSuccessCountdown();
|
||||
}
|
||||
},
|
||||
[startSuccessCountdown],
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.white }}>
|
||||
{url ? (
|
||||
<WebView
|
||||
ref={webRef}
|
||||
source={{
|
||||
uri: url,
|
||||
baseUrl: "http://192.168.1.15:8080/",
|
||||
}}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled
|
||||
startInLoadingState
|
||||
originWhitelist={["*"]}
|
||||
setSupportMultipleWindows={false}
|
||||
onLoadStart={handleLoadStart}
|
||||
onLoadEnd={handleLoadEnd}
|
||||
onError={handleError}
|
||||
onNavigationStateChange={handleNavigationStateChange}
|
||||
renderLoading={() => <LoadingIndicator />}
|
||||
/>
|
||||
) : (
|
||||
<WebView
|
||||
ref={webRef}
|
||||
source={{
|
||||
html: html ?? "<h3>No content</h3>",
|
||||
// baseUrl: `${BASE_URL}ESetuKYC/initiate/`,
|
||||
}}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled
|
||||
startInLoadingState
|
||||
onLoadStart={handleLoadStart}
|
||||
onLoadEnd={handleLoadEnd}
|
||||
onError={handleError}
|
||||
|
||||
onNavigationStateChange={(navState) => {
|
||||
console.log("Current -- URL:", navState.url);
|
||||
handleNavigationStateChange(navState);
|
||||
}}
|
||||
|
||||
onShouldStartLoadWithRequest={(request) => {
|
||||
console.log("Request URL:", request.url);
|
||||
return true;
|
||||
}}
|
||||
|
||||
renderLoading={() => <LoadingIndicator />}
|
||||
/>
|
||||
)}
|
||||
<SafeAreaView style={styles.root}>
|
||||
<WebView
|
||||
source={{ html }}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled
|
||||
startInLoadingState
|
||||
onLoadStart={handleLoadStart}
|
||||
onLoadEnd={handleLoadEnd}
|
||||
onError={handleError}
|
||||
onNavigationStateChange={handleNavigationStateChange}
|
||||
renderLoading={() => <LoadingIndicator />}
|
||||
/>
|
||||
|
||||
{isLoading && !showSuccessMessage && <LoadingIndicator />}
|
||||
|
||||
@@ -162,8 +133,8 @@ export const KycWebViewScreen = ({ route, navigation }: any) => {
|
||||
</Text>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={colors.primary || '#2563EB'}
|
||||
style={{ marginTop: 16 }}
|
||||
color={colors.primary}
|
||||
style={styles.successSpinner}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -182,6 +153,10 @@ export const KycWebViewScreen = ({ route, navigation }: any) => {
|
||||
export default KycWebViewScreen;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.white,
|
||||
},
|
||||
loadingContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
@@ -248,6 +223,9 @@ const styles = StyleSheet.create({
|
||||
color: '#2563EB',
|
||||
textAlign: 'center',
|
||||
},
|
||||
successSpinner: {
|
||||
marginTop: 16,
|
||||
},
|
||||
errorContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
|
||||
+74
-54
@@ -13,7 +13,7 @@ import { useTheme } from '../../Hooks/useTheme';
|
||||
import { setToken } from '../../Redux/Slices/Token';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import ImagePath from '../../Constant/ImagePath';
|
||||
import { showErrorAlert, showToast } from '../../Constant/ShowDailog';
|
||||
import { showErrorAlert, showSuccessAlert, showToast } from '../../Constant/ShowDailog';
|
||||
import AppBackground from '../../components/AppBackground';
|
||||
import FontsFamily from '../../Constant/FontsFamily';
|
||||
import { useLogin, useRagistrationState } from '../../Services/ApiCall/Hooks';
|
||||
@@ -35,6 +35,56 @@ const CAROUSEL_TEXTS = [
|
||||
{ title: "Secure & Reliable", subtitle: "Your data is protected with enterprise-grade security." },
|
||||
];
|
||||
|
||||
/**
|
||||
* Hoisted to module scope. Defining it inside Login gave it a new identity on
|
||||
* every render (i.e. every keystroke via Formik), which remounted the TextInput
|
||||
* and dismissed the keyboard after each character. It only uses props + the
|
||||
* module-level `styles`, so it is safe at module scope.
|
||||
*/
|
||||
const MinimalInput = ({ placeholder, value, onChangeText, onBlur, icon, secureTextEntry, rightIcon, onRightIconPress }: any) => {
|
||||
const focus = useSharedValue(0);
|
||||
const { colors: themeColors, isDarkMode } = useTheme();
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const borderColor = interpolateColor(focus.value, [0, 1], [isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)', themeColors.primary]);
|
||||
const borderWidth = interpolate(focus.value, [0, 1], [1, 1.5]);
|
||||
const backgroundColor = interpolateColor(focus.value, [0, 1], [isDarkMode ? 'rgba(30, 41, 59, 0.4)' : 'rgba(255, 255, 255, 0.6)', isDarkMode ? 'rgba(30, 41, 59, 0.8)' : 'rgba(255, 255, 255, 0.95)']);
|
||||
|
||||
return {
|
||||
borderColor,
|
||||
borderWidth,
|
||||
backgroundColor,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.modernInputWrapper, animatedStyle]}>
|
||||
<View style={styles.inputIcon}>{icon}</View>
|
||||
<TextInput
|
||||
style={[styles.textInput, { color: themeColors.text }]}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={themeColors.text + '80'} // 50% opacity
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onBlur={(e) => {
|
||||
focus.value = withTiming(0);
|
||||
onBlur(e);
|
||||
}}
|
||||
onFocus={() => {
|
||||
focus.value = withTiming(1);
|
||||
}}
|
||||
secureTextEntry={secureTextEntry}
|
||||
cursorColor={themeColors.primary}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<TouchableOpacity onPress={onRightIconPress} style={styles.inputRightIcon}>
|
||||
{rightIcon}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const Login = () => {
|
||||
const { colors, isDarkMode } = useTheme();
|
||||
const navigation = useNavigation<any>();
|
||||
@@ -99,9 +149,10 @@ const Login = () => {
|
||||
const handleSuccess = (res: any) => {
|
||||
dispatch(setToken({ ...res.data, token: res.token, userType: 'tender' }));
|
||||
if (res.statusCode === 200) {
|
||||
showSuccessAlert(res.message || 'Logged in successfully');
|
||||
handleGetData()
|
||||
} else {
|
||||
showErrorAlert(res.message);
|
||||
showErrorAlert(res.message || 'Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -121,51 +172,6 @@ const Login = () => {
|
||||
// the flicker/glitch the user sees.
|
||||
const isLoading = loginMutation.isPending || isResolvingRoute;
|
||||
|
||||
// Minimal Input Component (Enhanced for better aesthetics)
|
||||
const MinimalInput = ({ placeholder, value, onChangeText, onBlur, icon, secureTextEntry, rightIcon, onRightIconPress }: any) => {
|
||||
const focus = useSharedValue(0);
|
||||
const { colors: themeColors, isDarkMode } = useTheme();
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const borderColor = interpolateColor(focus.value, [0, 1], [isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)', themeColors.primary]);
|
||||
const borderWidth = interpolate(focus.value, [0, 1], [1, 1.5]);
|
||||
const backgroundColor = interpolateColor(focus.value, [0, 1], [isDarkMode ? 'rgba(30, 41, 59, 0.4)' : 'rgba(255, 255, 255, 0.6)', isDarkMode ? 'rgba(30, 41, 59, 0.8)' : 'rgba(255, 255, 255, 0.95)']);
|
||||
|
||||
return {
|
||||
borderColor,
|
||||
borderWidth,
|
||||
backgroundColor,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.modernInputWrapper, animatedStyle]}>
|
||||
<View style={styles.inputIcon}>{icon}</View>
|
||||
<TextInput
|
||||
style={[styles.textInput, { color: themeColors.text }]}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={themeColors.text + '80'} // 50% opacity
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onBlur={(e) => {
|
||||
focus.value = withTiming(0);
|
||||
onBlur(e);
|
||||
}}
|
||||
onFocus={() => {
|
||||
focus.value = withTiming(1);
|
||||
}}
|
||||
secureTextEntry={secureTextEntry}
|
||||
cursorColor={themeColors.primary}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<TouchableOpacity onPress={onRightIconPress} style={styles.inputRightIcon}>
|
||||
{rightIcon}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
@@ -237,13 +243,27 @@ const Login = () => {
|
||||
{/* FORM SECTION WITH GRADIENT GLASS CARD - AT BOTTOM */}
|
||||
{showForm && (
|
||||
<Animated.View style={[styles.glassCard, animatedFormStyle]}>
|
||||
{/* Glass Blur Layer */}
|
||||
<BlurView
|
||||
style={StyleSheet.absoluteFill}
|
||||
blurType={isDarkMode ? "dark" : "light"}
|
||||
blurAmount={20}
|
||||
reducedTransparencyFallbackColor={isDarkMode ? "rgba(15, 23, 42, 0.5)" : "rgba(255, 255, 255, 0.5)"}
|
||||
/>
|
||||
{/* Glass base layer.
|
||||
iOS gets the real native blur. On Android @react-native-community/blur
|
||||
is unreliable — it can blur/overlay the whole window instead of
|
||||
clipping to this card and leave a persistent frosted veil over the
|
||||
app after the screen unmounts. So Android uses a plain translucent
|
||||
fill (the gradients below still give the glass sheen). */}
|
||||
{Platform.OS === 'ios' ? (
|
||||
<BlurView
|
||||
style={StyleSheet.absoluteFill}
|
||||
blurType={isDarkMode ? "dark" : "light"}
|
||||
blurAmount={20}
|
||||
reducedTransparencyFallbackColor={isDarkMode ? "rgba(15, 23, 42, 0.5)" : "rgba(255, 255, 255, 0.5)"}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
{ backgroundColor: isDarkMode ? 'rgba(15, 23, 42, 0.78)' : 'rgba(255, 255, 255, 0.86)' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Gradient Background Layer */}
|
||||
<LinearGradient
|
||||
|
||||
@@ -4,7 +4,7 @@ import Animated, { FadeInDown, FadeOutUp } from 'react-native-reanimated';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useFetchLiveTender } from '../../Services/ApiCall/Hooks';
|
||||
import { SearchInput } from '../../components/SearchInput';
|
||||
import { Skeleton } from '../../components';
|
||||
import { Skeleton, EmptyState } from '../../components';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { togglePurchaseTender } from '../../Redux/Slices/SelectedPurchaseTender';
|
||||
import LiveTenderTile from './LiveTenderTile';
|
||||
@@ -133,10 +133,16 @@ const LiveTender = () => {
|
||||
keyExtractor={(item) => String(item.tenderId)}
|
||||
renderItem={renderItem}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 120, paddingTop: 10 }}
|
||||
contentContainerStyle={{ flexGrow: 1, paddingBottom: 120, paddingTop: 10 }}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.2}
|
||||
ListFooterComponent={renderFooter}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
title="No live tenders found"
|
||||
subtitle="New live tenders will appear here as they open."
|
||||
/>
|
||||
}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefetching}
|
||||
|
||||
@@ -229,10 +229,6 @@ const LiveTenderDetail = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const isLive =
|
||||
tenderData?.tenderStatus?.toLowerCase() === 'live' ||
|
||||
tenderData?.tenderStatus?.toLowerCase() === 'open';
|
||||
|
||||
const formatDateTime = (val?: string) => {
|
||||
if (!val) return '-';
|
||||
return moment(val, 'DD-MM-YYYY HH:mm:ss').isValid()
|
||||
@@ -255,15 +251,6 @@ const LiveTenderDetail = () => {
|
||||
Ref: {tenderData?.tenderNumber || '-'}
|
||||
</AppText>
|
||||
</View>
|
||||
<View style={styles.liveBox}>
|
||||
<View
|
||||
style={[
|
||||
styles.dot,
|
||||
{ backgroundColor: isLive ? colors.success : colors.textSecondary },
|
||||
]}
|
||||
/>
|
||||
<AppText variant="bodySmall" style={styles.liveText}>{isLive ? 'LIVE' : 'CLOSED'}</AppText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
SafeAreaView,
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import Modal from 'react-native-modal';
|
||||
@@ -29,46 +30,45 @@ interface Props {
|
||||
data?: any;
|
||||
}
|
||||
|
||||
// Hoisted to module scope: defining these inside the component gave them a new
|
||||
// identity every render, remounting the entire detail subtree each time.
|
||||
const formatDate = (date?: string) => {
|
||||
if (!date) return '-';
|
||||
return moment(date, 'DD-MM-YYYY HH:mm:ss').format('DD MMM YYYY, hh:mm A');
|
||||
};
|
||||
|
||||
const Section = ({ title, icon: Icon, children }: any) => (
|
||||
<View style={styles.sectionContainer}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Icon size={20} color={colors.primary} />
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
|
||||
const InfoBox = ({ label, value, full = false }: any) => (
|
||||
<View style={[styles.infoBox, full && { width: '100%' }]}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Text style={styles.value}>{value || '-'}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const TenderFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
|
||||
|
||||
const inset = useSafeAreaInsets();
|
||||
const { data: tenderResponse }: any = useFetchTenderById(data?.tenderId);
|
||||
const tender = tenderResponse?.data?.[0];
|
||||
|
||||
const formatDate = (date?: string) => {
|
||||
if (!date) return '-';
|
||||
return moment(date, 'DD-MM-YYYY HH:mm:ss').format('DD MMM YYYY, hh:mm A');
|
||||
};
|
||||
|
||||
if (!tender) return null;
|
||||
|
||||
const isLive = tender?.liveStatus === true;
|
||||
|
||||
const Section = ({ title, icon: Icon, children }: any) => (
|
||||
<View style={styles.sectionContainer}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Icon size={20} color={colors.primary} />
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
|
||||
const InfoBox = ({ label, value, full = false }: any) => (
|
||||
<View style={[styles.infoBox, full && { width: '100%' }]}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Text style={styles.value}>{value || '-'}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isVisible={visible}
|
||||
onBackButtonPress={onClose}
|
||||
onBackdropPress={onClose}
|
||||
style={styles.fullModal}
|
||||
animationIn="slideInRight"
|
||||
animationOut="slideOutRight"
|
||||
useNativeDriver
|
||||
useNativeDriverForBackdrop
|
||||
statusBarTranslucent
|
||||
>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
@@ -80,21 +80,12 @@ const TenderFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.headerTitle}>Tender Overview</Text>
|
||||
<Text style={styles.headerSub}>Ref: {tender?.tenderNumber}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.liveBox}>
|
||||
<View
|
||||
style={[
|
||||
styles.dot,
|
||||
{ backgroundColor: isLive ? '#22c55e' : '#94a3b8' },
|
||||
]}
|
||||
/>
|
||||
<Text style={styles.liveText}>{isLive ? 'LIVE' : 'CLOSED'}</Text>
|
||||
<Text style={styles.headerSub}>Ref: {tender?.tenderNumber ?? '—'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
{tender ? (
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
|
||||
{/* Basic Details */}
|
||||
<Section title="Basic Details" icon={Layers}>
|
||||
@@ -199,7 +190,12 @@ const TenderFullDetail: React.FC<Props> = ({ visible, onClose, data }) => {
|
||||
))}
|
||||
</Section>
|
||||
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={styles.loadingState}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
);
|
||||
@@ -214,6 +210,12 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
loadingState: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -7,7 +7,7 @@ import PurchaseTenderTile from './PurchaseTenderTile';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { setTender } from '../../Redux/Slices/SelectedTender';
|
||||
import { SearchInput } from '../../components/SearchInput';
|
||||
import { Skeleton } from '../../components';
|
||||
import { Skeleton, EmptyState } from '../../components';
|
||||
import { allBooleanProps } from './types/types';
|
||||
import FilterModal from './Modal/FilterModal';
|
||||
import colors from '../../Constant/Color';
|
||||
@@ -118,12 +118,14 @@ const PurchaseTenderAllList = () => {
|
||||
data={data?.result ?? []}
|
||||
keyExtractor={(item) => String(item.tenderId || item.id)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 200, paddingHorizontal: 10 }}
|
||||
contentContainerStyle={{ flexGrow: 1, paddingBottom: 200, paddingHorizontal: 10 }}
|
||||
renderItem={({ item }) => (
|
||||
<PurchaseTenderTile
|
||||
item={item}
|
||||
onPressDetails={() => {
|
||||
navigation.navigate('TenderDetail', { item, onAddToPurchase: () => { }, routeFrom: 'PurchasedTender' });
|
||||
// LiveTenderDetail reads a flat tender object from route.params
|
||||
// (route.params.tenderId / .tenderStatus), same as LiveTenderTile.
|
||||
navigation.navigate(NavigationString.LiveTenderDetail, item);
|
||||
}}
|
||||
onStartBidding={() => {
|
||||
Dispatch(setTender(item))
|
||||
@@ -136,6 +138,12 @@ const PurchaseTenderAllList = () => {
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.6}
|
||||
ListFooterComponent={renderFooter}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
title="No purchased tenders found"
|
||||
subtitle="Tenders you purchase will appear here."
|
||||
/>
|
||||
}
|
||||
refreshing={isRefetching}
|
||||
onRefresh={refetch}
|
||||
/>}
|
||||
|
||||
@@ -169,7 +169,6 @@ const TenderItem = ({ item, index, expanded, toggleExpand, colors, styles }: any
|
||||
const Dispatch = useDispatch();
|
||||
const isOpen = item.tenderStatus === 'Opened';
|
||||
const chevronStyle = useChevronStyle(expanded);
|
||||
console.log(item, "itemmmmmmm");
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
|
||||
@@ -56,7 +56,10 @@ const Tender = () => {
|
||||
const isAuctionUser = reduxToken?.userType?.toLowerCase() === 'auction';
|
||||
const cartCount = SelectedPurchaseList.length;
|
||||
|
||||
const styles = makeStyles(colors, spacing, shadows, TAB_WIDTH, TAB_HEIGHT);
|
||||
const styles = useMemo(
|
||||
() => makeStyles(colors, spacing, shadows, TAB_WIDTH, TAB_HEIGHT),
|
||||
[colors, spacing, shadows, TAB_WIDTH],
|
||||
);
|
||||
|
||||
if (isAuctionUser) {
|
||||
return (
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import Animated, { FadeInUp, FadeInDown } from 'react-native-reanimated';
|
||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||
import { useFocusEffect, useNavigation, useRoute } from '@react-navigation/native';
|
||||
import { pick, types } from '@react-native-documents/picker';
|
||||
import { InsideHeader } from '../../../components';
|
||||
import { useTheme } from '../../../Hooks/useTheme';
|
||||
@@ -43,11 +43,27 @@ const TenderBidding = () => {
|
||||
const navigation = useNavigation();
|
||||
const { colors, spacing, shadows } = useTheme();
|
||||
|
||||
const { data: tenderResponse }: any = useFetchTenderById(route.params?.tenderId);
|
||||
const { data: tenderResponse, refetch: refetchTenderById }: any = useFetchTenderById(route.params?.tenderId);
|
||||
const bidData = tenderResponse?.data;
|
||||
const { data: tender }: any = useProceedToBid(route.params?.tenderId);
|
||||
const { data: tender, refetch: refetchProceedToBid }: any = useProceedToBid(route.params?.tenderId);
|
||||
const tenderData = tender?.data;
|
||||
|
||||
// Refresh submission status whenever this screen regains focus — e.g. after
|
||||
// the Envelope-C editor submits and navigates back, the envelope should flip
|
||||
// to "Submitted" with no manual refresh. Skip the first focus (initial
|
||||
// mount) so we don't double-fetch what the queries just loaded.
|
||||
const isFirstFocus = useRef(true);
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (isFirstFocus.current) {
|
||||
isFirstFocus.current = false;
|
||||
return;
|
||||
}
|
||||
refetchProceedToBid();
|
||||
refetchTenderById();
|
||||
}, [refetchProceedToBid, refetchTenderById]),
|
||||
);
|
||||
|
||||
const [activeEnvelope, setActiveEnvelope] = useState<EnvelopeType>('A');
|
||||
const [envelopForm, setEnvelopForm] = useState<EnvelopeFormState>({
|
||||
A: {},
|
||||
@@ -174,8 +190,8 @@ const TenderBidding = () => {
|
||||
});
|
||||
if (res?.statusCode === 200) {
|
||||
logger.info('envelope', `[submit] ===== envelope ${envelopeType} submitted successfully =====`);
|
||||
// Success toast is emitted globally by the HTTP client (api/success).
|
||||
navigation.goBack();
|
||||
ToastAndroid.show(res.message, ToastAndroid.SHORT);
|
||||
} else {
|
||||
logger.warn('envelope', `[submit] envelope ${envelopeType} submission rejected by server`, {
|
||||
statusCode: res?.statusCode,
|
||||
@@ -228,8 +244,8 @@ const TenderBidding = () => {
|
||||
});
|
||||
if (res?.statusCode === 200) {
|
||||
logger.info('envelope', `[submit] ===== ${activeEnvelope} submitted successfully =====`);
|
||||
// Success toast is emitted globally by the HTTP client (api/success).
|
||||
navigation.goBack();
|
||||
ToastAndroid.show(res.message || `${activeEnvelope} submitted successfully`, ToastAndroid.SHORT);
|
||||
} else {
|
||||
logger.warn('envelope', `[submit] ${activeEnvelope} submission rejected by server`, {
|
||||
statusCode: res?.statusCode,
|
||||
@@ -272,6 +288,18 @@ const TenderBidding = () => {
|
||||
const activeEnvelopeList = tenderData?.[`tenderEnvelope${activeEnvelope}List`] || [];
|
||||
const qcbsQuestions = tenderData?.tenderEnvelopeQcbsList?.questions || [];
|
||||
|
||||
// Per-envelope submission status (LCS/QCBS share the QCBS envelope).
|
||||
const isEnvelopeSubmitted = (type: EnvelopeType): boolean => {
|
||||
switch (type) {
|
||||
case 'A': return !!tenderData?.envelopeASubmittStatus;
|
||||
case 'B': return !!tenderData?.envelopeBSubmittStatus;
|
||||
case 'C': return !!tenderData?.envelopeCSubmittStatus;
|
||||
case 'LCS':
|
||||
case 'QCBS': return !!tenderData?.envelopeQcbsSubmittStatus;
|
||||
default: return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.root, { backgroundColor: colors.background }]}>
|
||||
<InsideHeader title="Tender Bidding" showArrow />
|
||||
@@ -348,6 +376,7 @@ const TenderBidding = () => {
|
||||
key={tab.type}
|
||||
title={tab.title}
|
||||
isActive={activeEnvelope === tab.type}
|
||||
isSubmitted={isEnvelopeSubmitted(tab.type)}
|
||||
onPress={() => setActiveEnvelope(tab.type)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,33 +1,77 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import { useTheme } from '../../../../Hooks/useTheme';
|
||||
import AppText from '../../../../components/AppText';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
isActive: boolean;
|
||||
isSubmitted?: boolean;
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
const EnvelopeButton = ({ title, isActive, onPress }: Props) => {
|
||||
const CheckIcon = ({ color }: { color: string }) => (
|
||||
<Svg width={11} height={11} viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
d="M20 6L9 17l-5-5"
|
||||
stroke={color}
|
||||
strokeWidth={3.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Selectable envelope tile. Doubles as a tab (active = filled primary) and a
|
||||
* status indicator: a submitted envelope carries a green check badge + a
|
||||
* "Submitted" label and a green-tinted surface, so completed envelopes are
|
||||
* distinguishable from pending ones at a glance.
|
||||
*/
|
||||
const EnvelopeButton = ({ title, isActive, isSubmitted = false, onPress }: Props) => {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const backgroundColor = isActive
|
||||
? colors.primary
|
||||
: isSubmitted
|
||||
? `${colors.success}14`
|
||||
: colors.surface;
|
||||
const borderColor = isActive ? colors.primary : isSubmitted ? colors.success : colors.border;
|
||||
const titleColor = isActive ? colors.buttonText : colors.primary;
|
||||
const statusColor = isActive
|
||||
? colors.buttonText
|
||||
: isSubmitted
|
||||
? colors.success
|
||||
: colors.textSecondary;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.button,
|
||||
{ borderColor: colors.border, backgroundColor: colors.surface },
|
||||
isActive && { backgroundColor: colors.primary, borderColor: colors.primary },
|
||||
]}
|
||||
activeOpacity={0.7}
|
||||
style={[styles.button, { borderColor, backgroundColor }]}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<AppText
|
||||
variant="bodySmall"
|
||||
color={isActive ? colors.buttonText : colors.primary}
|
||||
style={styles.title as any}
|
||||
>
|
||||
<AppText variant="bodySmall" color={titleColor} style={styles.title as any}>
|
||||
{title}
|
||||
</AppText>
|
||||
|
||||
<View style={styles.statusRow}>
|
||||
{isSubmitted ? (
|
||||
<View
|
||||
style={[
|
||||
styles.checkCircle,
|
||||
{ backgroundColor: isActive ? 'rgba(255,255,255,0.25)' : colors.success },
|
||||
]}
|
||||
>
|
||||
<CheckIcon color={isActive ? colors.buttonText : '#FFFFFF'} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.dot, { backgroundColor: statusColor, opacity: isActive ? 0.85 : 0.5 }]} />
|
||||
)}
|
||||
<AppText variant="caption" color={statusColor} style={styles.statusText as any}>
|
||||
{isSubmitted ? 'Submitted' : 'Pending'}
|
||||
</AppText>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -36,12 +80,31 @@ const styles = StyleSheet.create({
|
||||
button: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 20,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1.5,
|
||||
minWidth: 100,
|
||||
alignItems: 'center',
|
||||
minWidth: 130,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
title: { fontWeight: '700' } as any,
|
||||
statusRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 5,
|
||||
gap: 5,
|
||||
},
|
||||
checkCircle: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
dot: {
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 4,
|
||||
},
|
||||
statusText: { fontWeight: '600' } as any,
|
||||
});
|
||||
|
||||
export default memo(EnvelopeButton);
|
||||
|
||||
Reference in New Issue
Block a user