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;
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'),

View File

@ -189,6 +189,7 @@ export interface ApiAppConfig {
giveawayAddPeersMax: number;
giveawayBoostsPerPremium: number;
giveawayCountriesMax: number;
boostsPerSentGift: number;
premiumPromoOrder: ApiPremiumSection[];
defaultEmojiStatusesStickerSetId: string;
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 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';

View File

@ -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(() => {

View File

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

View File

@ -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<OwnProps & StateProps> = ({
@ -37,12 +41,14 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
currentUserId,
sessionCount,
canBuyPremium,
isGiveawayAvailable,
}) => {
const {
loadProfilePhotos,
openPremiumModal,
openSupportChat,
openUrl,
openPremiumGiftingModal,
} = getActions();
const [isSupportDialogOpen, openSupportDialog, closeSupportDialog] = useFlag(false);
@ -158,6 +164,16 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
{lang('TelegramPremium')}
</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 className="settings-main-menu">
<ListItem
@ -196,11 +212,13 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const { currentUserId } = global;
const isGiveawayAvailable = selectIsGiveawayGiftsPurchaseAvailable(global);
return {
sessionCount: global.activeSessions.orderedHashes.length,
currentUserId,
canBuyPremium: !selectIsPremiumPurchaseBlocked(global),
isGiveawayAvailable,
};
},
)(SettingsMain));

View File

@ -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<OwnProps & StateProps> = ({
const lang = useLang();
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag();
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>([]);
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [pendingChannelId, setPendingChannelId] = useState<string | undefined>(undefined);
const [searchQuery, setSearchQuery] = useState<string>('');
@ -127,8 +128,8 @@ const AppendEntityPickerModal: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
});
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<OwnProps & StateProps> = ({
setPendingChannelId(privateLinkChannelId);
openConfirmModal();
} else {
setSelectedChannelIds(newSelectedIds);
setSelectedIds(newSelectedIds);
}
});
@ -218,12 +219,12 @@ const AppendEntityPickerModal: FC<OwnProps & StateProps> = ({
<Picker
className={styles.picker}
itemIds={entityType === 'members' ? displayedMembersIds : displayedChannelIds}
selectedIds={entityType === 'members' ? selectedMemberIds : selectedChannelIds}
selectedIds={selectedIds}
filterValue={searchQuery}
filterPlaceholder={lang('Search')}
searchInputId="new-members-picker-search"
searchInputId={`${entityType}-picker-search`}
onSelectedIdsChange={entityType === 'channels'
? handleSelectedChannelIdsChange : handleSelectedMembersChange}
? handleSelectedChannelIdsChange : handleSelectedMemberIdsChange}
onFilterChange={setSearchQuery}
isSearchable
/>
@ -261,7 +262,7 @@ export default memo(withGlobal<OwnProps>((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);

View File

@ -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<OwnProps & StateProps> = ({
urlAuth,
isPremiumModalOpen,
isGiveawayModalOpen,
isPremiumGiftingModalOpen,
isPaymentModalOpen,
isReceiptModalOpen,
isReactionPickerOpen,
@ -603,6 +606,7 @@ const Main: FC<OwnProps & StateProps> = ({
<MessageListHistoryHandler />
{isPremiumModalOpen && <PremiumMainModal isOpen={isPremiumModalOpen} />}
{isGiveawayModalOpen && <GiveawayModal isOpen={isGiveawayModalOpen} />}
{isPremiumGiftingModalOpen && <PremiumGiftingModal isOpen={isPremiumGiftingModalOpen} />}
<PremiumLimitReachedModal limit={limitReached} />
<PaymentModal isOpen={isPaymentModalOpen} onClose={closePaymentModal} />
<ReceiptModal isOpen={isReceiptModalOpen} onClose={clearReceipt} />
@ -646,6 +650,7 @@ export default memo(withGlobal<OwnProps>(
ratingPhoneCall,
premiumModal,
giveawayModal,
giftingModal,
isMasterTab,
payment,
limitReachedModal,
@ -712,6 +717,7 @@ export default memo(withGlobal<OwnProps>(
isCurrentUserPremium: selectIsCurrentUserPremium(global),
isPremiumModalOpen: premiumModal?.isOpen,
isGiveawayModalOpen: giveawayModal?.isOpen,
isPremiumGiftingModalOpen: giftingModal?.isOpen,
limitReached: limitReachedModal?.limit,
isPaymentModalOpen: payment.isPaymentModalOpen,
isReceiptModalOpen: Boolean(payment.receipt),

View File

@ -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;
}

View File

@ -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<OwnProps & StateProps> = ({
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<HTMLDivElement>(null);
const {
openPremiumModal, closeGiftPremiumModal, openInvoice, requestConfetti,
} = getActions();
const lang = useLang();
const [selectedOption, setSelectedOption] = useState<number | undefined>();
const fullMonthlyAmount = useMemo(() => {
if (!gifts?.length) {
const [selectedMonthOption, setSelectedMonthOption] = useState<number | undefined>();
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<OwnProps & StateProps> = ({
}
return (
<p className={styles.premiumFeatures}>
<p className={buildClassName(styles.premiumFeatures, styles.center)}>
{parts[1]}
<Link isPrimary onClick={handlePremiumClick}>{parts[2]}</Link>
{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 (
<Modal
dialogRef={dialogRef}
onClose={closeGiftPremiumModal}
isOpen={isOpen}
className={styles.modalDialog}
@ -116,53 +225,53 @@ const GiftPremiumModal: FC<OwnProps & StateProps> = ({
>
<i className="icon icon-close" />
</Button>
<Avatar
peer={user}
size="jumbo"
className={styles.avatar}
/>
<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 className={styles.avatars}>
<AvatarList
size="large"
peers={giftingUserList}
/>
</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()}
</div>
<Button className={styles.button} isShiny disabled={!selectedOption} onClick={handleSubmit}>
{lang(
'GiftSubscriptionFor',
selectedGift && formatCurrency(Number(selectedGift.amount), selectedGift.currency, lang.code),
)}
</Button>
{!isCompleted && (
<Button withPremiumGradient className={styles.button} isShiny disabled={!selectedGift} onClick={handleSubmit}>
{lang(
'GiftSubscriptionFor', selectedGift
&& formatCurrency(selectedGift!.amount, selectedGift.currency, lang.code),
)}
</Button>
)}
</Modal>
);
};
export default memo(withGlobal<OwnProps>((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));

View File

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

View File

@ -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<OwnProps> = ({
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<OwnProps> = ({
/>
<div className={styles.content}>
<div className={styles.month}>
{Boolean(discount) && isGiveaway && (
{Boolean(discount) && (
<span
className={buildClassName(styles.giveawayDiscount, isGiveaway && styles.discount)}
className={buildClassName(styles.giveawayDiscount, styles.discount)}
title={lang('GiftDiscount')}
> &minus;{discount}%
</span>
@ -73,11 +74,11 @@ const PremiumSubscriptionOption: FC<OwnProps> = ({
{lang('Months', months)}
</div>
<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))}
</div>
<div className={styles.amount}>
{formatCurrency(isGiveaway ? amount * userCount! : amount, currency, lang.code)}
{formatCurrency(amount, currency, lang.code)}
</div>
</div>
</label>

View File

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

View File

@ -656,7 +656,7 @@ export default memo(withGlobal<OwnProps>(
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 {

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_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';

View File

@ -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<void> => {
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);

View File

@ -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.

View File

@ -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;