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:
Abin Toppo
2026-07-24 11:14:23 +05:30
co-authored by Claude Opus 4.8
parent 7853bab639
commit 95d8e0a925
51 changed files with 1659 additions and 679 deletions
+4 -2
View File
@@ -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],
);
+9 -15
View File
@@ -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,
+16 -15
View File
@@ -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>
+15 -6
View File
@@ -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>
);
+27 -2
View File
@@ -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'),
},
);
};
+26 -12
View File
@@ -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,
};
+2 -3
View File
@@ -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);
});
});
+80 -13
View File
@@ -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,
+22 -7
View File
@@ -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,
+80 -102
View File
@@ -1,32 +1,42 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { View, ActivityIndicator, StyleSheet, Text } from "react-native";
import { 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
View File
@@ -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
+8 -2
View File
@@ -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}
-13
View File
@@ -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}>
+43 -41
View File
@@ -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',
+11 -3
View File
@@ -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
+4 -1
View File
@@ -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);