diff --git a/src/api/gramjs/apiBuilders/gifts.ts b/src/api/gramjs/apiBuilders/gifts.ts index e76166bbf..0b6d4843d 100644 --- a/src/api/gramjs/apiBuilders/gifts.ts +++ b/src/api/gramjs/apiBuilders/gifts.ts @@ -25,7 +25,7 @@ export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift { if (starGift instanceof GramJs.StarGiftUnique) { const { id, num, ownerId, ownerName, title, attributes, availabilityIssued, availabilityTotal, slug, ownerAddress, - giftAddress, resellAmount, releasedBy, resaleTonOnly, + giftAddress, resellAmount, releasedBy, resaleTonOnly, requirePremium, } = starGift; return { @@ -43,6 +43,7 @@ export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift { giftAddress, resellPrice: resellAmount && resellAmount.map((amount) => buildApiCurrencyAmount(amount)).filter(Boolean), releasedByPeerId: releasedBy && getApiChatIdFromMtpPeer(releasedBy), + requirePremium, resaleTonOnly, }; } @@ -50,6 +51,7 @@ export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift { const { id, limited, stars, availabilityRemains, availabilityTotal, convertStars, firstSaleDate, lastSaleDate, soldOut, birthday, upgradeStars, resellMinStars, title, availabilityResale, releasedBy, + requirePremium, limitedPerUser, perUserTotal, perUserRemains, } = starGift; addDocumentToLocalDb(starGift.sticker); @@ -74,6 +76,10 @@ export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift { resellMinStars: resellMinStars?.toJSNumber(), releasedByPeerId: releasedBy && getApiChatIdFromMtpPeer(releasedBy), availabilityResale: availabilityResale?.toJSNumber(), + requirePremium, + limitedPerUser, + perUserTotal, + perUserRemains, }; } diff --git a/src/api/types/stars.ts b/src/api/types/stars.ts index c35414683..4055ec8f6 100644 --- a/src/api/types/stars.ts +++ b/src/api/types/stars.ts @@ -22,6 +22,10 @@ export interface ApiStarGiftRegular { resellMinStars?: number; releasedByPeerId?: string; title?: string; + requirePremium?: true; + limitedPerUser?: true; + perUserTotal?: number; + perUserRemains?: number; } export interface ApiStarGiftUnique { @@ -39,6 +43,7 @@ export interface ApiStarGiftUnique { giftAddress?: string; resellPrice?: ApiTypeCurrencyAmount[]; releasedByPeerId?: string; + requirePremium?: true; resaleTonOnly?: true; } diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index ef276c22f..d9e9c318a 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -2153,6 +2153,10 @@ "TitleAgeCheckFailed" = "Age Check Failed"; "TitleAgeCheckSuccess" = "Age Check Success"; "ButtonAgeVerification" = "Verify My Age"; +"GiftRibbonPremium" = "premium"; +"NotificationGiftsLimit" = "You've already sent {count} of these gifts, and it's the limit."; +"PremiumGiftHeader" = "Premium Gift"; +"DescriptionGiftPremiumRequired" = "Subscribe to **Telegram Premium** to send up to **{count}** of these gifts and unlock access to multiple additional features."; "PriceInStars" = "Price in Stars"; "PriceInTON" = "Price in TON"; "DescriptionComposerGiftMinimumCurrencyPrice" = "Minimum price is **{amount}**."; @@ -2164,4 +2168,5 @@ "LabelPayInTON" = "Pay in TON"; "PriceChanged" = "Price Changed"; "PayNewPrice" = "Pay New Price"; -"PriceChangedText" = "The price has already changed from **{originalAmount}** to **{newAmount}**. Do you want to pay the new price?"; \ No newline at end of file +"PriceChangedText" = "The price has already changed from **{originalAmount}** to **{newAmount}**. Do you want to pay the new price?"; + diff --git a/src/components/common/gift/GiftRibbon.tsx b/src/components/common/gift/GiftRibbon.tsx index 27b3e0d7e..2a334f7e2 100644 --- a/src/components/common/gift/GiftRibbon.tsx +++ b/src/components/common/gift/GiftRibbon.tsx @@ -15,6 +15,7 @@ const COLORS = { blue: [['#6ED2FF', '#34A4FC'], ['#344F5A', '#152E42']], purple: [['#E367D7', '#757BF6'], ['#E367D7', '#757BF6']], green: [['#52D553', '#4BB121'], ['#52D553', '#4BB121']], + orange: [['#D48F23', '#BE7E15'], ['#D48F23', '#BE7E15']], } as const; type ColorKey = keyof typeof COLORS; diff --git a/src/components/main/premium/PremiumMainModal.module.scss b/src/components/main/premium/PremiumMainModal.module.scss index bfee93ce6..bd843ed04 100644 --- a/src/components/main/premium/PremiumMainModal.module.scss +++ b/src/components/main/premium/PremiumMainModal.module.scss @@ -50,6 +50,11 @@ margin: 1rem; } +.sticker-wrapper { + position: relative; + margin: 1rem; +} + .header-text { margin-inline: 0.5rem; font-size: 1.5rem; @@ -176,3 +181,16 @@ .subscriptionOption { margin: 0.8125rem; } + +.starParticlesHeader, +.giftParticlesHeader { + position: relative !important; +} + +.starParticlesHeader { + padding-top: 10rem !important; +} + +.giftParticlesHeader { + padding-top: 11rem !important; +} diff --git a/src/components/main/premium/PremiumMainModal.tsx b/src/components/main/premium/PremiumMainModal.tsx index 2f49579a5..081a8af06 100644 --- a/src/components/main/premium/PremiumMainModal.tsx +++ b/src/components/main/premium/PremiumMainModal.tsx @@ -6,6 +6,7 @@ import type { ApiPremiumPromo, ApiPremiumSection, ApiPremiumSubscriptionOption, + ApiStarGift, ApiSticker, ApiStickerSet, ApiUser, @@ -19,6 +20,7 @@ import { selectIsCurrentUserPremium, selectStickerSet, selectTabState, selectUse import { selectPremiumLimit } from '../../../global/selectors/limits'; import buildClassName from '../../../util/buildClassName'; import { formatCurrency } from '../../../util/formatCurrency'; +import { getStickerFromGift } from '../../common/helpers/gifts'; import { REM } from '../../common/helpers/mediaDimensions'; import renderText from '../../common/helpers/renderText'; import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities'; @@ -99,6 +101,7 @@ type StateProps = { isSuccess?: boolean; isGift?: boolean; monthsAmount?: number; + gift?: ApiStarGift; limitChannels: number; limitPins: number; limitLinks: number; @@ -130,6 +133,7 @@ const PremiumMainModal: FC = ({ toUser, monthsAmount, premiumPromoOrder, + gift, }) => { const dialogRef = useRef(); const { @@ -273,6 +277,10 @@ const PremiumMainModal: FC = ({ if (!promo || (fromUserStatusEmoji && !fromUserStatusSet)) return undefined; function getHeaderText() { + if (gift) { + return lang('PremiumGiftHeader'); + } + if (isGift) { return renderText( fromUser?.id === currentUserId @@ -307,6 +315,11 @@ const PremiumMainModal: FC = ({ } function getHeaderDescription() { + if (gift) { + const perUserTotal = gift.type !== 'starGiftUnique' ? gift.perUserTotal : 0; + return lang('DescriptionGiftPremiumRequired', { count: perUserTotal }); + } + if (isGift) { return fromUser?.id === currentUserId ? oldLang('TelegramPremiumUserGiftedPremiumOutboundDialogSubtitle', getUserFullName(toUser)) @@ -322,6 +335,52 @@ const PremiumMainModal: FC = ({ : oldLang(isPremium ? 'TelegramPremiumSubscribedSubtitle' : 'TelegramPremiumSubtitle'); } + function renderHeader() { + if (gift) { + const giftSticker = getStickerFromGift(gift); + return ( + + ); + } + + if (!fromUserStatusEmoji) { + return ( + + ); + } + + return ( + <> + +

+ {getHeaderText()} +

+
+ {renderText(getHeaderDescription(), ['simple_markdown', 'emoji'])} +
+ + ); + } + function renderFooterText() { if (!promo || (isGift && fromUser?.id === currentUserId)) { return undefined; @@ -375,30 +434,7 @@ const PremiumMainModal: FC = ({ > - {!fromUserStatusEmoji ? ( - - ) : ( - <> - -

- {getHeaderText()} -

-
- {renderText(getHeaderDescription(), ['simple_markdown', 'emoji'])} -
- - )} + {renderHeader()} {!isPremium && !isGift && renderSubscriptionOptions()}

@@ -483,6 +519,7 @@ export default memo(withGlobal((global): StateProps => { isSuccess: premiumModal?.isSuccess, isGift: premiumModal?.isGift, monthsAmount: premiumModal?.monthsAmount, + gift: premiumModal?.gift, fromUser, fromUserStatusEmoji, fromUserStatusSet, diff --git a/src/components/modals/common/ParticlesHeader.module.scss b/src/components/modals/common/ParticlesHeader.module.scss index fac56c93c..3b940028c 100644 --- a/src/components/modals/common/ParticlesHeader.module.scss +++ b/src/components/modals/common/ParticlesHeader.module.scss @@ -30,3 +30,14 @@ text-align: center; text-wrap: balance; } + +.stickerWrapper { + position: absolute; + z-index: 1; + top: 2rem; + transition: transform 0.25s ease-out; + + &:hover { + transform: scale(1.1); + } +} diff --git a/src/components/modals/common/ParticlesHeader.tsx b/src/components/modals/common/ParticlesHeader.tsx index ce4b8613c..233b1c257 100644 --- a/src/components/modals/common/ParticlesHeader.tsx +++ b/src/components/modals/common/ParticlesHeader.tsx @@ -1,35 +1,47 @@ import type { TeactNode } from '@teact'; import { memo, useLayoutEffect, useRef } from '@teact'; +import type { ApiSticker } from '../../../api/types'; + +import buildClassName from '../../../util/buildClassName'; import { PARTICLE_BURST_PARAMS, PARTICLE_COLORS, setupParticles } from '../../../util/particles.ts'; +import { REM } from '../../common/helpers/mediaDimensions'; import useLastCallback from '../../../hooks/useLastCallback.ts'; +import StickerView from '../../common/StickerView'; import SpeedingDiamond from './SpeedingDiamond.tsx'; import SwayingStar from './SwayingStar.tsx'; import styles from './ParticlesHeader.module.scss'; interface OwnProps { - model: 'swaying-star' | 'speeding-diamond'; + model: 'swaying-star' | 'speeding-diamond' | 'sticker'; + sticker?: ApiSticker; color: 'purple' | 'gold' | 'blue'; title: TeactNode; description: TeactNode; isDisabled?: boolean; + className?: string; } +const GIFT_STICKER_SIZE = 8 * REM; + const PARTICLE_PARAMS = { centerShift: [0, -36] as const, }; function ParticlesHeader({ model, + sticker, color, title, description, isDisabled, + className, }: OwnProps) { const canvasRef = useRef(); + const stickerRef = useRef(); useLayoutEffect(() => { if (isDisabled) return undefined; @@ -49,7 +61,7 @@ function ParticlesHeader({ }); return ( -
+
{model === 'swaying-star' ? ( @@ -58,8 +70,23 @@ function ParticlesHeader({ centerShift={PARTICLE_PARAMS.centerShift} onMouseMove={handleMouseMove} /> - ) : model === 'speeding-diamond' && ( + ) : model === 'speeding-diamond' ? ( + ) : model === 'sticker' && sticker && ( +
+ +
)}

diff --git a/src/components/modals/gift/GiftItem.module.scss b/src/components/modals/gift/GiftItem.module.scss index e613b61d6..04f1a3b92 100644 --- a/src/components/modals/gift/GiftItem.module.scss +++ b/src/components/modals/gift/GiftItem.module.scss @@ -75,6 +75,16 @@ font-size: 0.75rem !important; } +.premiumRequired { + outline: 0.125rem solid #D18D21; + outline-offset: -0.125rem; + + &:focus-visible { + outline: 0.125rem solid #D18D21; + outline-offset: -0.125rem; + } +} + .amount { margin-top: 0.0625rem; // It just refuses to be centered } diff --git a/src/components/modals/gift/GiftItemStar.tsx b/src/components/modals/gift/GiftItemStar.tsx index 92172ab48..07e81674a 100644 --- a/src/components/modals/gift/GiftItemStar.tsx +++ b/src/components/modals/gift/GiftItemStar.tsx @@ -1,5 +1,5 @@ import { memo, useMemo, useRef, useState } from '../../../lib/teact/teact'; -import { getActions } from '../../../global'; +import { getActions, withGlobal } from '../../../global'; import type { ApiStarGift, @@ -7,6 +7,7 @@ import type { } from '../../../api/types'; import { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../../../config'; +import { selectIsCurrentUserPremium } from '../../../global/selectors'; import buildClassName from '../../../util/buildClassName'; import { formatStarsAsIcon, formatTonAsIcon } from '../../../util/localization/format'; import { getStickerFromGift } from '../../common/helpers/gifts'; @@ -30,12 +31,16 @@ export type OwnProps = { isResale?: boolean; }; +type StateProps = { + isCurrentUserPremium?: boolean; +}; + const GIFT_STICKER_SIZE = 90; function GiftItemStar({ - gift, observeIntersection, onClick, isResale, -}: OwnProps) { - const { openGiftInfoModal } = getActions(); + gift, observeIntersection, onClick, isResale, isCurrentUserPremium, +}: OwnProps & StateProps) { + const { openGiftInfoModal, openPremiumModal, showNotification } = getActions(); const ref = useRef(); const stickerRef = useRef(); @@ -69,6 +74,9 @@ function GiftItemStar({ ? lang.number(resellMinStars) + '+' : priceInfo?.amount || 0; const isLimited = !isGiftUnique && Boolean(regularGift?.isLimited); const isSoldOut = !isGiftUnique && Boolean(regularGift?.isSoldOut); + const isPremiumRequired = Boolean(gift?.requirePremium); + const isUserLimitReached = Boolean(regularGift?.limitedPerUser && !regularGift?.perUserRemains); + const perUserTotal = regularGift?.perUserTotal; const handleGiftClick = useLastCallback(() => { if (isSoldOut && !isResale) { @@ -76,6 +84,25 @@ function GiftItemStar({ return; } + if (isUserLimitReached) { + showNotification({ + message: lang('NotificationGiftsLimit', { + count: perUserTotal, + }, { + withMarkdown: true, + withNodes: true, + }), + }); + return; + } + + if (isPremiumRequired && !isCurrentUserPremium) { + openPremiumModal({ + gift, + }); + return; + } + onClick(gift, isResale ? 'resell' : 'original'); }); @@ -116,6 +143,9 @@ function GiftItemStar({ /> ); } + if (isPremiumRequired) { + return ; + } if (isResale) { return ; } @@ -126,7 +156,7 @@ function GiftItemStar({ return ; } return undefined; - }, [isGiftUnique, isResale, gift, isSoldOut, isLimited, lang, giftNumber]); + }, [isGiftUnique, isResale, gift, isSoldOut, isLimited, lang, giftNumber, isPremiumRequired]); useOnIntersect(ref, observeIntersection, (entry) => { const visible = entry.isIntersecting; @@ -136,7 +166,12 @@ function GiftItemStar({ return (
((global): StateProps => { + const isCurrentUserPremium = selectIsCurrentUserPremium(global); + + return { + isCurrentUserPremium, + }; + })(GiftItemStar), +); diff --git a/src/global/actions/api/payments.ts b/src/global/actions/api/payments.ts index e3ed051e1..53fd31940 100644 --- a/src/global/actions/api/payments.ts +++ b/src/global/actions/api/payments.ts @@ -484,7 +484,7 @@ addActionHandler('closePremiumModal', (global, actions, payload): ActionReturnTy addActionHandler('openPremiumModal', async (global, actions, payload): Promise => { const { - initialSection, fromUserId, isSuccess, isGift, monthsAmount, toUserId, + initialSection, fromUserId, isSuccess, isGift, monthsAmount, toUserId, gift, tabId = getCurrentTabId(), } = payload || {}; @@ -505,6 +505,7 @@ addActionHandler('openPremiumModal', async (global, actions, payload): Promise { 'ButtonSensitiveAlways': { 'years': V; }; + 'NotificationGiftsLimit': { + 'count': V; + }; + 'DescriptionGiftPremiumRequired': { + 'count': V; + }; 'DescriptionComposerGiftMinimumCurrencyPrice': { 'amount': V; };