419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
import { memo, useEffect, useMemo, useState } from '../../../../lib/teact/teact';
|
|
import { getActions, getGlobal, withGlobal } from '../../../../global';
|
|
|
|
import type {
|
|
ApiPeer,
|
|
ApiStarGiftAuctionState,
|
|
ApiStarsAmount,
|
|
} from '../../../../api/types';
|
|
import type { TabState } from '../../../../global/types';
|
|
|
|
import { selectPeer, selectTabState } from '../../../../global/selectors';
|
|
import { formatStarsAsIcon } from '../../../../util/localization/format';
|
|
import { getBidAuctionPosition } from '../../../common/helpers/gifts';
|
|
import renderText from '../../../common/helpers/renderText';
|
|
|
|
import { useTransitionActiveKey } from '../../../../hooks/animations/useTransitionActiveKey';
|
|
import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev';
|
|
import useFlag from '../../../../hooks/useFlag';
|
|
import useLang from '../../../../hooks/useLang';
|
|
import useLastCallback from '../../../../hooks/useLastCallback';
|
|
|
|
import AnimatedCounter from '../../../common/AnimatedCounter';
|
|
import AnimatedIconFromSticker from '../../../common/AnimatedIconFromSticker';
|
|
import Avatar from '../../../common/Avatar';
|
|
import FullNameTitle from '../../../common/FullNameTitle';
|
|
import StarIcon from '../../../common/icons/StarIcon';
|
|
import Button from '../../../ui/Button';
|
|
import ConfirmDialog from '../../../ui/ConfirmDialog';
|
|
import InputText from '../../../ui/InputText';
|
|
import Modal from '../../../ui/Modal';
|
|
import TextTimer from '../../../ui/TextTimer';
|
|
import Transition from '../../../ui/Transition';
|
|
import StarSlider from '../../paidReaction/StarSlider';
|
|
import BalanceBlock from '../../stars/BalanceBlock';
|
|
|
|
import styles from './GiftAuctionBidModal.module.scss';
|
|
|
|
export type OwnProps = {
|
|
modal: TabState['giftAuctionBidModal'];
|
|
};
|
|
|
|
type StateProps = {
|
|
auctionState?: ApiStarGiftAuctionState;
|
|
starBalance?: ApiStarsAmount;
|
|
currentUserPeer?: ApiPeer;
|
|
topBidderIds?: string[];
|
|
};
|
|
|
|
const DEFAULT_BID_AMOUNT = 50;
|
|
const MAX_BID_AMOUNT_STEP = 50000;
|
|
const MAX_CUSTOM_BID_AMOUNT = 1000000000;
|
|
const BID_ROUNDING_STEP = 10000;
|
|
const MIN_SLIDER_PROGRESS = 0.25;
|
|
const GIFT_STICKER_SIZE = 24;
|
|
const DEFAULT_TOP_BIDDERS_COUNT = 3;
|
|
|
|
const GiftAuctionBidModal = ({
|
|
modal,
|
|
auctionState,
|
|
starBalance,
|
|
currentUserPeer,
|
|
topBidderIds,
|
|
}: OwnProps & StateProps) => {
|
|
const { closeGiftAuctionBidModal, sendStarGiftAuctionBid, loadGiftAuction } = getActions();
|
|
|
|
const isOpen = Boolean(modal);
|
|
|
|
const renderingAuctionState = useCurrentOrPrev(auctionState);
|
|
const renderingTopBidderIds = useCurrentOrPrev(topBidderIds);
|
|
|
|
const renderingTopBidderPeers = useMemo(() => {
|
|
if (!renderingTopBidderIds) return undefined;
|
|
const global = getGlobal();
|
|
return renderingTopBidderIds
|
|
.map((id) => selectPeer(global, id))
|
|
.filter(Boolean);
|
|
}, [renderingTopBidderIds]);
|
|
|
|
const [topBidder1, topBidder2, topBidder3] = renderingTopBidderPeers || [];
|
|
|
|
const topBidder1Key = useTransitionActiveKey([topBidder1?.id || '0']);
|
|
const topBidder2Key = useTransitionActiveKey([topBidder2?.id || '0']);
|
|
const topBidder3Key = useTransitionActiveKey([topBidder3?.id || '0']);
|
|
|
|
const giftsPerRound = renderingAuctionState?.gift.giftsPerRound || 0;
|
|
|
|
const lang = useLang();
|
|
|
|
const activeState = renderingAuctionState?.state.type === 'active'
|
|
? renderingAuctionState.state
|
|
: undefined;
|
|
const userState = renderingAuctionState?.userState;
|
|
|
|
const [selectedBidAmount, setSelectedBidAmount] = useState(DEFAULT_BID_AMOUNT);
|
|
const [isCustomBidModalOpen, openCustomBidModal, closeCustomBidModal] = useFlag();
|
|
const [customBidValue, setCustomBidValue] = useState('');
|
|
|
|
const baseMinBid = activeState?.minBidAmount || DEFAULT_BID_AMOUNT;
|
|
|
|
const currentMinBid = userState?.minBidAmount || baseMinBid;
|
|
|
|
const sliderMaxValue = Math.ceil(currentMinBid / BID_ROUNDING_STEP) * BID_ROUNDING_STEP + MAX_BID_AMOUNT_STEP;
|
|
|
|
useEffect(() => {
|
|
setSelectedBidAmount(currentMinBid);
|
|
}, [currentMinBid]);
|
|
|
|
const nextRoundAt = activeState?.nextRoundAt;
|
|
|
|
const bidDifference = userState?.bidAmount ? selectedBidAmount - userState.bidAmount : 0;
|
|
const isAtMaxValue = selectedBidAmount >= sliderMaxValue;
|
|
|
|
const sliderSecondaryText = useMemo(() => {
|
|
if (isAtMaxValue) return lang('GiftAuctionTapToBidMore');
|
|
if (bidDifference <= 0) return undefined;
|
|
return (
|
|
<>
|
|
+
|
|
<AnimatedCounter text={lang.number(bidDifference)} />
|
|
</>
|
|
);
|
|
}, [bidDifference, isAtMaxValue, lang]);
|
|
|
|
const handleAmountChange = useLastCallback((value: number) => {
|
|
setSelectedBidAmount(value);
|
|
});
|
|
|
|
const handleTimerEnd = useLastCallback(() => {
|
|
if (!modal?.auctionGiftId || !isOpen) return;
|
|
loadGiftAuction({ giftId: modal.auctionGiftId });
|
|
});
|
|
|
|
const handleRequestCustomValue = useLastCallback(() => {
|
|
openCustomBidModal();
|
|
});
|
|
|
|
const handleBadgeClick = useLastCallback(() => {
|
|
if (isAtMaxValue) {
|
|
handleRequestCustomValue();
|
|
}
|
|
});
|
|
|
|
const handleCustomBidChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const value = e.target.value.replace(/\D/g, '');
|
|
const numValue = Number(value);
|
|
if (numValue > MAX_CUSTOM_BID_AMOUNT) return;
|
|
setCustomBidValue(value);
|
|
});
|
|
|
|
const handleCustomBidSubmit = useLastCallback(() => {
|
|
if (!renderingAuctionState?.gift.id || !modal) return;
|
|
|
|
const resultValue = Number(customBidValue);
|
|
if (resultValue < currentMinBid) return;
|
|
|
|
setSelectedBidAmount(resultValue);
|
|
closeCustomBidModal();
|
|
|
|
setCustomBidValue('');
|
|
|
|
const { peerId, message, shouldHideName } = modal;
|
|
const isUpdateBid = Boolean(userState?.bidAmount);
|
|
|
|
sendStarGiftAuctionBid({
|
|
giftId: renderingAuctionState.gift.id,
|
|
bidAmount: resultValue,
|
|
peerId,
|
|
message: message ? { text: message } : undefined,
|
|
shouldHideName,
|
|
isUpdateBid,
|
|
});
|
|
});
|
|
|
|
const handleSubmit = useLastCallback(() => {
|
|
if (!renderingAuctionState?.gift.id || !modal) return;
|
|
const { peerId, message, shouldHideName } = modal;
|
|
const isUpdateBid = Boolean(userState?.bidAmount);
|
|
|
|
sendStarGiftAuctionBid({
|
|
giftId: renderingAuctionState.gift.id,
|
|
bidAmount: selectedBidAmount,
|
|
peerId,
|
|
message: message ? { text: message } : undefined,
|
|
shouldHideName,
|
|
isUpdateBid,
|
|
});
|
|
});
|
|
|
|
const userPosition = useMemo(() => {
|
|
if (!selectedBidAmount || !activeState?.bidLevels?.length) return undefined;
|
|
|
|
const { bidLevels } = activeState;
|
|
const userBidDate = userState?.bidDate || Number.MAX_SAFE_INTEGER;
|
|
|
|
return getBidAuctionPosition(selectedBidAmount, userBidDate, bidLevels);
|
|
}, [selectedBidAmount, activeState, userState?.bidDate]);
|
|
|
|
function renderInfoCards() {
|
|
return (
|
|
<div className={styles.infoCards}>
|
|
<div className={styles.infoCard}>
|
|
<div className={styles.infoCardValue}>
|
|
<StarIcon type="gold" size="adaptive" />
|
|
{lang.number(currentMinBid)}
|
|
</div>
|
|
<div className={styles.infoCardLabel}>{lang('GiftAuctionMinimumBid')}</div>
|
|
</div>
|
|
<div className={styles.infoCard}>
|
|
<div className={styles.infoCardValue}>
|
|
<TextTimer endsAt={nextRoundAt || 0} shouldShowZeroOnEnd onEnd={handleTimerEnd} />
|
|
</div>
|
|
<div className={styles.infoCardLabel}>{lang('GiftAuctionUntilNextRound')}</div>
|
|
</div>
|
|
<div className={styles.infoCard}>
|
|
<div className={styles.infoCardValue}>
|
|
<AnimatedIconFromSticker
|
|
noLoop={false}
|
|
className={styles.giftSticker}
|
|
sticker={renderingAuctionState?.gift.sticker}
|
|
size={GIFT_STICKER_SIZE}
|
|
/>
|
|
{lang.number(activeState?.giftsLeft || 0)}
|
|
</div>
|
|
<div className={styles.infoCardLabel}>{lang('GiftAuctionLeft')}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isWinning = Boolean(userState?.bidAmount && userPosition && userPosition <= giftsPerRound);
|
|
|
|
function renderCurrentBidSectionTitle() {
|
|
const giftTitle = renderingAuctionState?.gift.title || lang('StarGift');
|
|
const nextGiftNum = userPosition && userPosition <= 100
|
|
? (activeState?.lastGiftNum || 0) + userPosition
|
|
: undefined;
|
|
|
|
return (
|
|
<Transition
|
|
name="fade"
|
|
activeKey={isWinning ? 0 : 1}
|
|
className={styles.sectionTitleTransition}
|
|
slideClassName={styles.bidderInfoSlide}
|
|
>
|
|
{isWinning ? (
|
|
<div className={styles.winningStatus}>
|
|
<span className={styles.winningText}>{lang('GiftAuctionYoureWinning')}</span>
|
|
<span className={styles.winningBadge}>
|
|
{lang('GiftUnique', { title: giftTitle, number: nextGiftNum ? lang.number(nextGiftNum) : undefined })}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className={styles.sectionTitle}>{lang('GiftAuctionYourBidWillBe')}</div>
|
|
)}
|
|
</Transition>
|
|
);
|
|
}
|
|
|
|
function renderUserBid() {
|
|
return (
|
|
<div className={styles.section}>
|
|
{renderCurrentBidSectionTitle()}
|
|
<div className={styles.bidderRow}>
|
|
<div className={styles.bidderPosition}>
|
|
{userPosition && userPosition > 100 ? `${userPosition}+` : (userPosition || 1)}
|
|
</div>
|
|
<div className={styles.bidderInfo}>
|
|
{currentUserPeer && <Avatar peer={currentUserPeer} size="small" />}
|
|
{currentUserPeer && <FullNameTitle peer={currentUserPeer} className={styles.bidderName} />}
|
|
</div>
|
|
<div className={styles.bidderAmount}>
|
|
<StarIcon type="gold" size="adaptive" />
|
|
{lang.number(selectedBidAmount)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderTopBidderRow(
|
|
index: number,
|
|
emoji: string,
|
|
peer: ApiPeer | undefined,
|
|
amount: number | undefined,
|
|
activeKey: number,
|
|
) {
|
|
return (
|
|
<div className={styles.topBidderRow} key={index}>
|
|
<div className={styles.topBidderPosition}>
|
|
{renderText(emoji, ['emoji'])}
|
|
</div>
|
|
<Transition
|
|
name="fade"
|
|
activeKey={activeKey}
|
|
className={styles.bidderInfo}
|
|
slideClassName={styles.bidderInfoSlide}
|
|
>
|
|
{peer && (
|
|
<>
|
|
<Avatar peer={peer} size="small" />
|
|
<FullNameTitle peer={peer} className={styles.bidderName} />
|
|
</>
|
|
)}
|
|
</Transition>
|
|
{amount !== undefined && (
|
|
<div className={styles.bidderAmount}>
|
|
<StarIcon type="gold" size="adaptive" />
|
|
{lang.number(amount)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderTopWinners() {
|
|
const topCount = DEFAULT_TOP_BIDDERS_COUNT;
|
|
const bidLevels = activeState?.bidLevels;
|
|
|
|
return (
|
|
<div className={styles.section}>
|
|
<div className={styles.sectionTitle}>
|
|
{lang('GiftAuctionTopWinners', { count: topCount }, { pluralValue: topCount })}
|
|
</div>
|
|
{renderTopBidderRow(0, '🥇', topBidder1, bidLevels?.[0]?.amount, topBidder1Key)}
|
|
{renderTopBidderRow(1, '🥈', topBidder2, bidLevels?.[1]?.amount, topBidder2Key)}
|
|
{renderTopBidderRow(2, '🥉', topBidder3, bidLevels?.[2]?.amount, topBidder3Key)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
isOpen={isOpen}
|
|
hasAbsoluteCloseButton
|
|
isSlim
|
|
className={styles.root}
|
|
contentClassName={styles.content}
|
|
onClose={closeGiftAuctionBidModal}
|
|
isLowStackPriority
|
|
>
|
|
<div className={styles.headerControlPanel}>
|
|
<BalanceBlock balance={starBalance} className={styles.modalBalance} withAddButton />
|
|
</div>
|
|
|
|
<StarSlider
|
|
className={styles.slider}
|
|
defaultValue={currentMinBid}
|
|
minValue={baseMinBid}
|
|
minAllowedValue={currentMinBid}
|
|
minAllowedProgress={MIN_SLIDER_PROGRESS}
|
|
maxValue={sliderMaxValue}
|
|
floatingBadgeDescription={sliderSecondaryText}
|
|
onChange={handleAmountChange}
|
|
onBadgeClick={handleBadgeClick}
|
|
onCustomValueClick={handleRequestCustomValue}
|
|
shouldUseDynamicColor
|
|
shouldAllowCustomValue
|
|
/>
|
|
|
|
<h3 className={styles.title}>{lang('GiftAuctionPlaceBid')}</h3>
|
|
{renderInfoCards()}
|
|
|
|
{renderUserBid()}
|
|
{renderTopWinners()}
|
|
|
|
<Button noForcedUpperCase onClick={handleSubmit}>
|
|
{lang(userState?.bidAmount ? 'GiftAuctionAddToBid' : 'GiftAuctionPlaceBidButton', {
|
|
amount: formatStarsAsIcon(lang,
|
|
userState?.bidAmount ? selectedBidAmount - userState.bidAmount : selectedBidAmount,
|
|
{ asFont: true, className: styles.buttonStar }),
|
|
}, { withNodes: true })}
|
|
</Button>
|
|
<ConfirmDialog
|
|
isOpen={isCustomBidModalOpen}
|
|
title={lang('GiftAuctionCustomBidTitle')}
|
|
confirmLabel={lang('GiftAuctionCustomBidButton')}
|
|
isConfirmDisabled={!customBidValue || Number(customBidValue) < currentMinBid}
|
|
confirmHandler={handleCustomBidSubmit}
|
|
onClose={closeCustomBidModal}
|
|
>
|
|
<p>{lang('GiftAuctionCustomBidDescription', { count: renderingAuctionState?.gift.giftsPerRound })}</p>
|
|
<div className={styles.customBidInput}>
|
|
<StarIcon type="gold" size="adaptive" className={styles.customBidInputIcon} />
|
|
<InputText
|
|
value={customBidValue}
|
|
onChange={handleCustomBidChange}
|
|
placeholder={lang('GiftAuctionCustomBidPlaceholder')}
|
|
inputMode="numeric"
|
|
teactExperimentControlled
|
|
/>
|
|
</div>
|
|
</ConfirmDialog>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
export default memo(withGlobal<OwnProps>(
|
|
(global): Complete<StateProps> => {
|
|
const { stars, currentUserId } = global;
|
|
const { giftAuctionBidModal } = selectTabState(global);
|
|
const auctionGiftId = giftAuctionBidModal?.auctionGiftId;
|
|
const giftAuction = auctionGiftId
|
|
? global.giftAuctionByGiftId?.[auctionGiftId] : undefined;
|
|
|
|
const currentUserPeer = currentUserId ? selectPeer(global, currentUserId) : undefined;
|
|
|
|
const topBidderIds = giftAuction?.state.type === 'active'
|
|
? giftAuction.state.topBidders
|
|
: undefined;
|
|
|
|
return {
|
|
auctionState: giftAuction,
|
|
starBalance: stars?.balance,
|
|
currentUserPeer,
|
|
topBidderIds,
|
|
};
|
|
},
|
|
)(GiftAuctionBidModal));
|