359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
import type { FC } from '../../../lib/teact/teact';
|
|
import type React from '../../../lib/teact/teact';
|
|
import {
|
|
memo, useEffect, useMemo, useRef, useState,
|
|
} from '../../../lib/teact/teact';
|
|
import { toggleExtraClass } from '../../../lib/teact/teact-dom';
|
|
|
|
import type {
|
|
ApiLimitTypeForPromo,
|
|
ApiPremiumPromo,
|
|
ApiPremiumSection,
|
|
ApiPremiumSubscriptionOption,
|
|
} from '../../../api/types';
|
|
import type { GlobalState } from '../../../global/types';
|
|
import type { LangPair } from '../../../types/language';
|
|
|
|
import { PREMIUM_BOTTOM_VIDEOS, PREMIUM_FEATURE_SECTIONS, PREMIUM_LIMITS_ORDER } from '../../../config';
|
|
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
|
|
import animateHorizontalScroll from '../../../util/animateHorizontalScroll';
|
|
import buildClassName from '../../../util/buildClassName';
|
|
import { formatCurrency } from '../../../util/formatCurrency';
|
|
import renderText from '../../common/helpers/renderText';
|
|
|
|
import useFlag from '../../../hooks/useFlag';
|
|
import useLang from '../../../hooks/useLang';
|
|
import useLastCallback from '../../../hooks/useLastCallback';
|
|
import useOldLang from '../../../hooks/useOldLang';
|
|
import usePreviousDeprecated from '../../../hooks/usePreviousDeprecated';
|
|
|
|
import SliderDots from '../../common/SliderDots';
|
|
import Button from '../../ui/Button';
|
|
import PremiumLimitPreview from './common/PremiumLimitPreview';
|
|
import PremiumFeaturePreviewStickers from './previews/PremiumFeaturePreviewStickers';
|
|
import PremiumFeaturePreviewStories from './previews/PremiumFeaturePreviewStories';
|
|
import PremiumFeaturePreviewVideo from './previews/PremiumFeaturePreviewVideo';
|
|
|
|
import styles from './PremiumFeatureModal.module.scss';
|
|
|
|
export const PREMIUM_FEATURE_TITLES: Record<ApiPremiumSection, string> = {
|
|
double_limits: 'PremiumPreviewLimits',
|
|
infinite_reactions: 'PremiumPreviewReactions2',
|
|
premium_stickers: 'PremiumPreviewStickers',
|
|
animated_emoji: 'PremiumPreviewEmoji',
|
|
no_ads: 'PremiumPreviewNoAds',
|
|
voice_to_text: 'PremiumPreviewVoiceToText',
|
|
profile_badge: 'PremiumPreviewProfileBadge',
|
|
faster_download: 'PremiumPreviewDownloadSpeed',
|
|
more_upload: 'PremiumPreviewUploads',
|
|
advanced_chat_management: 'PremiumPreviewAdvancedChatManagement',
|
|
animated_userpics: 'PremiumPreviewAnimatedProfiles',
|
|
emoji_status: 'PremiumPreviewEmojiStatus',
|
|
translations: 'PremiumPreviewTranslations',
|
|
stories: 'PremiumPreviewStories',
|
|
saved_tags: 'PremiumPreviewTags2',
|
|
last_seen: 'PremiumPreviewLastSeen',
|
|
message_privacy: 'PremiumPreviewMessagePrivacy',
|
|
effects: 'Premium.MessageEffects',
|
|
todo: 'PremiumPreviewTodo',
|
|
};
|
|
|
|
export const PREMIUM_FEATURE_DESCRIPTIONS: Record<ApiPremiumSection, string> = {
|
|
double_limits: 'PremiumPreviewLimitsDescription',
|
|
infinite_reactions: 'PremiumPreviewReactions2Description',
|
|
premium_stickers: 'PremiumPreviewStickersDescription',
|
|
no_ads: 'PremiumPreviewNoAdsDescription',
|
|
animated_emoji: 'PremiumPreviewEmojiDescription',
|
|
voice_to_text: 'PremiumPreviewVoiceToTextDescription',
|
|
profile_badge: 'PremiumPreviewProfileBadgeDescription',
|
|
faster_download: 'PremiumPreviewDownloadSpeedDescription',
|
|
more_upload: 'PremiumPreviewUploadsDescription',
|
|
advanced_chat_management: 'PremiumPreviewAdvancedChatManagementDescription',
|
|
animated_userpics: 'PremiumPreviewAnimatedProfilesDescription',
|
|
emoji_status: 'PremiumPreviewEmojiStatusDescription',
|
|
translations: 'PremiumPreviewTranslationsDescription',
|
|
stories: 'PremiumPreviewStoriesDescription',
|
|
saved_tags: 'PremiumPreviewTagsDescription2',
|
|
last_seen: 'PremiumPreviewLastSeenDescription',
|
|
message_privacy: 'PremiumPreviewMessagePrivacyDescription',
|
|
effects: 'Premium.MessageEffectsInfo',
|
|
todo: 'PremiumPreviewTodoDescription',
|
|
};
|
|
|
|
const LIMITS_TITLES: Record<ApiLimitTypeForPromo, string> = {
|
|
channels: 'GroupsAndChannelsLimitTitle',
|
|
dialogFolderPinned: 'PinChatsLimitTitle',
|
|
channelsPublic: 'PublicLinksLimitTitle',
|
|
savedGifs: 'SavedGifsLimitTitle',
|
|
stickersFaved: 'FavoriteStickersLimitTitle',
|
|
aboutLength: 'BioLimitTitle',
|
|
captionLength: 'CaptionsLimitTitle',
|
|
dialogFilters: 'FoldersLimitTitle',
|
|
dialogFiltersChats: 'ChatPerFolderLimitTitle',
|
|
recommendedChannels: 'SimilarChannelsLimitTitle',
|
|
moreAccounts: 'ConnectedAccountsLimitTitle',
|
|
};
|
|
|
|
const LIMITS_DESCRIPTIONS: Record<ApiLimitTypeForPromo, string> = {
|
|
channels: 'GroupsAndChannelsLimitSubtitle',
|
|
dialogFolderPinned: 'PinChatsLimitSubtitle',
|
|
channelsPublic: 'PublicLinksLimitSubtitle',
|
|
savedGifs: 'SavedGifsLimitSubtitle',
|
|
stickersFaved: 'FavoriteStickersLimitSubtitle',
|
|
aboutLength: 'BioLimitSubtitle',
|
|
captionLength: 'CaptionsLimitSubtitle',
|
|
dialogFilters: 'FoldersLimitSubtitle',
|
|
dialogFiltersChats: 'ChatPerFolderLimitSubtitle',
|
|
recommendedChannels: 'SimilarChannelsLimitSubtitle',
|
|
moreAccounts: 'ConnectedAccountsLimitSubtitle',
|
|
};
|
|
|
|
const BORDER_THRESHOLD = 20;
|
|
|
|
type OwnProps = {
|
|
initialSection: ApiPremiumSection;
|
|
promo: ApiPremiumPromo;
|
|
isPremium?: boolean;
|
|
limits?: NonNullable<GlobalState['appConfig']>['limits'];
|
|
premiumPromoOrder?: ApiPremiumSection[];
|
|
subscriptionOption?: ApiPremiumSubscriptionOption;
|
|
onBack: VoidFunction;
|
|
onClickSubscribe: (startParam?: string) => void;
|
|
};
|
|
|
|
const PremiumFeatureModal: FC<OwnProps> = ({
|
|
promo,
|
|
initialSection,
|
|
isPremium,
|
|
limits,
|
|
premiumPromoOrder,
|
|
subscriptionOption,
|
|
onBack,
|
|
onClickSubscribe,
|
|
}) => {
|
|
const oldLang = useOldLang();
|
|
const lang = useLang();
|
|
const scrollContainerRef = useRef<HTMLDivElement>();
|
|
const [currentSlideIndex, setCurrentSlideIndex] = useState(PREMIUM_FEATURE_SECTIONS.indexOf(initialSection));
|
|
const [reverseAnimationSlideIndex, setReverseAnimationSlideIndex] = useState(0);
|
|
const [isScrolling, startScrolling, stopScrolling] = useFlag();
|
|
|
|
const [isScrolledToTop, setIsScrolledToTop] = useState(true);
|
|
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
|
|
|
|
const prevInitialSection = usePreviousDeprecated(initialSection);
|
|
|
|
const filteredSections = useMemo(() => {
|
|
if (!premiumPromoOrder) return PREMIUM_FEATURE_SECTIONS;
|
|
return premiumPromoOrder.filter((section) => PREMIUM_FEATURE_SECTIONS.includes(section));
|
|
}, [premiumPromoOrder]);
|
|
|
|
const subscriptionButtonText = useMemo(() => {
|
|
if (!subscriptionOption) return undefined;
|
|
|
|
const { amount, months, currency } = subscriptionOption;
|
|
const perMonthPrice = Math.floor(amount / months);
|
|
|
|
return isPremium
|
|
? lang('OK')
|
|
: lang('SubscribeToPremium', { price: formatCurrency(lang, perMonthPrice, currency) }, { withNodes: true });
|
|
}, [isPremium, lang, subscriptionOption]);
|
|
|
|
const handleClick = useLastCallback(() => {
|
|
onClickSubscribe(initialSection);
|
|
});
|
|
|
|
function handleScroll(e: React.UIEvent<HTMLDivElement>) {
|
|
const target = e.currentTarget;
|
|
const { clientWidth, scrollLeft: scrollLeftOriginal } = target;
|
|
|
|
const scrollLeft = Math.round(scrollLeftOriginal);
|
|
|
|
const left = scrollLeft % (clientWidth);
|
|
const progress = left / (clientWidth);
|
|
|
|
const reverseIndex = Math.ceil((scrollLeft + 1) / clientWidth);
|
|
|
|
setReverseAnimationSlideIndex(reverseIndex);
|
|
|
|
const prevElement = target.querySelector<HTMLDivElement>(`#premium_feature_preview_video_${reverseIndex - 1}`);
|
|
const reverseElement = target.querySelector<HTMLDivElement>(`#premium_feature_preview_video_${reverseIndex}`);
|
|
|
|
requestMutation(() => {
|
|
target.style.setProperty('--scroll-progress', progress.toString());
|
|
target.style.setProperty('--abs-scroll-progress', Math.abs(progress).toString());
|
|
|
|
if (prevElement) toggleExtraClass(prevElement, 'reverse', false);
|
|
if (reverseElement) toggleExtraClass(reverseElement, 'reverse', true);
|
|
});
|
|
|
|
if (isScrolling) return;
|
|
const slide = Math.round(scrollLeft / clientWidth);
|
|
setCurrentSlideIndex(slide);
|
|
}
|
|
|
|
function handleLimitsScroll(e: React.UIEvent<HTMLDivElement>) {
|
|
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget;
|
|
setIsScrolledToTop(scrollTop <= BORDER_THRESHOLD);
|
|
setIsScrolledToBottom(scrollTop >= scrollHeight - clientHeight - BORDER_THRESHOLD);
|
|
}
|
|
|
|
useEffect(() => {
|
|
const scrollContainer = scrollContainerRef.current;
|
|
if (!scrollContainer || (prevInitialSection === initialSection)) return;
|
|
|
|
const index = filteredSections.indexOf(initialSection);
|
|
setCurrentSlideIndex(index);
|
|
startScrolling();
|
|
animateHorizontalScroll(scrollContainer, scrollContainer.clientWidth * index, 0)
|
|
.then(stopScrolling);
|
|
}, [currentSlideIndex, filteredSections, initialSection, prevInitialSection]);
|
|
|
|
const handleSelectSlide = useLastCallback(async (index: number) => {
|
|
const scrollContainer = scrollContainerRef.current;
|
|
if (!scrollContainer) return;
|
|
|
|
setCurrentSlideIndex(index);
|
|
|
|
startScrolling();
|
|
await animateHorizontalScroll(scrollContainer, scrollContainer.clientWidth * index, 800);
|
|
stopScrolling();
|
|
});
|
|
|
|
const currentSection = filteredSections[currentSlideIndex];
|
|
const hasHeaderBackdrop = currentSection !== 'double_limits' && currentSection !== 'stories';
|
|
|
|
return (
|
|
<div className={styles.root}>
|
|
<Button
|
|
round
|
|
size="tiny"
|
|
className={buildClassName(styles.backButton, hasHeaderBackdrop && styles.whiteBackButton)}
|
|
color={hasHeaderBackdrop ? 'translucent-white' : 'translucent'}
|
|
onClick={onBack}
|
|
ariaLabel={oldLang('Back')}
|
|
iconName="arrow-left"
|
|
/>
|
|
|
|
<div className={styles.preview} />
|
|
|
|
<div className={buildClassName(styles.content, 'no-scrollbar')} onScroll={handleScroll} ref={scrollContainerRef}>
|
|
|
|
{filteredSections.map((section, index) => {
|
|
if (section === 'double_limits') {
|
|
return (
|
|
<div className={buildClassName(styles.slide, styles.limits)}>
|
|
<h2 className={buildClassName(styles.header, isScrolledToTop && styles.noHeaderBorder)}>
|
|
{oldLang(PREMIUM_FEATURE_TITLES.double_limits)}
|
|
</h2>
|
|
<div className={buildClassName(styles.limitsContent, 'custom-scroll')} onScroll={handleLimitsScroll}>
|
|
{PREMIUM_LIMITS_ORDER.map((limit, i) => {
|
|
const defaultLimit = limits?.[limit][0].toString();
|
|
const premiumLimit = limits?.[limit][1].toString();
|
|
return (
|
|
<PremiumLimitPreview
|
|
title={oldLang(LIMITS_TITLES[limit])}
|
|
description={oldLang(LIMITS_DESCRIPTIONS[limit], premiumLimit)}
|
|
leftValue={defaultLimit}
|
|
rightValue={premiumLimit}
|
|
colorStepProgress={i / (PREMIUM_LIMITS_ORDER.length - 1)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (section === 'premium_stickers') {
|
|
return (
|
|
<div className={styles.slide}>
|
|
<div className={styles.frame}>
|
|
<PremiumFeaturePreviewStickers isActive={currentSlideIndex === index} />
|
|
</div>
|
|
<h1 className={styles.title}>
|
|
{oldLang(PREMIUM_FEATURE_TITLES.premium_stickers)}
|
|
</h1>
|
|
<div className={styles.description}>
|
|
{renderText(oldLang(PREMIUM_FEATURE_DESCRIPTIONS.premium_stickers), ['br'])}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (section === 'stories') {
|
|
return (
|
|
<div className={buildClassName(styles.slide, styles.stories)}>
|
|
<PremiumFeaturePreviewStories />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const i = promo.videoSections.indexOf(section);
|
|
if (i === -1) return undefined;
|
|
const shouldUseNewLang = promo.videoSections[i] === 'todo';
|
|
return (
|
|
<div className={styles.slide}>
|
|
<div className={styles.frame}>
|
|
<PremiumFeaturePreviewVideo
|
|
isActive={currentSlideIndex === index}
|
|
videoId={promo.videos[i].id!}
|
|
videoThumbnail={promo.videos[i].thumbnail!}
|
|
isDown={PREMIUM_BOTTOM_VIDEOS.includes(section)}
|
|
index={index}
|
|
isReverseAnimation={index === reverseAnimationSlideIndex}
|
|
/>
|
|
</div>
|
|
<h1 className={styles.title}>
|
|
{shouldUseNewLang
|
|
? lang(
|
|
PREMIUM_FEATURE_TITLES[promo.videoSections[i]] as keyof LangPair,
|
|
undefined,
|
|
{ withNodes: true, renderTextFilters: ['br'] },
|
|
)
|
|
: oldLang(PREMIUM_FEATURE_TITLES[promo.videoSections[i]])}
|
|
</h1>
|
|
<div className={styles.description}>
|
|
{renderText(shouldUseNewLang
|
|
? lang(
|
|
PREMIUM_FEATURE_DESCRIPTIONS[promo.videoSections[i]] as keyof LangPair,
|
|
undefined,
|
|
{ withNodes: true, renderTextFilters: ['br'] },
|
|
)
|
|
: oldLang(PREMIUM_FEATURE_DESCRIPTIONS[promo.videoSections[i]]), ['br'],
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
</div>
|
|
|
|
<div
|
|
className={buildClassName(
|
|
styles.footer,
|
|
(isScrolledToBottom || currentSlideIndex !== 0) && styles.noFooterBorder,
|
|
)}
|
|
>
|
|
<SliderDots
|
|
length={PREMIUM_FEATURE_SECTIONS.length}
|
|
active={currentSlideIndex}
|
|
onSelectSlide={handleSelectSlide}
|
|
/>
|
|
{Boolean(subscriptionButtonText) && (
|
|
<Button
|
|
className={buildClassName(styles.button)}
|
|
isShiny={!isPremium}
|
|
withPremiumGradient={!isPremium}
|
|
onClick={isPremium ? onBack : handleClick}
|
|
>
|
|
{subscriptionButtonText}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default memo(PremiumFeatureModal);
|