Gifting: Premium Gifting (#4472)

Co-authored-by: zubiden <19638254+zubiden@users.noreply.github.com>
This commit is contained in:
Alexander Zinchuk 2024-04-19 13:39:04 +04:00
parent 2dc4e9d6cf
commit 34a3b62089
22 changed files with 555 additions and 116 deletions

View File

@ -63,6 +63,7 @@ export interface GramJsAppConfig extends LimitsConfig {
authorization_autoconfirm_period: number; authorization_autoconfirm_period: number;
giveaway_boosts_per_premium: number; giveaway_boosts_per_premium: number;
giveaway_countries_max: number; giveaway_countries_max: number;
boosts_per_sent_gift: number;
// Forums // Forums
topics_pinned_limit: number; topics_pinned_limit: number;
// Stories // Stories
@ -127,6 +128,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp
giveawayAddPeersMax: appConfig.giveaway_add_peers_max, giveawayAddPeersMax: appConfig.giveaway_add_peers_max,
giveawayBoostsPerPremium: appConfig.giveaway_boosts_per_premium, giveawayBoostsPerPremium: appConfig.giveaway_boosts_per_premium,
giveawayCountriesMax: appConfig.giveaway_countries_max, giveawayCountriesMax: appConfig.giveaway_countries_max,
boostsPerSentGift: appConfig.boosts_per_sent_gift,
canDisplayAutoarchiveSetting: appConfig.autoarchive_setting_available, canDisplayAutoarchiveSetting: appConfig.autoarchive_setting_available,
limits: { limits: {
uploadMaxFileparts: getLimit(appConfig, 'upload_max_fileparts', 'uploadMaxFileparts'), uploadMaxFileparts: getLimit(appConfig, 'upload_max_fileparts', 'uploadMaxFileparts'),

View File

@ -189,6 +189,7 @@ export interface ApiAppConfig {
giveawayAddPeersMax: number; giveawayAddPeersMax: number;
giveawayBoostsPerPremium: number; giveawayBoostsPerPremium: number;
giveawayCountriesMax: number; giveawayCountriesMax: number;
boostsPerSentGift: number;
premiumPromoOrder: ApiPremiumSection[]; premiumPromoOrder: ApiPremiumSection[];
defaultEmojiStatusesStickerSetId: string; defaultEmojiStatusesStickerSetId: string;
maxUniqueReactions: number; maxUniqueReactions: number;

View File

@ -18,6 +18,7 @@ export { default as DeleteFolderDialog } from '../components/main/DeleteFolderDi
export { default as PremiumMainModal } from '../components/main/premium/PremiumMainModal'; export { default as PremiumMainModal } from '../components/main/premium/PremiumMainModal';
export { default as GiftPremiumModal } from '../components/main/premium/GiftPremiumModal'; export { default as GiftPremiumModal } from '../components/main/premium/GiftPremiumModal';
export { default as GiveawayModal } from '../components/main/premium/GiveawayModal'; export { default as GiveawayModal } from '../components/main/premium/GiveawayModal';
export { default as PremiumGiftingModal } from '../components/main/premium/PremiumGiftingModal';
export { default as AppendEntityPickerModal } from '../components/main/AppendEntityPickerModal'; export { default as AppendEntityPickerModal } from '../components/main/AppendEntityPickerModal';
export { default as PremiumLimitReachedModal } from '../components/main/premium/common/PremiumLimitReachedModal'; export { default as PremiumLimitReachedModal } from '../components/main/premium/common/PremiumLimitReachedModal';
export { default as StatusPickerMenu } from '../components/left/main/StatusPickerMenu'; export { default as StatusPickerMenu } from '../components/left/main/StatusPickerMenu';

View File

@ -145,7 +145,7 @@ const UserBirthday = ({
const canGiftPremium = isToday && !user.isPremium && !user.isSelf && !isPremiumPurchaseBlocked; const canGiftPremium = isToday && !user.isPremium && !user.isSelf && !isPremiumPurchaseBlocked;
const handleOpenGiftModal = useLastCallback(() => { const handleOpenGiftModal = useLastCallback(() => {
openGiftPremiumModal({ forUserId: user.id }); openGiftPremiumModal({ forUserIds: [user.id] });
}); });
const handleClick = useLastCallback(() => { const handleClick = useLastCallback(() => {

View File

@ -104,7 +104,7 @@
} }
.settings-main-menu-premium .PremiumIcon { .settings-main-menu-premium .PremiumIcon {
margin-right: 2rem; margin-right: 1.25rem;
} }
.settings-main-menu { .settings-main-menu {

View File

@ -5,7 +5,10 @@ import { getActions, withGlobal } from '../../../global';
import { SettingsScreens } from '../../../types'; import { SettingsScreens } from '../../../types';
import { FAQ_URL, PRIVACY_URL } from '../../../config'; import { FAQ_URL, PRIVACY_URL } from '../../../config';
import { selectIsPremiumPurchaseBlocked } from '../../../global/selectors'; import {
selectIsGiveawayGiftsPurchaseAvailable,
selectIsPremiumPurchaseBlocked,
} from '../../../global/selectors';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import useHistoryBack from '../../../hooks/useHistoryBack'; import useHistoryBack from '../../../hooks/useHistoryBack';
@ -28,6 +31,7 @@ type StateProps = {
sessionCount: number; sessionCount: number;
currentUserId?: string; currentUserId?: string;
canBuyPremium?: boolean; canBuyPremium?: boolean;
isGiveawayAvailable?: boolean;
}; };
const SettingsMain: FC<OwnProps & StateProps> = ({ const SettingsMain: FC<OwnProps & StateProps> = ({
@ -37,12 +41,14 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
currentUserId, currentUserId,
sessionCount, sessionCount,
canBuyPremium, canBuyPremium,
isGiveawayAvailable,
}) => { }) => {
const { const {
loadProfilePhotos, loadProfilePhotos,
openPremiumModal, openPremiumModal,
openSupportChat, openSupportChat,
openUrl, openUrl,
openPremiumGiftingModal,
} = getActions(); } = getActions();
const [isSupportDialogOpen, openSupportDialog, closeSupportDialog] = useFlag(false); const [isSupportDialogOpen, openSupportDialog, closeSupportDialog] = useFlag(false);
@ -158,6 +164,16 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
{lang('TelegramPremium')} {lang('TelegramPremium')}
</ListItem> </ListItem>
)} )}
{isGiveawayAvailable && (
<ListItem
icon="gift"
className="settings-main-menu-premium"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => openPremiumGiftingModal()}
>
{lang('GiftPremiumGifting')}
</ListItem>
)}
</div> </div>
<div className="settings-main-menu"> <div className="settings-main-menu">
<ListItem <ListItem
@ -196,11 +212,13 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global): StateProps => { (global): StateProps => {
const { currentUserId } = global; const { currentUserId } = global;
const isGiveawayAvailable = selectIsGiveawayGiftsPurchaseAvailable(global);
return { return {
sessionCount: global.activeSessions.orderedHashes.length, sessionCount: global.activeSessions.orderedHashes.length,
currentUserId, currentUserId,
canBuyPremium: !selectIsPremiumPurchaseBlocked(global), canBuyPremium: !selectIsPremiumPurchaseBlocked(global),
isGiveawayAvailable,
}; };
}, },
)(SettingsMain)); )(SettingsMain));

View File

@ -1,6 +1,8 @@
import type { FC } from '../../lib/teact/teact'; import type { FC } from '../../lib/teact/teact';
import React, { import React, {
memo, useMemo, useState, memo,
useMemo,
useState,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../global'; import { getActions, getGlobal, withGlobal } from '../../global';
@ -66,8 +68,7 @@ const AppendEntityPickerModal: FC<OwnProps & StateProps> = ({
const lang = useLang(); const lang = useLang();
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag(); const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag();
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([]);
const [pendingChannelId, setPendingChannelId] = useState<string | undefined>(undefined); const [pendingChannelId, setPendingChannelId] = useState<string | undefined>(undefined);
const [searchQuery, setSearchQuery] = useState<string>(''); const [searchQuery, setSearchQuery] = useState<string>('');
@ -127,8 +128,8 @@ const AppendEntityPickerModal: FC<OwnProps & StateProps> = ({
return isChannel || isSuperGroup; return isChannel || isSuperGroup;
}), }),
false, false,
selectedChannelIds); selectedIds);
}, [channelsIds, lang, searchQuery, selectedChannelIds, isSuperGroup, isChannel]); }, [channelsIds, lang, searchQuery, selectedIds, isSuperGroup, isChannel]);
const handleCloseButtonClick = useLastCallback(() => { const handleCloseButtonClick = useLastCallback(() => {
onSubmit([]); onSubmit([]);
@ -136,36 +137,36 @@ const AppendEntityPickerModal: FC<OwnProps & StateProps> = ({
}); });
const handleSendIdList = useLastCallback(() => { const handleSendIdList = useLastCallback(() => {
onSubmit(entityType === 'members' ? selectedMemberIds : selectedChannelIds); onSubmit(selectedIds);
onClose(); onClose();
}); });
const confirmPrivateLinkChannelSelection = useLastCallback(() => { const confirmPrivateLinkChannelSelection = useLastCallback(() => {
if (pendingChannelId) { if (pendingChannelId) {
setSelectedChannelIds((prevIds) => unique([...prevIds, pendingChannelId])); setSelectedIds((prevIds) => unique([...prevIds, pendingChannelId]));
} }
closeConfirmModal(); closeConfirmModal();
}); });
const handleSelectedMembersChange = useLastCallback((newSelectedIds: string[]) => { const handleSelectedMemberIdsChange = useLastCallback((newSelectedIds: string[]) => {
if (newSelectedIds.length > selectionLimit) { if (newSelectedIds.length > selectionLimit) {
showNotification({ showNotification({
message: lang('BoostingSelectUpToWarningUsers', selectionLimit), message: lang('BoostingSelectUpToWarningUsers', selectionLimit),
}); });
return; return;
} }
setSelectedMemberIds(newSelectedIds); setSelectedIds(newSelectedIds);
}); });
const handleSelectedChannelIdsChange = useLastCallback((newSelectedIds: string[]) => { const handleSelectedChannelIdsChange = useLastCallback((newSelectedIds: string[]) => {
const chatsById = getGlobal().chats.byId; const chatsById = getGlobal().chats.byId;
const newlyAddedIds = newSelectedIds.filter((id) => !selectedChannelIds.includes(id)); const newlyAddedIds = newSelectedIds.filter((id) => !selectedIds.includes(id));
const privateLinkChannelId = newlyAddedIds.find((id) => { const privateLinkChannelId = newlyAddedIds.find((id) => {
const chat = chatsById[id]; const chat = chatsById[id];
return chat && !isChatPublic(chat); return chat && !isChatPublic(chat);
}); });
if (selectedChannelIds?.length >= selectionLimit) { if (selectedIds?.length >= selectionLimit) {
showNotification({ showNotification({
message: lang('BoostingSelectUpToWarningChannelsPlural', selectionLimit), message: lang('BoostingSelectUpToWarningChannelsPlural', selectionLimit),
}); });
@ -176,7 +177,7 @@ const AppendEntityPickerModal: FC<OwnProps & StateProps> = ({
setPendingChannelId(privateLinkChannelId); setPendingChannelId(privateLinkChannelId);
openConfirmModal(); openConfirmModal();
} else { } else {
setSelectedChannelIds(newSelectedIds); setSelectedIds(newSelectedIds);
} }
}); });
@ -218,12 +219,12 @@ const AppendEntityPickerModal: FC<OwnProps & StateProps> = ({
<Picker <Picker
className={styles.picker} className={styles.picker}
itemIds={entityType === 'members' ? displayedMembersIds : displayedChannelIds} itemIds={entityType === 'members' ? displayedMembersIds : displayedChannelIds}
selectedIds={entityType === 'members' ? selectedMemberIds : selectedChannelIds} selectedIds={selectedIds}
filterValue={searchQuery} filterValue={searchQuery}
filterPlaceholder={lang('Search')} filterPlaceholder={lang('Search')}
searchInputId="new-members-picker-search" searchInputId={`${entityType}-picker-search`}
onSelectedIdsChange={entityType === 'channels' onSelectedIdsChange={entityType === 'channels'
? handleSelectedChannelIdsChange : handleSelectedMembersChange} ? handleSelectedChannelIdsChange : handleSelectedMemberIdsChange}
onFilterChange={setSearchQuery} onFilterChange={setSearchQuery}
isSearchable isSearchable
/> />
@ -261,7 +262,7 @@ export default memo(withGlobal<OwnProps>((global, { chatId, entityType }): State
members = chatFullInfo.members; members = chatFullInfo.members;
adminMembersById = chatFullInfo.adminMembersById; adminMembersById = chatFullInfo.adminMembersById;
} }
} else if (entityType === 'channels') { } if (entityType === 'channels') {
const chat = chatId ? selectChat(global, chatId) : undefined; const chat = chatId ? selectChat(global, chatId) : undefined;
if (chat) { if (chat) {
isChannel = isChatChannel(chat); isChannel = isChatChannel(chat);

View File

@ -102,6 +102,7 @@ import NewContactModal from './NewContactModal.async';
import Notifications from './Notifications.async'; import Notifications from './Notifications.async';
import PremiumLimitReachedModal from './premium/common/PremiumLimitReachedModal.async'; import PremiumLimitReachedModal from './premium/common/PremiumLimitReachedModal.async';
import GiveawayModal from './premium/GiveawayModal.async'; import GiveawayModal from './premium/GiveawayModal.async';
import PremiumGiftingModal from './premium/PremiumGiftingModal.async';
import PremiumMainModal from './premium/PremiumMainModal.async'; import PremiumMainModal from './premium/PremiumMainModal.async';
import SafeLinkModal from './SafeLinkModal.async'; import SafeLinkModal from './SafeLinkModal.async';
@ -159,6 +160,7 @@ type StateProps = {
isReactionPickerOpen: boolean; isReactionPickerOpen: boolean;
isAppendModalOpen?: boolean; isAppendModalOpen?: boolean;
isGiveawayModalOpen?: boolean; isGiveawayModalOpen?: boolean;
isPremiumGiftingModalOpen?: boolean;
isCurrentUserPremium?: boolean; isCurrentUserPremium?: boolean;
chatlistModal?: TabState['chatlistModal']; chatlistModal?: TabState['chatlistModal'];
boostModal?: TabState['boostModal']; boostModal?: TabState['boostModal'];
@ -218,6 +220,7 @@ const Main: FC<OwnProps & StateProps> = ({
urlAuth, urlAuth,
isPremiumModalOpen, isPremiumModalOpen,
isGiveawayModalOpen, isGiveawayModalOpen,
isPremiumGiftingModalOpen,
isPaymentModalOpen, isPaymentModalOpen,
isReceiptModalOpen, isReceiptModalOpen,
isReactionPickerOpen, isReactionPickerOpen,
@ -603,6 +606,7 @@ const Main: FC<OwnProps & StateProps> = ({
<MessageListHistoryHandler /> <MessageListHistoryHandler />
{isPremiumModalOpen && <PremiumMainModal isOpen={isPremiumModalOpen} />} {isPremiumModalOpen && <PremiumMainModal isOpen={isPremiumModalOpen} />}
{isGiveawayModalOpen && <GiveawayModal isOpen={isGiveawayModalOpen} />} {isGiveawayModalOpen && <GiveawayModal isOpen={isGiveawayModalOpen} />}
{isPremiumGiftingModalOpen && <PremiumGiftingModal isOpen={isPremiumGiftingModalOpen} />}
<PremiumLimitReachedModal limit={limitReached} /> <PremiumLimitReachedModal limit={limitReached} />
<PaymentModal isOpen={isPaymentModalOpen} onClose={closePaymentModal} /> <PaymentModal isOpen={isPaymentModalOpen} onClose={closePaymentModal} />
<ReceiptModal isOpen={isReceiptModalOpen} onClose={clearReceipt} /> <ReceiptModal isOpen={isReceiptModalOpen} onClose={clearReceipt} />
@ -646,6 +650,7 @@ export default memo(withGlobal<OwnProps>(
ratingPhoneCall, ratingPhoneCall,
premiumModal, premiumModal,
giveawayModal, giveawayModal,
giftingModal,
isMasterTab, isMasterTab,
payment, payment,
limitReachedModal, limitReachedModal,
@ -712,6 +717,7 @@ export default memo(withGlobal<OwnProps>(
isCurrentUserPremium: selectIsCurrentUserPremium(global), isCurrentUserPremium: selectIsCurrentUserPremium(global),
isPremiumModalOpen: premiumModal?.isOpen, isPremiumModalOpen: premiumModal?.isOpen,
isGiveawayModalOpen: giveawayModal?.isOpen, isGiveawayModalOpen: giveawayModal?.isOpen,
isPremiumGiftingModalOpen: giftingModal?.isOpen,
limitReached: limitReachedModal?.limit, limitReached: limitReachedModal?.limit,
isPaymentModalOpen: payment.isPaymentModalOpen, isPaymentModalOpen: payment.isPaymentModalOpen,
isReceiptModalOpen: Boolean(payment.receipt), isReceiptModalOpen: Boolean(payment.receipt),

View File

@ -10,13 +10,16 @@
left: 0.5rem; left: 0.5rem;
} }
.avatar { .avatars {
margin: 0 auto 1.5rem; display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 1rem;
margin: 1rem;
} }
.headerText { .center {
font-size: 1.5rem;
font-weight: 500;
text-align: center; text-align: center;
} }
@ -36,9 +39,9 @@
margin-bottom: 2.5rem; margin-bottom: 2.5rem;
} }
.button { .boostIcon {
height: 3rem; color: var(--color-primary);
background: linear-gradient(88.39deg, #6C93FF -2.56%, #976FFF 51.27%, #DF69D1 107.39%); vertical-align: middle;
font-size: 1rem; line-height: 1.5;
font-weight: 600;
} }

View File

@ -1,23 +1,28 @@
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import React, { import React, {
memo, useCallback, useEffect, useMemo, useState, memo, useEffect, useMemo, useRef,
useState,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, getGlobal, withGlobal } from '../../../global';
import type { ApiPremiumGiftOption, ApiUser } from '../../../api/types'; import type {
ApiPremiumGiftCodeOption,
} from '../../../api/types';
import { getUserFirstOrLastName } from '../../../global/helpers'; import { BOOST_PER_SENT_GIFT } from '../../../config';
import { getUserFullName } from '../../../global/helpers';
import { import {
selectTabState, selectTabState,
selectUser,
selectUserFullInfo,
} from '../../../global/selectors'; } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { formatCurrency } from '../../../util/formatCurrency'; import { formatCurrency } from '../../../util/formatCurrency';
import renderText from '../../common/helpers/renderText'; import renderText from '../../common/helpers/renderText';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import Avatar from '../../common/Avatar'; import AvatarList from '../../common/AvatarList';
import Icon from '../../common/Icon';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import Link from '../../ui/Link'; import Link from '../../ui/Link';
import Modal from '../../ui/Modal'; import Modal from '../../ui/Modal';
@ -30,55 +35,124 @@ export type OwnProps = {
}; };
type StateProps = { type StateProps = {
user?: ApiUser; isCompleted?: boolean;
gifts?: ApiPremiumGiftOption[]; gifts?: ApiPremiumGiftCodeOption[] | undefined;
monthlyCurrency?: string; forUserIds?: string[];
monthlyAmount?: number; boostPerSentGift?: number;
}; };
const GiftPremiumModal: FC<OwnProps & StateProps> = ({ const GiftPremiumModal: FC<OwnProps & StateProps> = ({
isOpen, isOpen,
user, isCompleted,
gifts, gifts,
boostPerSentGift = BOOST_PER_SENT_GIFT,
forUserIds,
}) => { }) => {
const { openPremiumModal, closeGiftPremiumModal, openUrl } = getActions(); // eslint-disable-next-line no-null/no-null
const dialogRef = useRef<HTMLDivElement>(null);
const {
openPremiumModal, closeGiftPremiumModal, openInvoice, requestConfetti,
} = getActions();
const lang = useLang(); const lang = useLang();
const [selectedOption, setSelectedOption] = useState<number | undefined>(); const [selectedMonthOption, setSelectedMonthOption] = useState<number | undefined>();
const fullMonthlyAmount = useMemo(() => {
if (!gifts?.length) { const selectedUserQuantity = forUserIds && forUserIds.length * boostPerSentGift;
useEffect(() => {
if (forUserIds?.length) {
setSelectedMonthOption(gifts?.[0].months);
}
}, [gifts, forUserIds]);
const giftingUserList = useMemo(() => {
const usersById = getGlobal().users.byId;
return forUserIds?.map((userId) => usersById[userId]).filter(Boolean);
}, [forUserIds]);
const selectedGift = useMemo(() => {
return gifts?.find((gift) => gift.months === selectedMonthOption && gift.users === forUserIds?.length);
}, [gifts, selectedMonthOption, forUserIds?.length]);
const filteredGifts = useMemo(() => {
return gifts?.filter((gift) => gift.users
=== forUserIds?.length);
}, [gifts, forUserIds?.length]);
const fullMonthlyGiftAmount = useMemo(() => {
if (!filteredGifts?.length) {
return undefined; return undefined;
} }
const basicGift = gifts.reduce((acc, gift) => { const basicGift = filteredGifts.reduce((acc, gift) => {
return gift.months < acc.months ? gift : acc; return gift.amount < acc.amount ? gift : acc;
}); });
return Math.floor(basicGift.amount / basicGift.months); return Math.floor(basicGift.amount / basicGift.months);
}, [gifts]); }, [filteredGifts]);
useEffect(() => { const handleSubmit = useLastCallback(() => {
if (isOpen && gifts?.length) {
setSelectedOption(gifts[0].months);
}
}, [gifts, isOpen]);
const selectedGift = useMemo(() => {
return gifts?.find((gift) => gift.months === selectedOption);
}, [gifts, selectedOption]);
const handleSubmit = useCallback(() => {
if (!selectedGift) { if (!selectedGift) {
return; return;
} }
closeGiftPremiumModal(); openInvoice({
openUrl({ url: selectedGift.botUrl }); type: 'giftcode',
}, [closeGiftPremiumModal, openUrl, selectedGift]); userIds: forUserIds!,
currency: selectedGift!.currency,
amount: selectedGift!.amount,
option: selectedGift!,
});
});
const handlePremiumClick = useCallback(() => { const handlePremiumClick = useLastCallback(() => {
openPremiumModal(); openPremiumModal();
}, [openPremiumModal]); });
const showConfetti = useLastCallback(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
const {
top, left, width, height,
} = dialog.querySelector('.modal-content')!.getBoundingClientRect();
requestConfetti({
top,
left,
width,
height,
});
}
});
useEffect(() => {
if (isCompleted) {
showConfetti();
}
}, [isCompleted, showConfetti]);
const userNameList = useMemo(() => {
const usersById = getGlobal().users.byId;
return forUserIds?.map((userId) => getUserFullName(usersById[userId])).join(', ');
}, [forUserIds]);
function renderGiftTitle() {
if (isCompleted) {
return renderText(lang('TelegramPremiumUserGiftedPremiumOutboundDialogTitle',
[userNameList, selectedGift?.months]), ['simple_markdown']);
}
return lang('GiftTelegramPremiumTitle');
}
function renderGiftText() {
if (isCompleted) {
return renderText(lang('TelegramPremiumUserGiftedPremiumOutboundDialogSubtitle', userNameList),
['simple_markdown']);
}
return renderText(lang('GiftPremiumUsersGiveAccessManyZero', userNameList), ['simple_markdown']);
}
function renderPremiumFeaturesLink() { function renderPremiumFeaturesLink() {
const info = lang('GiftPremiumListFeaturesAndTerms'); const info = lang('GiftPremiumListFeaturesAndTerms');
@ -90,7 +164,7 @@ const GiftPremiumModal: FC<OwnProps & StateProps> = ({
} }
return ( return (
<p className={styles.premiumFeatures}> <p className={buildClassName(styles.premiumFeatures, styles.center)}>
{parts[1]} {parts[1]}
<Link isPrimary onClick={handlePremiumClick}>{parts[2]}</Link> <Link isPrimary onClick={handlePremiumClick}>{parts[2]}</Link>
{parts[3]} {parts[3]}
@ -98,8 +172,43 @@ const GiftPremiumModal: FC<OwnProps & StateProps> = ({
); );
} }
function renderBoostsPluralText() {
const giftParts = renderText(lang('GiftPremiumWillReceiveBoostsPlural', selectedUserQuantity), ['simple_markdown']);
return giftParts.map((part) => {
if (typeof part === 'string') {
return part.split(/(⚡)/g).map((subpart) => {
if (subpart === '⚡') {
return <Icon name="boost" className={styles.boostIcon} />;
}
return subpart;
});
}
return part;
});
}
function renderSubscriptionGiftOptions() {
return (
<div className={styles.subscriptionOptions}>
{filteredGifts?.map((gift) => {
return (
<PremiumSubscriptionOption
className={styles.subscriptionOption}
key={gift.months}
option={gift}
fullMonthlyAmount={fullMonthlyGiftAmount}
checked={gift.months === selectedMonthOption}
onChange={setSelectedMonthOption}
/>
);
})}
</div>
);
}
return ( return (
<Modal <Modal
dialogRef={dialogRef}
onClose={closeGiftPremiumModal} onClose={closeGiftPremiumModal}
isOpen={isOpen} isOpen={isOpen}
className={styles.modalDialog} className={styles.modalDialog}
@ -116,53 +225,53 @@ const GiftPremiumModal: FC<OwnProps & StateProps> = ({
> >
<i className="icon icon-close" /> <i className="icon icon-close" />
</Button> </Button>
<Avatar <div className={styles.avatars}>
peer={user} <AvatarList
size="jumbo" size="large"
className={styles.avatar} peers={giftingUserList}
/> />
<h2 className={styles.headerText}>
{lang('GiftTelegramPremiumTitle')}
</h2>
<p className={styles.description}>
{renderText(
lang('GiftTelegramPremiumDescription', getUserFirstOrLastName(user)),
['emoji', 'simple_markdown'],
)}
</p>
<div className={styles.options}>
{gifts?.map((gift) => (
<PremiumSubscriptionOption
key={gift.amount}
option={gift}
fullMonthlyAmount={fullMonthlyAmount}
checked={gift.months === selectedOption}
onChange={setSelectedOption}
/>
))}
</div> </div>
<h2 className={buildClassName(styles.headerText, styles.center)}>
{renderGiftTitle()}
</h2>
<p className={buildClassName(styles.description, styles.center)}>
{renderGiftText()}
</p>
{!isCompleted && (
<>
<p className={styles.description}>
{renderText(renderBoostsPluralText(), ['simple_markdown', 'emoji'])}
</p>
<div className={styles.options}>
{renderSubscriptionGiftOptions()}
</div>
</>
)}
{renderPremiumFeaturesLink()} {renderPremiumFeaturesLink()}
</div> </div>
<Button className={styles.button} isShiny disabled={!selectedOption} onClick={handleSubmit}> {!isCompleted && (
{lang( <Button withPremiumGradient className={styles.button} isShiny disabled={!selectedGift} onClick={handleSubmit}>
'GiftSubscriptionFor', {lang(
selectedGift && formatCurrency(Number(selectedGift.amount), selectedGift.currency, lang.code), 'GiftSubscriptionFor', selectedGift
)} && formatCurrency(selectedGift!.amount, selectedGift.currency, lang.code),
</Button> )}
</Button>
)}
</Modal> </Modal>
); );
}; };
export default memo(withGlobal<OwnProps>((global): StateProps => { export default memo(withGlobal<OwnProps>((global): StateProps => {
const { forUserId } = selectTabState(global).giftPremiumModal || {}; const {
const user = forUserId ? selectUser(global, forUserId) : undefined; gifts, forUserIds, isCompleted,
const gifts = user ? selectUserFullInfo(global, user.id)?.premiumGifts : undefined; } = selectTabState(global).giftPremiumModal || {};
return { return {
user, isCompleted,
gifts, gifts,
boostPerSentGift: global.appConfig?.boostsPerSentGift,
forUserIds,
}; };
})(GiftPremiumModal)); })(GiftPremiumModal));

View File

@ -396,7 +396,6 @@ const GiveawayModal: FC<OwnProps & StateProps> = ({
isGiveaway isGiveaway
key={gift.months} key={gift.months}
option={gift} option={gift}
userCount={gift.users}
fullMonthlyAmount={fullMonthlyAmount!} fullMonthlyAmount={fullMonthlyAmount!}
checked={gift.months === selectedMonthOption} checked={gift.months === selectedMonthOption}
onChange={setSelectedMonthOption} onChange={setSelectedMonthOption}
@ -707,6 +706,7 @@ const GiveawayModal: FC<OwnProps & StateProps> = ({
selectionLimit={countrySelectionLimit} selectionLimit={countrySelectionLimit}
/> />
<AppendEntityPickerModal <AppendEntityPickerModal
key={entityType}
isOpen={isEntityPickerModalOpen} isOpen={isEntityPickerModalOpen}
onClose={closeEntityPickerModal} onClose={closeEntityPickerModal}
entityType={entityType} entityType={entityType}

View File

@ -0,0 +1,18 @@
import type { FC } from '../../../lib/teact/teact';
import React from '../../../lib/teact/teact';
import type { OwnProps } from './PremiumGiftingModal';
import { Bundles } from '../../../util/moduleLoader';
import useModuleLoader from '../../../hooks/useModuleLoader';
const PremiumGiftingModalAsync: FC<OwnProps> = (props) => {
const { isOpen } = props;
const PremiumGiftingModal = useModuleLoader(Bundles.Extra, 'PremiumGiftingModal', !isOpen);
// eslint-disable-next-line react/jsx-props-no-spreading
return PremiumGiftingModal ? <PremiumGiftingModal {...props} /> : undefined;
};
export default PremiumGiftingModalAsync;

View File

@ -0,0 +1,80 @@
.root :global(.modal-content) {
padding: 0;
}
.root :global(.modal-dialog) {
max-width: 55vh;
}
.root :global(.modal-dialog), .root :global(.modal-content) {
overflow: hidden;
}
.main {
height: 90vh;
}
.filter {
padding: 0.375rem 1rem 0.25rem 0.75rem;
margin-bottom: 0.625rem;
background-color: var(--color-background);
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
border-bottom: 0.625rem solid var(--color-background-secondary);
display: flex;
flex-flow: row wrap;
align-items: center;
flex-shrink: 0;
overflow-y: auto;
max-height: 20rem;
}
.title {
margin: 0;
}
.buttons {
width: 100%;
background: var(--color-background);
position: absolute;
bottom: 0;
z-index: 1;
padding: 0.75rem;
}
.picker {
height: 75vh;
}
.avatars {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 1rem;
margin: 1rem;
}
.center {
text-align: center;
}
.description,
.premiumFeatures {
text-align: center;
margin: 0 auto 2rem;
max-width: 25rem;
}
.premiumFeatures {
font-size: 0.9375rem;
color: var(--color-text-secondary);
}
.options {
margin-bottom: 2.5rem;
}
.button {
height: 3rem;
font-weight: 600;
}

View File

@ -0,0 +1,139 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useMemo, useState,
} from '../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../global';
import { GIVEAWAY_MAX_ADDITIONAL_CHANNELS } from '../../../config';
import {
filterUsersByName, isUserBot,
} from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import { unique } from '../../../util/iteratees';
import sortChatIds from '../../common/helpers/sortChatIds';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import Icon from '../../common/Icon';
import Picker from '../../common/Picker';
import Button from '../../ui/Button';
import Modal from '../../ui/Modal';
import styles from './PremiumGiftingModal.module.scss';
export type OwnProps = {
isOpen?: boolean;
};
interface StateProps {
currentUserId?: string;
userSelectionLimit?: number;
userIds?: string[];
}
const PremiumGiftingModal: FC<OwnProps & StateProps> = ({
isOpen,
currentUserId,
userSelectionLimit = GIVEAWAY_MAX_ADDITIONAL_CHANNELS,
userIds,
}) => {
const { closePremiumGiftingModal, openGiftPremiumModal, showNotification } = getActions();
const lang = useLang();
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState<string>('');
const displayedUserIds = useMemo(() => {
const usersById = getGlobal().users.byId;
const filteredContactIds = userIds ? filterUsersByName(userIds, usersById, searchQuery) : [];
return sortChatIds(unique(filteredContactIds).filter((userId) => {
const user = usersById[userId];
if (!user) {
return true;
}
return !isUserBot(user) && userId !== currentUserId;
}));
}, [currentUserId, searchQuery, userIds]);
const handleSendIdList = useLastCallback(() => {
if (selectedUserIds?.length) {
openGiftPremiumModal({ forUserIds: selectedUserIds });
closePremiumGiftingModal();
}
});
const handleSelectedUserIdsChange = useLastCallback((newSelectedIds: string[]) => {
if (newSelectedIds.length > userSelectionLimit) {
showNotification({
message: lang('BoostingSelectUpToWarningUsers', userSelectionLimit),
});
return;
}
setSelectedUserIds(newSelectedIds);
});
function renderSearchField() {
return (
<div className={styles.filter} dir={lang.isRtl ? 'rtl' : undefined}>
<Button
round
size="smaller"
color="translucent"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => closePremiumGiftingModal()}
ariaLabel={lang('Close')}
>
<Icon name="close" />
</Button>
<h3 className={styles.title}>{lang('GiftTelegramPremiumTitle')}
</h3>
</div>
);
}
return (
<Modal
className={styles.root}
isOpen={isOpen}
onClose={closePremiumGiftingModal}
onEnter={handleSendIdList}
>
<div className={styles.main}>
{renderSearchField()}
<div className={buildClassName(styles.main, 'custom-scroll')}>
<Picker
className={styles.picker}
itemIds={displayedUserIds}
selectedIds={selectedUserIds}
filterValue={searchQuery}
filterPlaceholder={lang('Search')}
searchInputId="users-picker-search"
onSelectedIdsChange={handleSelectedUserIdsChange}
onFilterChange={setSearchQuery}
isSearchable
/>
</div>
<div className={styles.buttons}>
<Button withPremiumGradient size="smaller" onClick={handleSendIdList} disabled={!selectedUserIds?.length}>
{lang('Continue')}
</Button>
</div>
</div>
</Modal>
);
};
export default memo(withGlobal<OwnProps>((global): StateProps => {
const { currentUserId } = global;
return {
currentUserId,
userIds: global.contactList?.userIds,
userSelectionLimit: global.appConfig?.giveawayAddPeersMax,
};
})(PremiumGiftingModal));

View File

@ -174,5 +174,6 @@
} }
.subscriptionOption { .subscriptionOption {
margin: 0.8125rem; margin: 0.8125rem;
} }

View File

@ -14,7 +14,6 @@ import styles from './PremiumSubscriptionOption.module.scss';
type OwnProps = { type OwnProps = {
option: ApiPremiumGiftOption | ApiPremiumGiftCodeOption; option: ApiPremiumGiftOption | ApiPremiumGiftCodeOption;
isGiveaway?: boolean; isGiveaway?: boolean;
userCount?: number;
checked?: boolean; checked?: boolean;
fullMonthlyAmount?: number; fullMonthlyAmount?: number;
className?: string; className?: string;
@ -23,14 +22,16 @@ type OwnProps = {
const PremiumSubscriptionOption: FC<OwnProps> = ({ const PremiumSubscriptionOption: FC<OwnProps> = ({
option, checked, fullMonthlyAmount, option, checked, fullMonthlyAmount,
onChange, className, isGiveaway, userCount, onChange, className, isGiveaway,
}) => { }) => {
const lang = useLang(); const lang = useLang();
const { const {
months, amount, currency, months, amount, currency,
} = option; } = option;
const users = 'users' in option ? option.users : undefined;
const perMonth = Math.floor(amount / months); const perMonth = Math.floor(amount / months);
const isUserCountPlural = users ? users > 1 : undefined;
const discount = useMemo(() => { const discount = useMemo(() => {
return fullMonthlyAmount && fullMonthlyAmount > perMonth return fullMonthlyAmount && fullMonthlyAmount > perMonth
@ -63,9 +64,9 @@ const PremiumSubscriptionOption: FC<OwnProps> = ({
/> />
<div className={styles.content}> <div className={styles.content}>
<div className={styles.month}> <div className={styles.month}>
{Boolean(discount) && isGiveaway && ( {Boolean(discount) && (
<span <span
className={buildClassName(styles.giveawayDiscount, isGiveaway && styles.discount)} className={buildClassName(styles.giveawayDiscount, styles.discount)}
title={lang('GiftDiscount')} title={lang('GiftDiscount')}
> &minus;{discount}% > &minus;{discount}%
</span> </span>
@ -73,11 +74,11 @@ const PremiumSubscriptionOption: FC<OwnProps> = ({
{lang('Months', months)} {lang('Months', months)}
</div> </div>
<div className={styles.perMonth}> <div className={styles.perMonth}>
{isGiveaway ? `${formatCurrency(amount, currency, lang.code)} x ${userCount!}` {(isGiveaway || isUserCountPlural) ? `${formatCurrency(amount, currency, lang.code)} x ${users!}`
: lang('PricePerMonth', formatCurrency(perMonth, currency, lang.code))} : lang('PricePerMonth', formatCurrency(perMonth, currency, lang.code))}
</div> </div>
<div className={styles.amount}> <div className={styles.amount}>
{formatCurrency(isGiveaway ? amount * userCount! : amount, currency, lang.code)} {formatCurrency(amount, currency, lang.code)}
</div> </div>
</div> </div>
</label> </label>

View File

@ -313,7 +313,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
}); });
const handleGiftPremiumClick = useLastCallback(() => { const handleGiftPremiumClick = useLastCallback(() => {
openGiftPremiumModal({ forUserId: chatId }); openGiftPremiumModal({ forUserIds: [chatId] });
closeMenu(); closeMenu();
}); });

View File

@ -656,7 +656,7 @@ export default memo(withGlobal<OwnProps>(
providerName = url.startsWith(DONATE_PROVIDER_URL) ? DONATE_PROVIDER : undefined; providerName = url.startsWith(DONATE_PROVIDER_URL) ? DONATE_PROVIDER : undefined;
} }
const chat = inputInvoice && 'chatId' in inputInvoice ? selectChat(global, inputInvoice.chatId) : undefined; const chat = inputInvoice && 'chatId' in inputInvoice ? selectChat(global, inputInvoice.chatId!) : undefined;
const isProviderError = Boolean(invoice && (!providerName || !SUPPORTED_PROVIDERS.has(providerName))); const isProviderError = Boolean(invoice && (!providerName || !SUPPORTED_PROVIDERS.has(providerName)));
const { needCardholderName, needCountry, needZip } = (nativeParams || {}); const { needCardholderName, needCountry, needZip } = (nativeParams || {});
const { const {

View File

@ -318,6 +318,7 @@ export const GIVEAWAY_BOOST_PER_PREMIUM = 4;
export const GIVEAWAY_MAX_ADDITIONAL_CHANNELS = 10; export const GIVEAWAY_MAX_ADDITIONAL_CHANNELS = 10;
export const GIVEAWAY_MAX_ADDITIONAL_USERS = 10; export const GIVEAWAY_MAX_ADDITIONAL_USERS = 10;
export const GIVEAWAY_MAX_ADDITIONAL_COUNTRIES = 10; export const GIVEAWAY_MAX_ADDITIONAL_COUNTRIES = 10;
export const BOOST_PER_SENT_GIFT = 3;
export const LIGHT_THEME_BG_COLOR = '#99BA92'; export const LIGHT_THEME_BG_COLOR = '#99BA92';
export const DARK_THEME_BG_COLOR = '#0F0F0F'; export const DARK_THEME_BG_COLOR = '#0F0F0F';

View File

@ -422,18 +422,46 @@ addActionHandler('closeGiveawayModal', (global, actions, payload): ActionReturnT
}, tabId); }, tabId);
}); });
addActionHandler('openPremiumGiftingModal', (global, actions, payload): ActionReturnType => {
const {
tabId = getCurrentTabId(),
} = payload || {};
global = getGlobal();
global = updateTabState(global, {
giftingModal: {
isOpen: true,
},
}, tabId);
setGlobal(global);
});
addActionHandler('closePremiumGiftingModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
giftingModal: undefined,
}, tabId);
});
addActionHandler('openGiftPremiumModal', async (global, actions, payload): Promise<void> => { addActionHandler('openGiftPremiumModal', async (global, actions, payload): Promise<void> => {
const { forUserId, tabId = getCurrentTabId() } = payload || {}; const {
forUserIds, tabId = getCurrentTabId(),
} = payload || {};
const result = await callApi('fetchPremiumPromo'); const result = await callApi('fetchPremiumPromo');
if (!result) return; if (!result) return;
global = getGlobal(); global = getGlobal();
global = addUsers(global, buildCollectionByKey(result.users, 'id')); global = addUsers(global, buildCollectionByKey(result.users, 'id'));
const gifts = await callApi('getPremiumGiftCodeOptions', {});
global = updateTabState(global, { global = updateTabState(global, {
giftPremiumModal: { giftPremiumModal: {
isOpen: true, isOpen: true,
forUserId, forUserIds,
gifts,
}, },
}, tabId); }, tabId);
setGlobal(global); setGlobal(global);

View File

@ -1,9 +1,10 @@
import type { ActionReturnType } from '../../types'; import type { ActionReturnType } from '../../types';
import { areDeepEqual } from '../../../util/areDeepEqual';
import { formatCurrency } from '../../../util/formatCurrency'; import { formatCurrency } from '../../../util/formatCurrency';
import * as langProvider from '../../../util/langProvider'; import * as langProvider from '../../../util/langProvider';
import { IS_PRODUCTION_HOST } from '../../../util/windowEnvironment'; import { IS_PRODUCTION_HOST } from '../../../util/windowEnvironment';
import { addActionHandler } from '../../index'; import { addActionHandler, setGlobal } from '../../index';
import { closeInvoice } from '../../reducers'; import { closeInvoice } from '../../reducers';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
import { selectChatMessage, selectTabState } from '../../selectors'; import { selectChatMessage, selectTabState } from '../../selectors';
@ -30,6 +31,24 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
} }
} }
if (inputInvoice && inputInvoice.type === 'giftcode') {
if (!inputInvoice.userIds) {
return;
}
const giftModalState = selectTabState(global, tabId).giftPremiumModal;
if (giftModalState && giftModalState.isOpen
&& areDeepEqual(inputInvoice.userIds, giftModalState.forUserIds)) {
global = updateTabState(global, {
giftPremiumModal: {
...giftModalState,
isCompleted: true,
},
}, tabId);
setGlobal(global);
}
}
// On the production host, the payment frame receives a message with the payment event, // On the production host, the payment frame receives a message with the payment event,
// after which the payment form closes. In other cases, the payment form must be closed manually. // after which the payment form closes. In other cases, the payment form must be closed manually.
// Closing the invoice will cause the closing of the Payment Modal dialog and then closing the payment. // Closing the invoice will cause the closing of the Payment Modal dialog and then closing the payment.

View File

@ -641,9 +641,15 @@ export type TabState = {
prepaidGiveaway?: ApiPrepaidGiveaway; prepaidGiveaway?: ApiPrepaidGiveaway;
}; };
giftPremiumModal?: { giftingModal?: {
isOpen?: boolean; isOpen?: boolean;
forUserId?: string; };
giftPremiumModal?: {
isCompleted?: boolean;
isOpen?: boolean;
forUserIds?: string[];
gifts?: ApiPremiumGiftCodeOption[];
}; };
limitReachedModal?: { limitReachedModal?: {
@ -2966,6 +2972,9 @@ export interface ActionPayloads {
} & WithTabId); } & WithTabId);
closeGiveawayModal: WithTabId | undefined; closeGiveawayModal: WithTabId | undefined;
openPremiumGiftingModal: WithTabId | undefined;
closePremiumGiftingModal: WithTabId | undefined;
transcribeAudio: { transcribeAudio: {
chatId: string; chatId: string;
messageId: number; messageId: number;
@ -2976,7 +2985,9 @@ export interface ActionPayloads {
loadPremiumStickers: undefined; loadPremiumStickers: undefined;
openGiftPremiumModal: ({ openGiftPremiumModal: ({
forUserId?: string; chatId?: string;
forMultipleUsers?: boolean;
forUserIds?: string[];
} & WithTabId) | undefined; } & WithTabId) | undefined;
closeGiftPremiumModal: WithTabId | undefined; closeGiftPremiumModal: WithTabId | undefined;