From 34a3b620890818d23a24444d52198f211b73e2c7 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 19 Apr 2024 13:39:04 +0400 Subject: [PATCH] Gifting: Premium Gifting (#4472) Co-authored-by: zubiden <19638254+zubiden@users.noreply.github.com> --- src/api/gramjs/apiBuilders/appConfig.ts | 2 + src/api/types/misc.ts | 1 + src/bundles/extra.ts | 1 + .../common/profile/UserBirthday.tsx | 2 +- src/components/left/settings/Settings.scss | 2 +- src/components/left/settings/SettingsMain.tsx | 20 +- .../main/AppendEntityPickerModal.tsx | 33 +-- src/components/main/Main.tsx | 6 + .../main/premium/GiftPremiumModal.module.scss | 23 +- .../main/premium/GiftPremiumModal.tsx | 251 +++++++++++++----- src/components/main/premium/GiveawayModal.tsx | 2 +- .../premium/PremiumGiftingModal.async.tsx | 18 ++ .../premium/PremiumGiftingModal.module.scss | 80 ++++++ .../main/premium/PremiumGiftingModal.tsx | 139 ++++++++++ .../main/premium/PremiumMainModal.module.scss | 3 +- .../premium/PremiumSubscriptionOption.tsx | 13 +- src/components/middle/HeaderMenuContainer.tsx | 2 +- src/components/payment/PaymentModal.tsx | 2 +- src/config.ts | 1 + src/global/actions/api/payments.ts | 32 ++- src/global/actions/apiUpdaters/payments.ts | 21 +- src/global/types.ts | 17 +- 22 files changed, 555 insertions(+), 116 deletions(-) create mode 100644 src/components/main/premium/PremiumGiftingModal.async.tsx create mode 100644 src/components/main/premium/PremiumGiftingModal.module.scss create mode 100644 src/components/main/premium/PremiumGiftingModal.tsx diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index 648987107..21db0bcaa 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -63,6 +63,7 @@ export interface GramJsAppConfig extends LimitsConfig { authorization_autoconfirm_period: number; giveaway_boosts_per_premium: number; giveaway_countries_max: number; + boosts_per_sent_gift: number; // Forums topics_pinned_limit: number; // Stories @@ -127,6 +128,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp giveawayAddPeersMax: appConfig.giveaway_add_peers_max, giveawayBoostsPerPremium: appConfig.giveaway_boosts_per_premium, giveawayCountriesMax: appConfig.giveaway_countries_max, + boostsPerSentGift: appConfig.boosts_per_sent_gift, canDisplayAutoarchiveSetting: appConfig.autoarchive_setting_available, limits: { uploadMaxFileparts: getLimit(appConfig, 'upload_max_fileparts', 'uploadMaxFileparts'), diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 9a5b512de..9309edd7e 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -189,6 +189,7 @@ export interface ApiAppConfig { giveawayAddPeersMax: number; giveawayBoostsPerPremium: number; giveawayCountriesMax: number; + boostsPerSentGift: number; premiumPromoOrder: ApiPremiumSection[]; defaultEmojiStatusesStickerSetId: string; maxUniqueReactions: number; diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index b5ff94f9e..eed219e9c 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -18,6 +18,7 @@ export { default as DeleteFolderDialog } from '../components/main/DeleteFolderDi export { default as PremiumMainModal } from '../components/main/premium/PremiumMainModal'; export { default as GiftPremiumModal } from '../components/main/premium/GiftPremiumModal'; 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 PremiumLimitReachedModal } from '../components/main/premium/common/PremiumLimitReachedModal'; export { default as StatusPickerMenu } from '../components/left/main/StatusPickerMenu'; diff --git a/src/components/common/profile/UserBirthday.tsx b/src/components/common/profile/UserBirthday.tsx index fbca2bc8f..e8ada379c 100644 --- a/src/components/common/profile/UserBirthday.tsx +++ b/src/components/common/profile/UserBirthday.tsx @@ -145,7 +145,7 @@ const UserBirthday = ({ const canGiftPremium = isToday && !user.isPremium && !user.isSelf && !isPremiumPurchaseBlocked; const handleOpenGiftModal = useLastCallback(() => { - openGiftPremiumModal({ forUserId: user.id }); + openGiftPremiumModal({ forUserIds: [user.id] }); }); const handleClick = useLastCallback(() => { diff --git a/src/components/left/settings/Settings.scss b/src/components/left/settings/Settings.scss index 08aeaf1ad..a1f6444f1 100644 --- a/src/components/left/settings/Settings.scss +++ b/src/components/left/settings/Settings.scss @@ -104,7 +104,7 @@ } .settings-main-menu-premium .PremiumIcon { - margin-right: 2rem; + margin-right: 1.25rem; } .settings-main-menu { diff --git a/src/components/left/settings/SettingsMain.tsx b/src/components/left/settings/SettingsMain.tsx index a92bece9b..0420f5d14 100644 --- a/src/components/left/settings/SettingsMain.tsx +++ b/src/components/left/settings/SettingsMain.tsx @@ -5,7 +5,10 @@ import { getActions, withGlobal } from '../../../global'; import { SettingsScreens } from '../../../types'; import { FAQ_URL, PRIVACY_URL } from '../../../config'; -import { selectIsPremiumPurchaseBlocked } from '../../../global/selectors'; +import { + selectIsGiveawayGiftsPurchaseAvailable, + selectIsPremiumPurchaseBlocked, +} from '../../../global/selectors'; import useFlag from '../../../hooks/useFlag'; import useHistoryBack from '../../../hooks/useHistoryBack'; @@ -28,6 +31,7 @@ type StateProps = { sessionCount: number; currentUserId?: string; canBuyPremium?: boolean; + isGiveawayAvailable?: boolean; }; const SettingsMain: FC = ({ @@ -37,12 +41,14 @@ const SettingsMain: FC = ({ currentUserId, sessionCount, canBuyPremium, + isGiveawayAvailable, }) => { const { loadProfilePhotos, openPremiumModal, openSupportChat, openUrl, + openPremiumGiftingModal, } = getActions(); const [isSupportDialogOpen, openSupportDialog, closeSupportDialog] = useFlag(false); @@ -158,6 +164,16 @@ const SettingsMain: FC = ({ {lang('TelegramPremium')} )} + {isGiveawayAvailable && ( + openPremiumGiftingModal()} + > + {lang('GiftPremiumGifting')} + + )}
= ({ export default memo(withGlobal( (global): StateProps => { const { currentUserId } = global; + const isGiveawayAvailable = selectIsGiveawayGiftsPurchaseAvailable(global); return { sessionCount: global.activeSessions.orderedHashes.length, currentUserId, canBuyPremium: !selectIsPremiumPurchaseBlocked(global), + isGiveawayAvailable, }; }, )(SettingsMain)); diff --git a/src/components/main/AppendEntityPickerModal.tsx b/src/components/main/AppendEntityPickerModal.tsx index b01e9e733..9f2c4f7e1 100644 --- a/src/components/main/AppendEntityPickerModal.tsx +++ b/src/components/main/AppendEntityPickerModal.tsx @@ -1,6 +1,8 @@ import type { FC } from '../../lib/teact/teact'; import React, { - memo, useMemo, useState, + memo, + useMemo, + useState, } from '../../lib/teact/teact'; import { getActions, getGlobal, withGlobal } from '../../global'; @@ -66,8 +68,7 @@ const AppendEntityPickerModal: FC = ({ const lang = useLang(); const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag(); - const [selectedChannelIds, setSelectedChannelIds] = useState([]); - const [selectedMemberIds, setSelectedMemberIds] = useState([]); + const [selectedIds, setSelectedIds] = useState([]); const [pendingChannelId, setPendingChannelId] = useState(undefined); const [searchQuery, setSearchQuery] = useState(''); @@ -127,8 +128,8 @@ const AppendEntityPickerModal: FC = ({ return isChannel || isSuperGroup; }), false, - selectedChannelIds); - }, [channelsIds, lang, searchQuery, selectedChannelIds, isSuperGroup, isChannel]); + selectedIds); + }, [channelsIds, lang, searchQuery, selectedIds, isSuperGroup, isChannel]); const handleCloseButtonClick = useLastCallback(() => { onSubmit([]); @@ -136,36 +137,36 @@ const AppendEntityPickerModal: FC = ({ }); const handleSendIdList = useLastCallback(() => { - onSubmit(entityType === 'members' ? selectedMemberIds : selectedChannelIds); + onSubmit(selectedIds); onClose(); }); const confirmPrivateLinkChannelSelection = useLastCallback(() => { if (pendingChannelId) { - setSelectedChannelIds((prevIds) => unique([...prevIds, pendingChannelId])); + setSelectedIds((prevIds) => unique([...prevIds, pendingChannelId])); } closeConfirmModal(); }); - const handleSelectedMembersChange = useLastCallback((newSelectedIds: string[]) => { + const handleSelectedMemberIdsChange = useLastCallback((newSelectedIds: string[]) => { if (newSelectedIds.length > selectionLimit) { showNotification({ message: lang('BoostingSelectUpToWarningUsers', selectionLimit), }); return; } - setSelectedMemberIds(newSelectedIds); + setSelectedIds(newSelectedIds); }); const handleSelectedChannelIdsChange = useLastCallback((newSelectedIds: string[]) => { 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 chat = chatsById[id]; return chat && !isChatPublic(chat); }); - if (selectedChannelIds?.length >= selectionLimit) { + if (selectedIds?.length >= selectionLimit) { showNotification({ message: lang('BoostingSelectUpToWarningChannelsPlural', selectionLimit), }); @@ -176,7 +177,7 @@ const AppendEntityPickerModal: FC = ({ setPendingChannelId(privateLinkChannelId); openConfirmModal(); } else { - setSelectedChannelIds(newSelectedIds); + setSelectedIds(newSelectedIds); } }); @@ -218,12 +219,12 @@ const AppendEntityPickerModal: FC = ({ @@ -261,7 +262,7 @@ export default memo(withGlobal((global, { chatId, entityType }): State members = chatFullInfo.members; adminMembersById = chatFullInfo.adminMembersById; } - } else if (entityType === 'channels') { + } if (entityType === 'channels') { const chat = chatId ? selectChat(global, chatId) : undefined; if (chat) { isChannel = isChatChannel(chat); diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 8da311c01..039df6727 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -102,6 +102,7 @@ import NewContactModal from './NewContactModal.async'; import Notifications from './Notifications.async'; import PremiumLimitReachedModal from './premium/common/PremiumLimitReachedModal.async'; import GiveawayModal from './premium/GiveawayModal.async'; +import PremiumGiftingModal from './premium/PremiumGiftingModal.async'; import PremiumMainModal from './premium/PremiumMainModal.async'; import SafeLinkModal from './SafeLinkModal.async'; @@ -159,6 +160,7 @@ type StateProps = { isReactionPickerOpen: boolean; isAppendModalOpen?: boolean; isGiveawayModalOpen?: boolean; + isPremiumGiftingModalOpen?: boolean; isCurrentUserPremium?: boolean; chatlistModal?: TabState['chatlistModal']; boostModal?: TabState['boostModal']; @@ -218,6 +220,7 @@ const Main: FC = ({ urlAuth, isPremiumModalOpen, isGiveawayModalOpen, + isPremiumGiftingModalOpen, isPaymentModalOpen, isReceiptModalOpen, isReactionPickerOpen, @@ -603,6 +606,7 @@ const Main: FC = ({ {isPremiumModalOpen && } {isGiveawayModalOpen && } + {isPremiumGiftingModalOpen && } @@ -646,6 +650,7 @@ export default memo(withGlobal( ratingPhoneCall, premiumModal, giveawayModal, + giftingModal, isMasterTab, payment, limitReachedModal, @@ -712,6 +717,7 @@ export default memo(withGlobal( isCurrentUserPremium: selectIsCurrentUserPremium(global), isPremiumModalOpen: premiumModal?.isOpen, isGiveawayModalOpen: giveawayModal?.isOpen, + isPremiumGiftingModalOpen: giftingModal?.isOpen, limitReached: limitReachedModal?.limit, isPaymentModalOpen: payment.isPaymentModalOpen, isReceiptModalOpen: Boolean(payment.receipt), diff --git a/src/components/main/premium/GiftPremiumModal.module.scss b/src/components/main/premium/GiftPremiumModal.module.scss index bfe8e82bb..ca88c232c 100644 --- a/src/components/main/premium/GiftPremiumModal.module.scss +++ b/src/components/main/premium/GiftPremiumModal.module.scss @@ -10,13 +10,16 @@ left: 0.5rem; } -.avatar { - margin: 0 auto 1.5rem; +.avatars { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 1rem; + margin: 1rem; } -.headerText { - font-size: 1.5rem; - font-weight: 500; +.center { text-align: center; } @@ -36,9 +39,9 @@ margin-bottom: 2.5rem; } -.button { - height: 3rem; - background: linear-gradient(88.39deg, #6C93FF -2.56%, #976FFF 51.27%, #DF69D1 107.39%); - font-size: 1rem; - font-weight: 600; +.boostIcon { + color: var(--color-primary); + vertical-align: middle; + line-height: 1.5; } + diff --git a/src/components/main/premium/GiftPremiumModal.tsx b/src/components/main/premium/GiftPremiumModal.tsx index d556cb7b2..18c2eecb1 100644 --- a/src/components/main/premium/GiftPremiumModal.tsx +++ b/src/components/main/premium/GiftPremiumModal.tsx @@ -1,23 +1,28 @@ import type { FC } from '../../../lib/teact/teact'; import React, { - memo, useCallback, useEffect, useMemo, useState, + memo, useEffect, useMemo, useRef, + useState, } 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 { selectTabState, - selectUser, - selectUserFullInfo, } from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; import { formatCurrency } from '../../../util/formatCurrency'; import renderText from '../../common/helpers/renderText'; 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 Link from '../../ui/Link'; import Modal from '../../ui/Modal'; @@ -30,55 +35,124 @@ export type OwnProps = { }; type StateProps = { - user?: ApiUser; - gifts?: ApiPremiumGiftOption[]; - monthlyCurrency?: string; - monthlyAmount?: number; + isCompleted?: boolean; + gifts?: ApiPremiumGiftCodeOption[] | undefined; + forUserIds?: string[]; + boostPerSentGift?: number; }; const GiftPremiumModal: FC = ({ isOpen, - user, + isCompleted, gifts, + boostPerSentGift = BOOST_PER_SENT_GIFT, + forUserIds, }) => { - const { openPremiumModal, closeGiftPremiumModal, openUrl } = getActions(); + // eslint-disable-next-line no-null/no-null + const dialogRef = useRef(null); + + const { + openPremiumModal, closeGiftPremiumModal, openInvoice, requestConfetti, + } = getActions(); const lang = useLang(); - const [selectedOption, setSelectedOption] = useState(); - const fullMonthlyAmount = useMemo(() => { - if (!gifts?.length) { + const [selectedMonthOption, setSelectedMonthOption] = useState(); + + 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; } - const basicGift = gifts.reduce((acc, gift) => { - return gift.months < acc.months ? gift : acc; + const basicGift = filteredGifts.reduce((acc, gift) => { + return gift.amount < acc.amount ? gift : acc; }); return Math.floor(basicGift.amount / basicGift.months); - }, [gifts]); + }, [filteredGifts]); - useEffect(() => { - 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(() => { + const handleSubmit = useLastCallback(() => { if (!selectedGift) { return; } - closeGiftPremiumModal(); - openUrl({ url: selectedGift.botUrl }); - }, [closeGiftPremiumModal, openUrl, selectedGift]); + openInvoice({ + type: 'giftcode', + userIds: forUserIds!, + currency: selectedGift!.currency, + amount: selectedGift!.amount, + option: selectedGift!, + }); + }); - const handlePremiumClick = useCallback(() => { + const handlePremiumClick = useLastCallback(() => { 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() { const info = lang('GiftPremiumListFeaturesAndTerms'); @@ -90,7 +164,7 @@ const GiftPremiumModal: FC = ({ } return ( -

+

{parts[1]} {parts[2]} {parts[3]} @@ -98,8 +172,43 @@ const GiftPremiumModal: FC = ({ ); } + 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 ; + } + return subpart; + }); + } + return part; + }); + } + + function renderSubscriptionGiftOptions() { + return ( +

+ {filteredGifts?.map((gift) => { + return ( + + ); + })} +
+ ); + } + return ( = ({ > - -

- {lang('GiftTelegramPremiumTitle')} -

-

- {renderText( - lang('GiftTelegramPremiumDescription', getUserFirstOrLastName(user)), - ['emoji', 'simple_markdown'], - )} -

- -
- {gifts?.map((gift) => ( - - ))} +
+
+

+ {renderGiftTitle()} +

+

+ {renderGiftText()} +

+ {!isCompleted && ( + <> +

+ {renderText(renderBoostsPluralText(), ['simple_markdown', 'emoji'])} +

+
+ {renderSubscriptionGiftOptions()} +
+ + )} {renderPremiumFeaturesLink()}
- + {!isCompleted && ( + + )}
); }; export default memo(withGlobal((global): StateProps => { - const { forUserId } = selectTabState(global).giftPremiumModal || {}; - const user = forUserId ? selectUser(global, forUserId) : undefined; - const gifts = user ? selectUserFullInfo(global, user.id)?.premiumGifts : undefined; + const { + gifts, forUserIds, isCompleted, + } = selectTabState(global).giftPremiumModal || {}; return { - user, + isCompleted, gifts, + boostPerSentGift: global.appConfig?.boostsPerSentGift, + forUserIds, }; })(GiftPremiumModal)); diff --git a/src/components/main/premium/GiveawayModal.tsx b/src/components/main/premium/GiveawayModal.tsx index ca74cd30a..e6d444ea3 100644 --- a/src/components/main/premium/GiveawayModal.tsx +++ b/src/components/main/premium/GiveawayModal.tsx @@ -396,7 +396,6 @@ const GiveawayModal: FC = ({ isGiveaway key={gift.months} option={gift} - userCount={gift.users} fullMonthlyAmount={fullMonthlyAmount!} checked={gift.months === selectedMonthOption} onChange={setSelectedMonthOption} @@ -707,6 +706,7 @@ const GiveawayModal: FC = ({ selectionLimit={countrySelectionLimit} /> = (props) => { + const { isOpen } = props; + const PremiumGiftingModal = useModuleLoader(Bundles.Extra, 'PremiumGiftingModal', !isOpen); + + // eslint-disable-next-line react/jsx-props-no-spreading + return PremiumGiftingModal ? : undefined; +}; + +export default PremiumGiftingModalAsync; diff --git a/src/components/main/premium/PremiumGiftingModal.module.scss b/src/components/main/premium/PremiumGiftingModal.module.scss new file mode 100644 index 000000000..7b664767e --- /dev/null +++ b/src/components/main/premium/PremiumGiftingModal.module.scss @@ -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; +} diff --git a/src/components/main/premium/PremiumGiftingModal.tsx b/src/components/main/premium/PremiumGiftingModal.tsx new file mode 100644 index 000000000..1a436f514 --- /dev/null +++ b/src/components/main/premium/PremiumGiftingModal.tsx @@ -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 = ({ + isOpen, + currentUserId, + userSelectionLimit = GIVEAWAY_MAX_ADDITIONAL_CHANNELS, + userIds, +}) => { + const { closePremiumGiftingModal, openGiftPremiumModal, showNotification } = getActions(); + + const lang = useLang(); + + const [selectedUserIds, setSelectedUserIds] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + + 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 ( +
+ +

{lang('GiftTelegramPremiumTitle')} +

+
+ ); + } + + return ( + +
+ {renderSearchField()} +
+ +
+
+ +
+
+
+ ); +}; + +export default memo(withGlobal((global): StateProps => { + const { currentUserId } = global; + + return { + currentUserId, + userIds: global.contactList?.userIds, + userSelectionLimit: global.appConfig?.giveawayAddPeersMax, + }; +})(PremiumGiftingModal)); diff --git a/src/components/main/premium/PremiumMainModal.module.scss b/src/components/main/premium/PremiumMainModal.module.scss index a9c46688e..40e3b9acf 100644 --- a/src/components/main/premium/PremiumMainModal.module.scss +++ b/src/components/main/premium/PremiumMainModal.module.scss @@ -174,5 +174,6 @@ } .subscriptionOption { - margin: 0.8125rem; + margin: 0.8125rem; } + diff --git a/src/components/main/premium/PremiumSubscriptionOption.tsx b/src/components/main/premium/PremiumSubscriptionOption.tsx index 7f510ac50..96576bc4c 100644 --- a/src/components/main/premium/PremiumSubscriptionOption.tsx +++ b/src/components/main/premium/PremiumSubscriptionOption.tsx @@ -14,7 +14,6 @@ import styles from './PremiumSubscriptionOption.module.scss'; type OwnProps = { option: ApiPremiumGiftOption | ApiPremiumGiftCodeOption; isGiveaway?: boolean; - userCount?: number; checked?: boolean; fullMonthlyAmount?: number; className?: string; @@ -23,14 +22,16 @@ type OwnProps = { const PremiumSubscriptionOption: FC = ({ option, checked, fullMonthlyAmount, - onChange, className, isGiveaway, userCount, + onChange, className, isGiveaway, }) => { const lang = useLang(); const { months, amount, currency, } = option; + const users = 'users' in option ? option.users : undefined; const perMonth = Math.floor(amount / months); + const isUserCountPlural = users ? users > 1 : undefined; const discount = useMemo(() => { return fullMonthlyAmount && fullMonthlyAmount > perMonth @@ -63,9 +64,9 @@ const PremiumSubscriptionOption: FC = ({ />
- {Boolean(discount) && isGiveaway && ( + {Boolean(discount) && ( −{discount}% @@ -73,11 +74,11 @@ const PremiumSubscriptionOption: FC = ({ {lang('Months', months)}
- {isGiveaway ? `${formatCurrency(amount, currency, lang.code)} x ${userCount!}` + {(isGiveaway || isUserCountPlural) ? `${formatCurrency(amount, currency, lang.code)} x ${users!}` : lang('PricePerMonth', formatCurrency(perMonth, currency, lang.code))}
- {formatCurrency(isGiveaway ? amount * userCount! : amount, currency, lang.code)} + {formatCurrency(amount, currency, lang.code)}
diff --git a/src/components/middle/HeaderMenuContainer.tsx b/src/components/middle/HeaderMenuContainer.tsx index beda5747a..4444afc6e 100644 --- a/src/components/middle/HeaderMenuContainer.tsx +++ b/src/components/middle/HeaderMenuContainer.tsx @@ -313,7 +313,7 @@ const HeaderMenuContainer: FC = ({ }); const handleGiftPremiumClick = useLastCallback(() => { - openGiftPremiumModal({ forUserId: chatId }); + openGiftPremiumModal({ forUserIds: [chatId] }); closeMenu(); }); diff --git a/src/components/payment/PaymentModal.tsx b/src/components/payment/PaymentModal.tsx index 4b15a5a74..19a5ef42c 100644 --- a/src/components/payment/PaymentModal.tsx +++ b/src/components/payment/PaymentModal.tsx @@ -656,7 +656,7 @@ export default memo(withGlobal( 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 { needCardholderName, needCountry, needZip } = (nativeParams || {}); const { diff --git a/src/config.ts b/src/config.ts index 121c76e03..675ea4fb8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -318,6 +318,7 @@ export const GIVEAWAY_BOOST_PER_PREMIUM = 4; export const GIVEAWAY_MAX_ADDITIONAL_CHANNELS = 10; export const GIVEAWAY_MAX_ADDITIONAL_USERS = 10; export const GIVEAWAY_MAX_ADDITIONAL_COUNTRIES = 10; +export const BOOST_PER_SENT_GIFT = 3; export const LIGHT_THEME_BG_COLOR = '#99BA92'; export const DARK_THEME_BG_COLOR = '#0F0F0F'; diff --git a/src/global/actions/api/payments.ts b/src/global/actions/api/payments.ts index 93e8f6ae0..4c71d7dcd 100644 --- a/src/global/actions/api/payments.ts +++ b/src/global/actions/api/payments.ts @@ -422,18 +422,46 @@ addActionHandler('closeGiveawayModal', (global, actions, payload): ActionReturnT }, 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 => { - const { forUserId, tabId = getCurrentTabId() } = payload || {}; + const { + forUserIds, tabId = getCurrentTabId(), + } = payload || {}; const result = await callApi('fetchPremiumPromo'); if (!result) return; global = getGlobal(); global = addUsers(global, buildCollectionByKey(result.users, 'id')); + const gifts = await callApi('getPremiumGiftCodeOptions', {}); + global = updateTabState(global, { giftPremiumModal: { isOpen: true, - forUserId, + forUserIds, + gifts, }, }, tabId); setGlobal(global); diff --git a/src/global/actions/apiUpdaters/payments.ts b/src/global/actions/apiUpdaters/payments.ts index 4d8e01e08..2bd9a5c91 100644 --- a/src/global/actions/apiUpdaters/payments.ts +++ b/src/global/actions/apiUpdaters/payments.ts @@ -1,9 +1,10 @@ import type { ActionReturnType } from '../../types'; +import { areDeepEqual } from '../../../util/areDeepEqual'; import { formatCurrency } from '../../../util/formatCurrency'; import * as langProvider from '../../../util/langProvider'; import { IS_PRODUCTION_HOST } from '../../../util/windowEnvironment'; -import { addActionHandler } from '../../index'; +import { addActionHandler, setGlobal } from '../../index'; import { closeInvoice } from '../../reducers'; import { updateTabState } from '../../reducers/tabs'; 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, // 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. diff --git a/src/global/types.ts b/src/global/types.ts index 38db7859d..259600629 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -641,9 +641,15 @@ export type TabState = { prepaidGiveaway?: ApiPrepaidGiveaway; }; - giftPremiumModal?: { + giftingModal?: { isOpen?: boolean; - forUserId?: string; + }; + + giftPremiumModal?: { + isCompleted?: boolean; + isOpen?: boolean; + forUserIds?: string[]; + gifts?: ApiPremiumGiftCodeOption[]; }; limitReachedModal?: { @@ -2966,6 +2972,9 @@ export interface ActionPayloads { } & WithTabId); closeGiveawayModal: WithTabId | undefined; + openPremiumGiftingModal: WithTabId | undefined; + closePremiumGiftingModal: WithTabId | undefined; + transcribeAudio: { chatId: string; messageId: number; @@ -2976,7 +2985,9 @@ export interface ActionPayloads { loadPremiumStickers: undefined; openGiftPremiumModal: ({ - forUserId?: string; + chatId?: string; + forMultipleUsers?: boolean; + forUserIds?: string[]; } & WithTabId) | undefined; closeGiftPremiumModal: WithTabId | undefined;