diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index 6542fbaf6..5921916e9 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -52,6 +52,8 @@ export interface GramJsAppConfig extends LimitsConfig { autologin_token: string; url_auth_domains: string[]; premium_purchase_blocked: boolean; + giveaway_gifts_purchase_available: boolean; + giveaway_add_peers_max: number; premium_bot_username: string; premium_invoice_slug: string; premium_promo_order: string[]; @@ -59,6 +61,8 @@ export interface GramJsAppConfig extends LimitsConfig { hidden_members_group_size_min: number; autoarchive_setting_available: boolean; authorization_autoconfirm_period: number; + giveaway_boosts_per_premium: number; + giveaway_countries_max: number; // Forums topics_pinned_limit: number; // Stories @@ -109,11 +113,15 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp premiumInvoiceSlug: appConfig.premium_invoice_slug, premiumPromoOrder: appConfig.premium_promo_order as ApiPremiumSection[], isPremiumPurchaseBlocked: appConfig.premium_purchase_blocked, + isGiveawayGiftsPurchaseAvailable: appConfig.giveaway_gifts_purchase_available, defaultEmojiStatusesStickerSetId: appConfig.default_emoji_statuses_stickerset_id, topicsPinnedLimit: appConfig.topics_pinned_limit, maxUserReactionsDefault: appConfig.reactions_user_max_default, maxUserReactionsPremium: appConfig.reactions_user_max_premium, hiddenMembersMinCount: appConfig.hidden_members_group_size_min, + giveawayAddPeersMax: appConfig.giveaway_add_peers_max, + giveawayBoostsPerPremium: appConfig.giveaway_boosts_per_premium, + giveawayCountriesMax: appConfig.giveaway_countries_max, canDisplayAutoarchiveSetting: appConfig.autoarchive_setting_available, limits: { uploadMaxFileparts: getLimit(appConfig, 'upload_max_fileparts', 'uploadMaxFileparts'), diff --git a/src/api/gramjs/apiBuilders/payments.ts b/src/api/gramjs/apiBuilders/payments.ts index 5044d9f7b..679ce7eb5 100644 --- a/src/api/gramjs/apiBuilders/payments.ts +++ b/src/api/gramjs/apiBuilders/payments.ts @@ -6,7 +6,7 @@ import type { ApiCheckedGiftCode, ApiGiveawayInfo, ApiInvoice, ApiLabeledPrice, ApiMyBoost, ApiPaymentCredentials, - ApiPaymentForm, ApiPaymentSavedInfo, ApiPremiumPromo, ApiPremiumSubscriptionOption, + ApiPaymentForm, ApiPaymentSavedInfo, ApiPremiumGiftCodeOption, ApiPremiumPromo, ApiPremiumSubscriptionOption, ApiReceipt, } from '../../types'; @@ -14,7 +14,7 @@ import { buildApiMessageEntity } from './common'; import { omitVirtualClassFields } from './helpers'; import { buildApiDocument, buildApiWebDocument } from './messageContent'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; -import { buildStatisticsPercentage } from './statistics'; +import { buildPrepaidGiveaway, buildStatisticsPercentage } from './statistics'; export function buildShippingOptions(shippingOptions: GramJs.ShippingOption[] | undefined) { if (!shippingOptions) { @@ -209,7 +209,7 @@ export function buildApiPaymentCredentials(credentials: GramJs.PaymentSavedCrede export function buildApiBoostsStatus(boostStatus: GramJs.premium.BoostsStatus): ApiBoostsStatus { const { - level, boostUrl, boosts, myBoost, currentLevelBoosts, nextLevelBoosts, premiumAudience, + level, boostUrl, boosts, myBoost, currentLevelBoosts, nextLevelBoosts, premiumAudience, prepaidGiveaways, } = boostStatus; return { level, @@ -219,6 +219,7 @@ export function buildApiBoostsStatus(boostStatus: GramJs.premium.BoostsStatus): boostUrl, nextLevelBoosts, ...(premiumAudience && { premiumSubscribers: buildStatisticsPercentage(premiumAudience) }), + ...(prepaidGiveaways && { prepaidGiveaways: prepaidGiveaways.map(buildPrepaidGiveaway) }), }; } @@ -295,3 +296,16 @@ export function buildApiCheckedGiftCode(giftcode: GramJs.payments.TypeCheckedGif giveawayMessageId: giveawayMsgId, }; } + +export function buildApiPremiumGiftCodeOption(option: GramJs.PremiumGiftCodeOption): ApiPremiumGiftCodeOption { + const { + amount, currency, months, users, + } = option; + + return { + amount: amount.toJSNumber(), + currency, + months, + users, + }; +} diff --git a/src/api/gramjs/apiBuilders/statistics.ts b/src/api/gramjs/apiBuilders/statistics.ts index d9977974c..7bd992a35 100644 --- a/src/api/gramjs/apiBuilders/statistics.ts +++ b/src/api/gramjs/apiBuilders/statistics.ts @@ -6,7 +6,7 @@ import type { ApiMessagePublicForward, ApiPostStatistics, ApiStoryPublicForward, - StatisticsGraph, + PrepaidGiveaway, StatisticsGraph, StatisticsMessageInteractionCounter, StatisticsOverviewItem, StatisticsOverviewPercentage, @@ -214,6 +214,15 @@ export function buildStatisticsPercentage(data: GramJs.StatsPercentValue): Stati }; } +export function buildPrepaidGiveaway(prepaidGiveaway: GramJs.PrepaidGiveaway): PrepaidGiveaway { + return { + id: prepaidGiveaway.id.toString(), + date: prepaidGiveaway.date, + months: prepaidGiveaway.months, + quantity: prepaidGiveaway.quantity, + }; +} + function getOverviewPeriod(data: GramJs.StatsDateRangeDays): StatisticsOverviewPeriod { return { maxDate: data.maxDate, diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index 0a8cd4a6b..6b9dfc860 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -12,12 +12,13 @@ import type { ApiFormattedText, ApiGroupCall, ApiInputReplyInfo, + ApiInputStorePaymentPurpose, ApiMessageEntity, ApiNewPoll, ApiPhoneCall, ApiPhoto, ApiPoll, - ApiReaction, + ApiPremiumGiftCodeOption, ApiReaction, ApiReportReason, ApiRequestInputInvoice, ApiSendMessageAction, @@ -566,16 +567,69 @@ export function buildInputPhoneCall({ id, accessHash }: ApiPhoneCall) { }); } +export function buildInputStorePaymentPurpose(purpose: ApiInputStorePaymentPurpose): +GramJs.TypeInputStorePaymentPurpose { + if (purpose.type === 'giftcode') { + return new GramJs.InputStorePaymentPremiumGiftCode({ + users: purpose.users.map((user) => buildInputEntity(user.id, user.accessHash) as GramJs.InputUser), + boostPeer: purpose.boostChannel + ? buildInputPeer(purpose.boostChannel.id, purpose.boostChannel.accessHash) + : undefined, + currency: purpose.currency, + amount: BigInt(purpose.amount), + }); + } + + const randomId = generateRandomBigInt(); + + return new GramJs.InputStorePaymentPremiumGiveaway({ + boostPeer: buildInputPeer(purpose.chat.id, purpose.chat.accessHash), + additionalPeers: purpose.additionalChannels?.map((chat) => buildInputPeer(chat.id, chat.accessHash)), + countriesIso2: purpose.countries, + prizeDescription: purpose.prizeDescription, + onlyNewSubscribers: purpose.isOnlyForNewSubscribers || undefined, + winnersAreVisible: purpose.areWinnersVisible || undefined, + untilDate: purpose.untilDate, + currency: purpose.currency, + amount: BigInt(purpose.amount), + randomId, + }); +} + +function buildPremiumGiftCodeOption(optionData: ApiPremiumGiftCodeOption) { + return new GramJs.PremiumGiftCodeOption({ + users: optionData.users, + months: optionData.months, + currency: optionData.currency, + amount: BigInt(optionData.amount), + }); +} + export function buildInputInvoice(invoice: ApiRequestInputInvoice) { - if ('slug' in invoice) { - return new GramJs.InputInvoiceSlug({ - slug: invoice.slug, - }); - } else { - return new GramJs.InputInvoiceMessage({ - peer: buildInputPeer(invoice.chat.id, invoice.chat.accessHash), - msgId: invoice.messageId, - }); + switch (invoice.type) { + case 'message': { + return new GramJs.InputInvoiceMessage({ + peer: buildInputPeer(invoice.chat.id, invoice.chat.accessHash), + msgId: invoice.messageId, + }); + } + + case 'slug': { + return new GramJs.InputInvoiceSlug({ + slug: invoice.slug, + }); + } + + case 'giveaway': + default: { + const purpose = buildInputStorePaymentPurpose(invoice.purpose); + const option = buildPremiumGiftCodeOption(invoice.option); + + return new GramJs.InputInvoicePremiumGiftCode({ + purpose, + option, + }); + } } } diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index a6453a0f0..0be1d0775 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -101,5 +101,6 @@ export * from './stories'; export { validateRequestedInfo, sendPaymentForm, getPaymentForm, getReceipt, fetchPremiumPromo, fetchTemporaryPaymentPassword, - applyBoost, fetchBoostsList, fetchBoostsStatus, fetchGiveawayInfo, fetchMyBoosts, applyGiftCode, checkGiftCode, + applyBoost, fetchBoostList, fetchBoostStatus, fetchGiveawayInfo, fetchMyBoosts, applyGiftCode, checkGiftCode, + getPremiumGiftCodeOptions, launchPrepaidGiveaway, } from './payments'; diff --git a/src/api/gramjs/methods/payments.ts b/src/api/gramjs/methods/payments.ts index 96d524ab6..f08e43507 100644 --- a/src/api/gramjs/methods/payments.ts +++ b/src/api/gramjs/methods/payments.ts @@ -2,7 +2,7 @@ import BigInt from 'big-integer'; import { Api as GramJs } from '../../../lib/gramjs'; import type { - ApiChat, ApiPeer, ApiRequestInputInvoice, + ApiChat, ApiInputStorePaymentPurpose, ApiPeer, ApiRequestInputInvoice, OnApiUpdate, } from '../../types'; @@ -15,12 +15,15 @@ import { buildApiInvoiceFromForm, buildApiMyBoost, buildApiPaymentForm, + buildApiPremiumGiftCodeOption, buildApiPremiumPromo, buildApiReceipt, buildShippingOptions, } from '../apiBuilders/payments'; import { buildApiUser } from '../apiBuilders/users'; -import { buildInputInvoice, buildInputPeer, buildShippingInfo } from '../gramjsBuilders'; +import { + buildInputInvoice, buildInputPeer, buildInputStorePaymentPurpose, buildShippingInfo, +} from '../gramjsBuilders'; import { addEntitiesToLocalDb, deserializeBytes, @@ -241,7 +244,7 @@ export async function applyBoost({ }; } -export async function fetchBoostsStatus({ +export async function fetchBoostStatus({ chat, }: { chat: ApiChat; @@ -257,7 +260,7 @@ export async function fetchBoostsStatus({ return buildApiBoostsStatus(result); } -export async function fetchBoostsList({ +export async function fetchBoostList({ chat, offset = '', limit, @@ -348,3 +351,37 @@ export function applyGiftCode({ shouldReturnTrue: true, }); } + +export async function getPremiumGiftCodeOptions({ + chat, +}: { + chat?: ApiChat; +}) { + const result = await invokeRequest(new GramJs.payments.GetPremiumGiftCodeOptions({ + boostPeer: chat && buildInputPeer(chat.id, chat.accessHash), + })); + + if (!result) { + return undefined; + } + + return result.map(buildApiPremiumGiftCodeOption); +} + +export function launchPrepaidGiveaway({ + chat, + giveawayId, + paymentPurpose, +}: { + chat: ApiChat; + giveawayId: string; + paymentPurpose: ApiInputStorePaymentPurpose; +}) { + return invokeRequest(new GramJs.payments.LaunchPrepaidGiveaway({ + peer: buildInputPeer(chat.id, chat.accessHash), + giveawayId: BigInt(giveawayId), + purpose: buildInputStorePaymentPurpose(paymentPurpose), + }), { + shouldReturnTrue: true, + }); +} diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index ccb940d42..09a3a1202 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -2,6 +2,7 @@ import type { ThreadId } from '../../types'; import type { ApiWebDocument } from './bots'; import type { ApiGroupCall, PhoneCallAction } from './calls'; import type { ApiChat } from './chats'; +import type { ApiInputStorePaymentPurpose, ApiPremiumGiftCodeOption } from './payments'; import type { ApiMessageStoryData, ApiWebPageStoryData } from './stories'; export interface ApiDimensions { @@ -170,22 +171,66 @@ export interface ApiPoll { }; } -// First type used for state, second - for API requests -export type ApiInputInvoice = { +/* Used for Invoice UI */ +export type ApiInputInvoiceMessage = { + type: 'message'; chatId: string; messageId: number; isExtendedMedia?: boolean; -} | { +}; + +export type ApiInputInvoiceSlug = { + type: 'slug'; slug: string; }; -export type ApiRequestInputInvoice = { +export type ApiInputInvoiceGiveaway = { + type: 'giveaway'; + chatId: string; + additionalChannelIds?: string[]; + isOnlyForNewSubscribers?: boolean; + areWinnersVisible?: boolean; + prizeDescription?: string; + countries?: string[]; + untilDate: number; + currency: string; + amount: number; + option: ApiPremiumGiftCodeOption; +}; + +export type ApiInputInvoiceGiftCode = { + type: 'giftcode'; + userIds: string[]; + boostChannelId?: string; + currency: string; + amount: number; + option: ApiPremiumGiftCodeOption; +}; + +export type ApiInputInvoice = ApiInputInvoiceMessage | ApiInputInvoiceSlug | ApiInputInvoiceGiveaway +| ApiInputInvoiceGiftCode; + +/* Used for Invoice request */ +export type ApiRequestInputInvoiceMessage = { + type: 'message'; chat: ApiChat; messageId: number; -} | { +}; + +export type ApiRequestInputInvoiceSlug = { + type: 'slug'; slug: string; }; +export type ApiRequestInputInvoiceGiveaway = { + type: 'giveaway'; + purpose: ApiInputStorePaymentPurpose; + option: ApiPremiumGiftCodeOption; +}; + +export type ApiRequestInputInvoice = ApiRequestInputInvoiceMessage | ApiRequestInputInvoiceSlug +| ApiRequestInputInvoiceGiveaway; + export interface ApiInvoice { text: string; title: string; diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index c056d5731..db04ac03b 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -185,6 +185,10 @@ export interface ApiAppConfig { premiumInvoiceSlug: string; premiumBotUsername: string; isPremiumPurchaseBlocked: boolean; + isGiveawayGiftsPurchaseAvailable: boolean; + giveawayAddPeersMax: number; + giveawayBoostsPerPremium: number; + giveawayCountriesMax: number; premiumPromoOrder: ApiPremiumSection[]; defaultEmojiStatusesStickerSetId: string; maxUniqueReactions: number; diff --git a/src/api/types/payments.ts b/src/api/types/payments.ts index 3ab9d5f39..2b28a9e11 100644 --- a/src/api/types/payments.ts +++ b/src/api/types/payments.ts @@ -1,8 +1,10 @@ import type { ApiPremiumSection } from '../../global/types'; import type { ApiInvoiceContainer } from '../../types'; import type { ApiWebDocument } from './bots'; +import type { ApiChat } from './chats'; import type { ApiDocument, ApiMessageEntity, ApiPaymentCredentials } from './messages'; -import type { StatisticsOverviewPercentage } from './statistics'; +import type { PrepaidGiveaway, StatisticsOverviewPercentage } from './statistics'; +import type { ApiUser } from './users'; export interface ApiShippingAddress { streetLine1: string; @@ -81,6 +83,36 @@ export interface ApiPremiumSubscriptionOption { botUrl: string; } +export type ApiInputStorePaymentGiveaway = { + type: 'giveaway'; + isOnlyForNewSubscribers?: boolean; + areWinnersVisible?: boolean; + chat: ApiChat; + additionalChannels?: ApiChat[]; + countries?: string[]; + prizeDescription?: string; + untilDate: number; + currency: string; + amount: number; +}; + +export type ApiInputStorePaymentGiftcode = { + type: 'giftcode'; + users: ApiUser[]; + boostChannel?: ApiChat; + currency: string; + amount: number; +}; + +export type ApiInputStorePaymentPurpose = ApiInputStorePaymentGiveaway | ApiInputStorePaymentGiftcode; + +export interface ApiPremiumGiftCodeOption { + users: number; + months: number; + currency: string; + amount: number; +} + export type ApiBoostsStatus = { level: number; currentLevelBoosts: number; @@ -89,6 +121,7 @@ export type ApiBoostsStatus = { hasMyBoost?: boolean; boostUrl: string; premiumSubscribers?: StatisticsOverviewPercentage; + prepaidGiveaways?: PrepaidGiveaway[]; }; export type ApiMyBoost = { @@ -131,3 +164,10 @@ export type ApiCheckedGiftCode = { months: number; usedAt?: number; }; + +export interface ApiPrepaidGiveaway { + id: string; + months: number; + quantity: number; + date: number; +} diff --git a/src/api/types/statistics.ts b/src/api/types/statistics.ts index 459e4da90..f91a27adc 100644 --- a/src/api/types/statistics.ts +++ b/src/api/types/statistics.ts @@ -54,6 +54,7 @@ export interface ApiBoostStatistics { boosts: number; premiumSubscribers: StatisticsOverviewPercentage; remainingBoosts: number; + prepaidGiveaways: PrepaidGiveaway[]; } export interface ApiMessagePublicForward { @@ -105,6 +106,13 @@ export interface StatisticsOverviewPercentage { percentage: string; } +export interface PrepaidGiveaway { + id: string; + months: number; + quantity: number; + date: number; +} + export interface StatisticsOverviewPeriod { maxDate: number; minDate: number; diff --git a/src/assets/premium/GiftBlueRound.svg b/src/assets/premium/GiftBlueRound.svg new file mode 100644 index 000000000..26e0f3fea --- /dev/null +++ b/src/assets/premium/GiftBlueRound.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/premium/GiftGreenRound.svg b/src/assets/premium/GiftGreenRound.svg new file mode 100644 index 000000000..92b0436cf --- /dev/null +++ b/src/assets/premium/GiftGreenRound.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/premium/GiftRedRound.svg b/src/assets/premium/GiftRedRound.svg new file mode 100644 index 000000000..1b5f6daf7 --- /dev/null +++ b/src/assets/premium/GiftRedRound.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/premium/GiveawayUsersRound.svg b/src/assets/premium/GiveawayUsersRound.svg new file mode 100644 index 000000000..3496d873f --- /dev/null +++ b/src/assets/premium/GiveawayUsersRound.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 479cd48f4..ce3d4c92e 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -17,6 +17,8 @@ export { default as AttachBotInstallModal } from '../components/modals/attachBot export { default as DeleteFolderDialog } from '../components/main/DeleteFolderDialog'; 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 AppendEntityPickerModal } from '../components/main/AppendEntityPickerModal'; export { default as PremiumLimitReachedModal } from '../components/main/premium/common/PremiumLimitReachedModal'; export { default as StatusPickerMenu } from '../components/left/main/StatusPickerMenu'; export { default as BoostModal } from '../components/modals/boost/BoostModal'; @@ -31,6 +33,7 @@ export { default as UnpinAllMessagesModal } from '../components/common/UnpinAllM export { default as MessageSelectToolbar } from '../components/middle/MessageSelectToolbar'; export { default as SeenByModal } from '../components/common/SeenByModal'; export { default as PrivacySettingsNoticeModal } from '../components/common/PrivacySettingsNoticeModal'; +export { default as CountryPickerModal } from '../components/common/CountryPickerModal'; export { default as ReactorListModal } from '../components/middle/ReactorListModal'; export { default as EmojiInteractionAnimation } from '../components/middle/EmojiInteractionAnimation'; export { default as ChatLanguageModal } from '../components/middle/ChatLanguageModal'; diff --git a/src/components/common/CalendarModal.tsx b/src/components/common/CalendarModal.tsx index 87323cafe..6c336f95f 100644 --- a/src/components/common/CalendarModal.tsx +++ b/src/components/common/CalendarModal.tsx @@ -74,7 +74,7 @@ const CalendarModal: FC = ({ const passedSelectedDate = useMemo(() => (selectedAt ? new Date(selectedAt) : new Date()), [selectedAt]); const prevIsOpen = usePrevious(isOpen); - const [isTimeInputFocused, markTimeInputAsFocused, unmarkTimeInputAsFocused] = useFlag(false); + const [isTimeInputFocused, markTimeInputAsFocused] = useFlag(false); const [selectedDate, setSelectedDate] = useState(passedSelectedDate); const [currentMonthAndYear, setCurrentMonthAndYear] = useState( @@ -91,6 +91,9 @@ const CalendarModal: FC = ({ const currentYear = currentMonthAndYear.getFullYear(); const currentMonth = currentMonthAndYear.getMonth(); + const isDisabled = (isFutureMode && selectedDate.getTime() < minDate.getTime()) + || (isPastMode && selectedDate.getTime() > maxDate.getTime()); + useEffect(() => { if (!prevIsOpen && isOpen) { setSelectedDate(passedSelectedDate); @@ -169,8 +172,14 @@ const CalendarModal: FC = ({ } const handleSubmit = useCallback(() => { - onSubmit(selectedDate); - }, [onSubmit, selectedDate]); + if (isFutureMode && selectedDate < minDate) { + onSubmit(minDate); + } else if (isPastMode && selectedDate > maxDate) { + onSubmit(maxDate); + } else { + onSubmit(selectedDate); + } + }, [isFutureMode, isPastMode, minDate, maxDate, onSubmit, selectedDate]); const handleChangeHours = useCallback((e: React.ChangeEvent) => { const value = e.target.value.replace(/[^\d]+/g, ''); @@ -220,7 +229,6 @@ const CalendarModal: FC = ({ value={selectedHours} onChange={handleChangeHours} onFocus={markTimeInputAsFocused} - onBlur={unmarkTimeInputAsFocused} /> : = ({ value={selectedMinutes} onChange={handleChangeMinutes} onFocus={markTimeInputAsFocused} - onBlur={unmarkTimeInputAsFocused} /> ); @@ -322,14 +329,19 @@ const CalendarModal: FC = ({ {withTimePicker && renderTimePicker()}
- - {secondButtonLabel && ( - - )} + {secondButtonLabel && ( + + )} +
); diff --git a/src/components/common/CountryPickerModal.async.tsx b/src/components/common/CountryPickerModal.async.tsx new file mode 100644 index 000000000..9d34243ad --- /dev/null +++ b/src/components/common/CountryPickerModal.async.tsx @@ -0,0 +1,18 @@ +import type { FC } from '../../lib/teact/teact'; +import React from '../../lib/teact/teact'; + +import type { OwnProps } from './CountryPickerModal'; + +import { Bundles } from '../../util/moduleLoader'; + +import useModuleLoader from '../../hooks/useModuleLoader'; + +const CountryPickerModalAsync: FC = (props) => { + const { isOpen } = props; + const CountryPickerModal = useModuleLoader(Bundles.Extra, 'CountryPickerModal', !isOpen); + + // eslint-disable-next-line react/jsx-props-no-spreading,react/jsx-no-undef + return CountryPickerModal ? : undefined; +}; + +export default CountryPickerModalAsync; diff --git a/src/components/common/CountryPickerModal.module.scss b/src/components/common/CountryPickerModal.module.scss new file mode 100644 index 000000000..098b751e6 --- /dev/null +++ b/src/components/common/CountryPickerModal.module.scss @@ -0,0 +1,29 @@ +.root :global(.modal-dialog) { + max-width: 55vh; +} + +.pickerSelector { + display: flex; + align-items: center; +} + +.pickerTitle { + flex: 1; + margin: 0 0 0 1.25rem; + font-size: 1.25rem; + + @media (max-width: 600px) { + margin-left: 0.75rem; + } +} + +.picker { + height: 70vh; +} + +.footer { + display: flex; + justify-content: flex-end; + flex-direction: column; +} + diff --git a/src/components/common/CountryPickerModal.tsx b/src/components/common/CountryPickerModal.tsx new file mode 100644 index 000000000..92a64133c --- /dev/null +++ b/src/components/common/CountryPickerModal.tsx @@ -0,0 +1,118 @@ +import type { FC } from '../../lib/teact/teact'; +import React, { + memo, useMemo, useState, +} from '../../lib/teact/teact'; +import { getActions } from '../../global'; + +import type { ApiCountry } from '../../api/types'; + +import buildClassName from '../../util/buildClassName'; + +import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; +import usePrevious from '../../hooks/usePrevious'; + +import Button from '../ui/Button'; +import Modal from '../ui/Modal'; +import Icon from './Icon'; +import Picker from './Picker'; + +import styles from './CountryPickerModal.module.scss'; + +export type OwnProps = { + isOpen: boolean; + onClose: () => void; + onSubmit: (value: string[]) => void; + countryList: ApiCountry[]; + selectionLimit?: number | undefined; +}; + +const CountryPickerModal: FC = ({ + isOpen, + onClose, + onSubmit, + countryList, + selectionLimit, +}) => { + const { showNotification } = getActions(); + + const lang = useLang(); + + const [selectedCountryIds, setSelectedCountryIds] = useState([]); + const prevSelectedCountryIds = usePrevious(selectedCountryIds); + const noPickerScrollRestore = prevSelectedCountryIds === selectedCountryIds; + + const displayedIds = useMemo(() => { + if (!countryList) { + return []; + } + + return countryList + .filter((country) => !country.isHidden) + .map((country) => country.iso2); + }, [countryList]); + + const handleSelectedIdsChange = useLastCallback((newSelectedIds: string[]) => { + if (selectionLimit && newSelectedIds.length > selectionLimit) { + showNotification({ + message: lang('BoostingSelectUpToWarningCountries', selectionLimit), + }); + return; + } + setSelectedCountryIds(newSelectedIds); + }); + + const handleSubmit = useLastCallback(() => { + onSubmit(selectedCountryIds); + onClose(); + }); + + return ( + +
+
+ + +

+ {lang('BoostingSelectCountry')} +

+
+
+ +
+ +
+ +
+ +
+
+ ); +}; + +export default memo(CountryPickerModal); diff --git a/src/components/common/Picker.tsx b/src/components/common/Picker.tsx index ceddd980c..407af9a64 100644 --- a/src/components/common/Picker.tsx +++ b/src/components/common/Picker.tsx @@ -3,9 +3,12 @@ import React, { memo, useEffect, useMemo, useRef, } from '../../lib/teact/teact'; +import type { ApiCountry } from '../../api/types'; + import { requestMeasure } from '../../lib/fasterdom/fasterdom'; import { isUserId } from '../../global/helpers'; import buildClassName from '../../util/buildClassName'; +import { buildCollectionByKey } from '../../util/iteratees'; import { MEMO_EMPTY_ARRAY } from '../../util/memo'; import useInfiniteScroll from '../../hooks/useInfiniteScroll'; @@ -42,6 +45,8 @@ type OwnProps = { onFilterChange?: (value: string) => void; onDisabledClick?: (id: string) => void; onLoadMore?: () => void; + isCountryList?: boolean; + countryList?: ApiCountry[]; }; // Focus slows down animation, also it breaks transition layout in Chrome @@ -69,6 +74,8 @@ const Picker: FC = ({ onFilterChange, onDisabledClick, onLoadMore, + isCountryList, + countryList, }) => { // eslint-disable-next-line no-null/no-null const inputRef = useRef(null); @@ -92,17 +99,18 @@ const Picker: FC = ({ const lockedIdsSet = useMemo(() => new Set(lockedIds), [lockedIds]); const sortedItemIds = useMemo(() => { - return itemIds.sort((a, b) => { - const aIsLocked = lockedIdsSet.has(a); - const bIsLocked = lockedIdsSet.has(b); - if (aIsLocked && !bIsLocked) { - return -1; + const lockedBucket: string[] = []; + const unlockedBucket: string[] = []; + + itemIds.forEach((id) => { + if (lockedIdsSet.has(id)) { + lockedBucket.push(id); + } else { + unlockedBucket.push(id); } - if (!aIsLocked && bIsLocked) { - return 1; - } - return 0; }); + + return lockedBucket.concat(unlockedBucket); }, [itemIds, lockedIdsSet]); const handleItemClick = useLastCallback((id: string) => { @@ -130,6 +138,22 @@ const Picker: FC = ({ const lang = useLang(); + const countriesByIso = useMemo(() => { + if (!countryList) return undefined; + return buildCollectionByKey(countryList, 'iso2'); + }, [countryList]); + + const renderChatInfo = (id: string) => { + if (isCountryList && countriesByIso) { + const country = countriesByIso[id]; + return
{country.defaultName}
; + } else if (isUserId(id)) { + return ; + } else { + return ; + } + }; + return (
{isSearchable && ( @@ -194,11 +218,7 @@ const Picker: FC = ({ ripple > {!isRoundCheckbox ? renderCheckbox() : undefined} - {isUserId(id) ? ( - - ) : ( - - )} + {renderChatInfo(id)} {isRoundCheckbox ? renderCheckbox() : undefined} ); diff --git a/src/components/common/helpers/boostInfo.ts b/src/components/common/helpers/boostInfo.ts index a0fe6c27b..26df42744 100644 --- a/src/components/common/helpers/boostInfo.ts +++ b/src/components/common/helpers/boostInfo.ts @@ -2,7 +2,7 @@ import type { ApiBoostsStatus } from '../../../api/types'; export function getBoostProgressInfo(boostInfo: ApiBoostsStatus, freezeOnLevelUp?: boolean) { const { - level, boosts, currentLevelBoosts, nextLevelBoosts, hasMyBoost, + level, boosts, currentLevelBoosts, nextLevelBoosts, hasMyBoost, prepaidGiveaways, } = boostInfo; const isJustUpgraded = freezeOnLevelUp && boosts === currentLevelBoosts && hasMyBoost; @@ -23,5 +23,6 @@ export function getBoostProgressInfo(boostInfo: ApiBoostsStatus, freezeOnLevelUp levelProgress, remainingBoosts, isMaxLevel, + prepaidGiveaways, }; } diff --git a/src/components/main/AppendEntityPicker.async.tsx b/src/components/main/AppendEntityPicker.async.tsx new file mode 100644 index 000000000..35aa59dee --- /dev/null +++ b/src/components/main/AppendEntityPicker.async.tsx @@ -0,0 +1,18 @@ +import type { FC } from '../../lib/teact/teact'; +import React from '../../lib/teact/teact'; + +import type { OwnProps } from './AppendEntityPickerModal'; + +import { Bundles } from '../../util/moduleLoader'; + +import useModuleLoader from '../../hooks/useModuleLoader'; + +const AppendEntityPickerModalAsync: FC = (props) => { + const { isOpen } = props; + const AppendEntityPickerModal = useModuleLoader(Bundles.Extra, 'AppendEntityPickerModal', !isOpen); + + // eslint-disable-next-line react/jsx-props-no-spreading + return AppendEntityPickerModal ? : undefined; +}; + +export default AppendEntityPickerModalAsync; diff --git a/src/components/main/AppendEntityPicker.module.scss b/src/components/main/AppendEntityPicker.module.scss new file mode 100644 index 000000000..5c43b6c6a --- /dev/null +++ b/src/components/main/AppendEntityPicker.module.scss @@ -0,0 +1,46 @@ +.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: 70vh; +} diff --git a/src/components/main/AppendEntityPickerModal.tsx b/src/components/main/AppendEntityPickerModal.tsx new file mode 100644 index 000000000..f5874cfef --- /dev/null +++ b/src/components/main/AppendEntityPickerModal.tsx @@ -0,0 +1,277 @@ +import type { FC } from '../../lib/teact/teact'; +import React, { + memo, + useMemo, + useState, +} from '../../lib/teact/teact'; +import { getActions, getGlobal, withGlobal } from '../../global'; + +import type { ApiChat, ApiChatMember, ApiUserStatus } from '../../api/types'; + +import { + filterChatsByName, + filterUsersByName, isChatChannel, isChatPublic, isUserBot, sortUserIds, +} from '../../global/helpers'; +import { selectChat, selectChatFullInfo } from '../../global/selectors'; +import buildClassName from '../../util/buildClassName'; +import { unique } from '../../util/iteratees'; +import sortChatIds from '../common/helpers/sortChatIds'; + +import useFlag from '../../hooks/useFlag'; +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 ConfirmDialog from '../ui/ConfirmDialog'; +import Modal from '../ui/Modal'; + +import styles from './AppendEntityPicker.module.scss'; + +export type OwnProps = { + isOpen?: boolean; + onClose: () => void; + chatId?: string; + entityType: 'members' | 'channels' | undefined; + onSubmit: (value: string[]) => void; + selectionLimit: number; +}; + +interface StateProps { + chatId?: string; + members?: ApiChatMember[]; + adminMembersById?: Record; + userStatusesById: Record; + channelList?: (ApiChat | undefined)[] | undefined; + isChannel?: boolean; + currentUserId?: string | undefined; +} + +const AppendEntityPickerModal: FC = ({ + chatId, + isOpen, + onClose, + members, + adminMembersById, + userStatusesById, + entityType, + isChannel, + onSubmit, + currentUserId, + selectionLimit, +}) => { + const { showNotification } = getActions(); + + const lang = useLang(); + const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag(); + + const [selectedChannelIds, setSelectedChannelIds] = useState([]); + const [selectedMemberIds, setSelectedMemberIds] = useState([]); + const [pendingChannelId, setPendingChannelId] = useState(undefined); + const [searchQuery, setSearchQuery] = useState(''); + + const channelsIds = useMemo(() => { + const chatsById = getGlobal().chats.byId; + const activeChatIds = getGlobal().chats.listIds.active; + + return activeChatIds!.map((id) => chatsById[id]) + .filter((chat) => chat && isChatChannel(chat) && chat.id !== chatId) + .map((chat) => chat!.id); + }, [chatId]); + + const adminIds = useMemo(() => { + return adminMembersById && Object.keys(adminMembersById); + }, [adminMembersById]); + + const memberIds = useMemo(() => { + const usersById = getGlobal().users.byId; + if (!members || !usersById) { + return []; + } + + const userIds = sortUserIds( + members.map(({ userId }) => userId), + usersById, + userStatusesById, + ); + + return adminIds ? userIds.filter((userId) => userId !== currentUserId) : userIds; + }, [adminIds, currentUserId, members, userStatusesById]); + + const displayedMembersIds = useMemo(() => { + const usersById = getGlobal().users.byId; + const filteredContactIds = memberIds ? filterUsersByName(memberIds, usersById, searchQuery) : []; + + return sortChatIds(unique(filteredContactIds).filter((userId) => { + const user = usersById[userId]; + if (!user) { + return true; + } + + return !isUserBot(user); + })); + }, [memberIds, searchQuery]); + + const displayedChannelIds = useMemo(() => { + const chatsById = getGlobal().chats.byId; + const foundChannelIds = channelsIds ? filterChatsByName(lang, channelsIds, chatsById, searchQuery) : []; + + return sortChatIds(unique(foundChannelIds).filter((contactId) => { + const chat = chatsById[contactId]; + if (!chat) { + return true; + } + + return isChannel; + }), + false, + selectedChannelIds); + }, [channelsIds, lang, searchQuery, selectedChannelIds, isChannel]); + + const handleCloseButtonClick = useLastCallback(() => { + onSubmit([]); + onClose(); + }); + + const handleSendIdList = useLastCallback(() => { + onSubmit(entityType === 'members' ? selectedMemberIds : selectedChannelIds); + onClose(); + }); + + const confirmPrivateLinkChannelSelection = useLastCallback(() => { + if (pendingChannelId) { + setSelectedChannelIds((prevIds) => unique([...prevIds, pendingChannelId])); + } + closeConfirmModal(); + }); + + const handleSelectedMembersChange = useLastCallback((newSelectedIds: string[]) => { + if (newSelectedIds.length > selectionLimit) { + showNotification({ + message: lang('BoostingSelectUpToWarningUsers', selectionLimit), + }); + return; + } + setSelectedMemberIds(newSelectedIds); + }); + + const handleSelectedChannelIdsChange = useLastCallback((newSelectedIds: string[]) => { + const chatsById = getGlobal().chats.byId; + const newlyAddedIds = newSelectedIds.filter((id) => !selectedChannelIds.includes(id)); + const privateLinkChannelId = newlyAddedIds.find((id) => { + const chat = chatsById[id]; + return chat && !isChatPublic(chat); + }); + + if (selectedChannelIds?.length >= selectionLimit) { + showNotification({ + message: lang('BoostingSelectUpToWarningChannelsPlural', selectionLimit), + }); + return; + } + + if (privateLinkChannelId) { + setPendingChannelId(privateLinkChannelId); + openConfirmModal(); + } else { + setSelectedChannelIds(newSelectedIds); + } + }); + + const handleClose = useLastCallback(() => { + onSubmit([]); + onClose(); + }); + + function renderSearchField() { + return ( +
+ +

{lang(entityType === 'channels' + ? 'RequestPeer.ChooseChannelTitle' : 'BoostingAwardSpecificUsers')} +

+
+ ); + } + + return ( + +
+ {renderSearchField()} +
+ +
+
+ +
+
+ +
+ ); +}; + +export default memo(withGlobal((global, { chatId, entityType }): StateProps => { + const { statusesById: userStatusesById } = global.users; + let isChannel; + let members: ApiChatMember[] | undefined; + let adminMembersById: Record | undefined; + let currentUserId: string | undefined; + + if (entityType === 'members') { + currentUserId = global.currentUserId; + const chatFullInfo = chatId ? selectChatFullInfo(global, chatId) : undefined; + if (chatFullInfo) { + members = chatFullInfo.members; + adminMembersById = chatFullInfo.adminMembersById; + } + } else if (entityType === 'channels') { + const chat = chatId ? selectChat(global, chatId) : undefined; + if (chat) { + isChannel = isChatChannel(chat); + } + } + + return { + chatId, + members, + adminMembersById, + userStatusesById, + isChannel, + currentUserId, + }; +})(AppendEntityPickerModal)); diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index ec22420d1..ae83b08c5 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -101,6 +101,7 @@ import InviteViaLinkModal from './InviteViaLinkModal.async'; import NewContactModal from './NewContactModal.async'; import Notifications from './Notifications.async'; import PremiumLimitReachedModal from './premium/common/PremiumLimitReachedModal.async'; +import GiveawayModal from './premium/GiveawayModal.async'; import PremiumMainModal from './premium/PremiumMainModal.async'; import SafeLinkModal from './SafeLinkModal.async'; @@ -156,6 +157,8 @@ type StateProps = { isPaymentModalOpen?: boolean; isReceiptModalOpen?: boolean; isReactionPickerOpen: boolean; + isAppendModalOpen?: boolean; + isGiveawayModalOpen?: boolean; isCurrentUserPremium?: boolean; chatlistModal?: TabState['chatlistModal']; boostModal?: TabState['boostModal']; @@ -214,6 +217,7 @@ const Main: FC = ({ currentUserName, urlAuth, isPremiumModalOpen, + isGiveawayModalOpen, isPaymentModalOpen, isReceiptModalOpen, isReactionPickerOpen, @@ -595,7 +599,8 @@ const Main: FC = ({ - + {isPremiumModalOpen && } + {isGiveawayModalOpen && } @@ -638,6 +643,7 @@ export default memo(withGlobal( newContact, ratingPhoneCall, premiumModal, + giveawayModal, isMasterTab, payment, limitReachedModal, @@ -703,6 +709,7 @@ export default memo(withGlobal( urlAuth, isCurrentUserPremium: selectIsCurrentUserPremium(global), isPremiumModalOpen: premiumModal?.isOpen, + isGiveawayModalOpen: giveawayModal?.isOpen, limitReached: limitReachedModal?.limit, isPaymentModalOpen: payment.isPaymentModalOpen, isReceiptModalOpen: Boolean(payment.receipt), diff --git a/src/components/main/premium/GiftPremiumModal.tsx b/src/components/main/premium/GiftPremiumModal.tsx index 9c5c2f8aa..d556cb7b2 100644 --- a/src/components/main/premium/GiftPremiumModal.tsx +++ b/src/components/main/premium/GiftPremiumModal.tsx @@ -15,7 +15,6 @@ import { import { formatCurrency } from '../../../util/formatCurrency'; import renderText from '../../common/helpers/renderText'; -import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; import useLang from '../../../hooks/useLang'; import Avatar from '../../common/Avatar'; @@ -45,31 +44,28 @@ const GiftPremiumModal: FC = ({ const { openPremiumModal, closeGiftPremiumModal, openUrl } = getActions(); const lang = useLang(); - const renderedUser = useCurrentOrPrev(user, true); - const renderedGifts = useCurrentOrPrev(gifts, true); const [selectedOption, setSelectedOption] = useState(); - const firstGift = renderedGifts?.[0]; const fullMonthlyAmount = useMemo(() => { - if (!renderedGifts || renderedGifts.length === 0 || !firstGift) { + if (!gifts?.length) { return undefined; } - const basicGift = renderedGifts.reduce((acc, gift) => { - return gift.months < firstGift.months ? gift : firstGift; - }, firstGift); + const basicGift = gifts.reduce((acc, gift) => { + return gift.months < acc.months ? gift : acc; + }); return Math.floor(basicGift.amount / basicGift.months); - }, [firstGift, renderedGifts]); + }, [gifts]); useEffect(() => { - if (isOpen) { - setSelectedOption(firstGift?.months); + if (isOpen && gifts?.length) { + setSelectedOption(gifts[0].months); } - }, [firstGift?.months, isOpen]); + }, [gifts, isOpen]); const selectedGift = useMemo(() => { - return renderedGifts?.find((gift) => gift.months === selectedOption); - }, [renderedGifts, selectedOption]); + return gifts?.find((gift) => gift.months === selectedOption); + }, [gifts, selectedOption]); const handleSubmit = useCallback(() => { if (!selectedGift) { @@ -121,7 +117,7 @@ const GiftPremiumModal: FC = ({ @@ -130,13 +126,13 @@ const GiftPremiumModal: FC = ({

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

- {renderedGifts?.map((gift) => ( + {gifts?.map((gift) => ( = (props) => { + const { isOpen } = props; + const GiveawayModal = useModuleLoader(Bundles.Extra, 'GiveawayModal', !isOpen); + + // eslint-disable-next-line react/jsx-props-no-spreading + return GiveawayModal ? : undefined; +}; + +export default GiveawayModalAsync; diff --git a/src/components/main/premium/GiveawayModal.module.scss b/src/components/main/premium/GiveawayModal.module.scss new file mode 100644 index 000000000..264987f53 --- /dev/null +++ b/src/components/main/premium/GiveawayModal.module.scss @@ -0,0 +1,280 @@ +@use '../../../styles/mixins'; + +.root { + --premium-gradient: linear-gradient(88.39deg, #6C93FF -2.56%, #976FFF 51.27%, #DF69D1 107.39%); + --premium-feature-background: linear-gradient(65.85deg, #6C93FF -0.24%, #976FFF 53.99%, #DF69D1 110.53%); + + user-select: none; +} + +.root :global(.modal-content) { + padding: 0; +} + +.root :global(.modal-dialog) { + height: min(calc(55vh + 41px + 193px), 90vh); + max-width: 26.25rem; + overflow: hidden; +} + +.button { + font-weight: 500; + font-size: 1rem; + height: 3rem; +} + +.main { + height: 100%; + overflow-y: scroll; + display: flex; + flex-direction: column; + align-items: center; +} + +.logo { + margin: 1rem; + width: 6.25rem; + height: 6.25rem; + min-height: 6.25rem; +} + +.header-text { + font-size: 1.5rem; + font-weight: 500; + text-align: center; + margin-inline: 0.5rem; +} + +.description { + text-align: center; + margin-inline: 2.5rem; + margin-bottom: 2rem; +} + +.header { + z-index: 2; + display: flex; + align-items: center; + border-bottom: 0.0625rem solid var(--color-borders); + position: absolute; + width: 100%; + left: 0; + top: 0; + height: 3.5rem; + padding: 0.5rem; + background: var(--color-background); + transition: 0.25s ease-out transform; +} + +.hidden-header { + transform: translateY(-100%); +} + +.close-button { + position: absolute; + top: 0.5rem; + left: 0.5rem; + z-index: 3; +} + +.premium-header-text { + font-size: 1.25rem; + font-weight: 500; + margin: 0 0 0 3rem; + unicode-bidi: plaintext; +} + +.primary-footer-text { + color: var(--color-text); +} + +.section { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0 0.375rem; + border-top: 0.0625rem solid var(--color-borders); +} + +.types { + border-top: none; +} + +.footer { + position: absolute; + width: 100%; + background: var(--color-background); + border-top: 0.0625rem solid var(--color-borders); + left: 0; + bottom: 0; + padding: 1rem; + z-index: 1; +} + +.options { + width: 100%; +} + +.giveawayTitle { + margin: 0.8125rem 0 0 1rem; + font-size: 1rem; + color: var(--color-links); + + @include mixins.adapt-margin-to-scrollbar(0.8125rem); +} + +.subscription { + margin: 1rem 1.625rem 1.3125rem 1.5rem; + font-size: 1rem; + font-weight: 400; + color: var(--color-text-secondary); + + @include mixins.adapt-margin-to-scrollbar(1rem); +} + +.subscriptionOption { + margin-bottom: 0; + padding-inline: 3.5rem 1rem; + box-shadow: none; +} + +.status { + display: flex; + align-items: center; + gap: 1rem; + justify-content: space-between; + width: 100%; + padding: 0 0.375rem 0 1.125rem; + margin-bottom: 1.3125rem; +} + +.info { + flex-grow: 1; +} + +.titleInfo { + margin: 0; +} + +.month { + color: var(--color-text-secondary); + margin: 0; +} + +.quantity { + display: flex; + justify-content: space-between; + align-items: center; +} + +.floatingBadge { + display: flex; + align-items: center; + justify-content: center; + color: var(--color-links); + background-color: rgba(78, 142, 229, 0.1); + position: relative; + z-index: 1; +} + +.floatingBadgeColor { + margin: 0.8125rem 0.8125rem 0 1rem; + padding: 0.1875rem 0.75rem 0.25rem 0.5rem; + border-radius: 1rem; + background-color: rgba(78, 142, 229, 0.1); + + @include mixins.adapt-margin-to-scrollbar(0.8125rem); +} + +.floatingBadgeButtonColor { + padding: 0.25rem 0.375rem 0.25rem 0.1875rem; + border-radius: 0.375rem; + background-color: white; + margin-left: 0.5rem; +} + +.floatingBadgeIcon { + font-size: 1.125rem; + margin-right: 0.1875rem; +} + +.floatingBadgeValue { + font-size: 0.875rem; + font-weight: 500; +} + +.subscriptionFooter { + margin-bottom: 6rem; +} + +.premiumFeatures { + margin-bottom: 0; + font-size: 1rem; + color: var(--color-text-secondary); +} + +.dateButton { + margin-bottom: 1rem; + padding-right: 1.1875rem; + display: flex; + padding-left: 1rem; + justify-content: space-between; + align-items: center; +} + +.title { + color: var(--color-text);; + font-size: 1rem; + text-transform: initial; + margin: 0; +} + +.checkboxSection { + display: flex; + justify-content: space-between; + padding: 1rem; +} + +.prizesSection { + display: flex; + align-items: center; + padding: 1rem; +} + +.prizesInput { + flex-grow: 1; + margin-left: 1rem; + margin-bottom: 0; +} + +.subLabelClassName { + margin-top: 0.1875rem; + color: var(--color-links) !important; +} + +.prepaidImg { + width: 2.75rem; + height: 2.75rem; +} + +.addButton { + margin-bottom: 1rem; +} + +.addChannel { + margin-inline-start: initial !important; +} + +@media (max-width: 600px) { + .root :global(.modal-dialog) { + width: 100%; + height: 100%; + max-width: 100% !important; + border-radius: 0; + } + + .root .transition { + height: 100%; + } +} diff --git a/src/components/main/premium/GiveawayModal.tsx b/src/components/main/premium/GiveawayModal.tsx new file mode 100644 index 000000000..52868eddc --- /dev/null +++ b/src/components/main/premium/GiveawayModal.tsx @@ -0,0 +1,741 @@ +import type { ChangeEvent } from 'react'; +import type { FC } from '../../../lib/teact/teact'; +import React, { + memo, useEffect, useMemo, useRef, useState, +} from '../../../lib/teact/teact'; +import { getActions, getGlobal, withGlobal } from '../../../global'; + +import type { + ApiCountry, ApiPremiumGiftCodeOption, ApiPrepaidGiveaway, ApiUser, +} from '../../../api/types'; + +import { + GIVEAWAY_BOOST_PER_PREMIUM, + GIVEAWAY_MAX_ADDITIONAL_CHANNELS, + GIVEAWAY_MAX_ADDITIONAL_COUNTRIES, + GIVEAWAY_MAX_ADDITIONAL_USERS, +} from '../../../config'; +import { getUserFullName } from '../../../global/helpers'; +import { + selectTabState, +} from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; +import { formatDateTimeToString } from '../../../util/date/dateFormat'; +import renderText from '../../common/helpers/renderText'; + +import useFlag from '../../../hooks/useFlag'; +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; + +import CalendarModal from '../../common/CalendarModal'; +import CountryPickerModal from '../../common/CountryPickerModal'; +import GroupChatInfo from '../../common/GroupChatInfo'; +import Icon from '../../common/Icon'; +import Button from '../../ui/Button'; +import ConfirmDialog from '../../ui/ConfirmDialog'; +import InputText from '../../ui/InputText'; +import Link from '../../ui/Link'; +import ListItem from '../../ui/ListItem'; +import Modal from '../../ui/Modal'; +import RadioGroup from '../../ui/RadioGroup'; +import RangeSliderWithMarks from '../../ui/RangeSliderWithMarks'; +import Switcher from '../../ui/Switcher'; +import AppendEntityPickerModal from '../AppendEntityPickerModal'; +import GiveawayTypeOption from './GiveawayTypeOption'; +import PremiumSubscriptionOption from './PremiumSubscriptionOption'; + +import styles from './GiveawayModal.module.scss'; + +import GiftBlueRound from '../../../assets/premium/GiftBlueRound.svg'; +import GiftGreenRound from '../../../assets/premium/GiftGreenRound.svg'; +import GiftRedRound from '../../../assets/premium/GiftRedRound.svg'; +import GiveawayUsersRound from '../../../assets/premium/GiveawayUsersRound.svg'; +import PremiumLogo from '../../../assets/premium/PremiumLogo.svg'; + +export type OwnProps = { + isOpen?: boolean; + userIds?: string[]; +}; + +type StateProps = { + chatId?: string; + gifts?: ApiPremiumGiftCodeOption[]; + isOpen?: boolean; + fromUser?: ApiUser; + selectedMemberList?: string[] | undefined; + selectedChannelList?: string[] | undefined; + giveawayBoostPerPremiumLimit?: number; + userSelectionLimit?: number; + countryList: ApiCountry[]; + prepaidGiveaway?: ApiPrepaidGiveaway; + countrySelectionLimit: number | undefined; +}; + +type GiveawayAction = 'createRandomlyUsers' | 'createSpecificUsers'; +type ApiGiveawayType = 'random_users' | 'specific_users'; +type SubscribersType = 'all' | 'new'; + +interface TypeOption { + name: string; + text: string; + value: ApiGiveawayType; + img: string; + actions?: GiveawayAction; + isLink: boolean; + onClickAction?: () => void; +} + +const DEFAULT_CUSTOM_EXPIRE_DATE = 86400 * 3 * 1000; // 3 days +const MAX_ADDITIONAL_CHANNELS = 9; +const DEFAULT_BOOST_COUNT = 5; + +const GIVEAWAY_IMG_LIST: { [key: number]: string } = { + 3: GiftGreenRound, + 6: GiftBlueRound, + 12: GiftRedRound, +}; + +const GiveawayModal: FC = ({ + chatId, + gifts, + isOpen, + selectedMemberList, + selectedChannelList, + giveawayBoostPerPremiumLimit = GIVEAWAY_BOOST_PER_PREMIUM, + countryList, + prepaidGiveaway, + countrySelectionLimit = GIVEAWAY_MAX_ADDITIONAL_COUNTRIES, + userSelectionLimit = GIVEAWAY_MAX_ADDITIONAL_USERS, +}) => { + // eslint-disable-next-line no-null/no-null + const dialogRef = useRef(null); + const { + closeGiveawayModal, openInvoice, openPremiumModal, + launchPrepaidGiveaway, + } = getActions(); + + const lang = useLang(); + const [isCalendarOpened, openCalendar, closeCalendar] = useFlag(); + const [isCountryPickerModalOpen, openCountryPickerModal, closeCountryPickerModal] = useFlag(); + const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag(); + const [isEntityPickerModalOpen, openEntityPickerModal, closeEntityPickerModal] = useFlag(); + const [entityType, setEntityType] = useState<'members' | 'channels' | undefined>(undefined); + + const TYPE_OPTIONS: TypeOption[] = [{ + name: 'BoostingCreateGiveaway', + text: 'BoostingWinnersRandomly', + value: 'random_users', + img: GiftBlueRound, + actions: 'createRandomlyUsers', + isLink: false, + }, { + name: 'BoostingAwardSpecificUsers', + text: 'BoostingSelectRecipients', + value: 'specific_users', + img: GiveawayUsersRound, + actions: 'createSpecificUsers', + isLink: true, + onClickAction: () => { + openEntityPickerModal(); + setEntityType('members'); + }, + }]; + + const [customExpireDate, setCustomExpireDate] = useState(Date.now() + DEFAULT_CUSTOM_EXPIRE_DATE); + const [isHeaderHidden, setHeaderHidden] = useState(true); + const [selectedUserCount, setSelectedUserCount] = useState(DEFAULT_BOOST_COUNT); + const [selectedGiveawayOption, setGiveawayOption] = useState(TYPE_OPTIONS[0].value); + const [selectedSubscriberOption, setSelectedSubscriberOption] = useState('all'); + const [selectedMonthOption, setSelectedMonthOption] = useState(); + const [selectedUserIds, setSelectedUserIds] = useState([]); + const [selectedChannelIds, setSelectedChannelIds] = useState([]); + const [selectedCountriesIds, setSelectedCountriesIds] = useState([]); + const [shouldShowWinners, setShouldShowWinners] = useState(false); + const [shouldShowPrizes, setShouldShowPrizes] = useState(false); + const [prizeDescription, setPrizeDescription] = useState(undefined); + const [dataPrepaidGiveaway, setDataPrepaidGiveaway] = useState(undefined); + const boostQuantity = selectedUserCount * giveawayBoostPerPremiumLimit; + const isRandomUsers = selectedGiveawayOption === 'random_users'; + + const SUBSCRIBER_OPTIONS = useMemo(() => [ + { + value: 'all', + label: lang('BoostingAllSubscribers'), + subLabel: selectedCountriesIds && selectedCountriesIds.length > 0 + ? lang('Giveaway.ReceiverType.Countries', selectedCountriesIds.length) + : lang('BoostingFromAllCountries'), + }, + { + value: 'new', + label: lang('BoostingNewSubscribers'), + subLabel: selectedCountriesIds && selectedCountriesIds.length > 0 + ? lang('Giveaway.ReceiverType.Countries', selectedCountriesIds.length) + : lang('BoostingFromAllCountries'), + }, + ], [lang, selectedCountriesIds]); + + const monthQuantity = lang('Months', selectedMonthOption); + + const selectedGift = useMemo(() => { + return gifts!.find((gift) => gift.months === selectedMonthOption && gift.users === selectedUserCount); + }, [gifts, selectedMonthOption, selectedUserCount]); + + const filteredGifts = useMemo(() => { + return gifts?.filter((gift) => gift.users + === (selectedUserIds?.length ? selectedUserIds?.length : selectedUserCount)); + }, [gifts, selectedUserIds, selectedUserCount]); + + const fullMonthlyAmount = useMemo(() => { + if (!filteredGifts?.length) { + return undefined; + } + + const basicGift = filteredGifts.reduce((acc, gift) => { + return gift.amount < acc.amount ? gift : acc; + }); + + return Math.floor(basicGift.amount / basicGift.months); + }, [filteredGifts]); + + const userCountOptions = useMemo(() => { + const uniqueUserCounts = new Set(gifts?.map((gift) => gift.users)); + return Array.from(uniqueUserCounts).sort((a, b) => a - b); + }, [gifts]); + + useEffect(() => { + if (isOpen) { + setSelectedMonthOption(prepaidGiveaway ? prepaidGiveaway.months : gifts?.[0].months); + } + }, [gifts, isOpen, prepaidGiveaway]); + + useEffect(() => { + if (prepaidGiveaway) { + setSelectedUserCount(prepaidGiveaway.quantity); + setDataPrepaidGiveaway(prepaidGiveaway); + } + }, [prepaidGiveaway]); + + useEffect(() => { + if (selectedMemberList) { + setSelectedUserIds(selectedMemberList); + } + }, [selectedMemberList]); + + useEffect(() => { + if (selectedChannelList) { + setSelectedChannelIds(selectedChannelList); + } + }, [selectedChannelList]); + + const handlePremiumClick = useLastCallback(() => { + openPremiumModal(); + }); + + const handleClick = useLastCallback(() => { + if (selectedUserIds?.length) { + openInvoice({ + type: 'giftcode', + boostChannelId: chatId!, + userIds: selectedUserIds, + currency: selectedGift!.currency, + amount: selectedGift!.amount, + option: selectedGift!, + }); + } else { + openInvoice({ + type: 'giveaway', + chatId: chatId!, + additionalChannelIds: selectedChannelIds, + isOnlyForNewSubscribers: selectedSubscriberOption === 'new', + countries: selectedCountriesIds, + areWinnersVisible: shouldShowWinners, + prizeDescription, + untilDate: customExpireDate / 1000, + currency: selectedGift!.currency, + amount: selectedGift!.amount, + option: selectedGift!, + }); + } + + closeGiveawayModal(); + }); + + const confirmLaunchPrepaidGiveaway = useLastCallback(() => { + launchPrepaidGiveaway({ + chatId: chatId!, + giveawayId: dataPrepaidGiveaway!.id, + paymentPurpose: { + additionalChannelIds: selectedChannelIds, + countries: selectedCountriesIds, + prizeDescription, + areWinnersVisible: shouldShowWinners, + untilDate: customExpireDate / 1000, + currency: selectedGift!.currency, + amount: selectedGift!.amount, + }, + }); + + closeConfirmModal(); + closeGiveawayModal(); + }); + + const handleUserCountChange = useLastCallback((newValue) => { + setSelectedUserCount(newValue); + }); + + const handlePrizeDescriptionChange = useLastCallback((e: ChangeEvent) => { + setPrizeDescription(e.target.value); + }); + + const userNames = useMemo(() => { + const usersById = getGlobal().users.byId; + return selectedUserIds?.map((userId) => getUserFullName(usersById[userId])).join(', '); + }, [selectedUserIds]); + + const handleAdd = useLastCallback(() => { + openEntityPickerModal(); + setEntityType('channels'); + }); + + function handleScroll(e: React.UIEvent) { + const { scrollTop } = e.currentTarget; + + setHeaderHidden(scrollTop <= 150); + } + + const handleChangeSubscriberOption = useLastCallback((value) => { + setSelectedSubscriberOption(value); + }); + + const handleChangeTypeOption = useLastCallback((value: ApiGiveawayType) => { + setGiveawayOption(value); + setSelectedUserIds([]); + }); + + const handleExpireDateChange = useLastCallback((date: Date) => { + setCustomExpireDate(date.getTime()); + closeCalendar(); + }); + + const handleSetCountriesListChange = useLastCallback((value: string[]) => { + setSelectedCountriesIds(value); + }); + + const handleSetIdsListChange = useLastCallback((value: string[]) => { + return entityType === 'members' + ? (value?.length ? setSelectedUserIds(value) : setGiveawayOption('random_users')) + : setSelectedChannelIds(value); + }); + + const handleClose = useLastCallback(() => { + closeGiveawayModal(); + }); + + const handleShouldShowWinnersChange = useLastCallback((e: ChangeEvent) => { + setShouldShowWinners(e.target.checked); + }); + + const handleShouldShowPrizesChange = useLastCallback((e: ChangeEvent) => { + setShouldShowPrizes(e.target.checked); + }); + + const onClickActionHandler = useLastCallback(() => { + openCountryPickerModal(); + }); + + if (!gifts) return undefined; + + function renderTypeOptions() { + return ( +
+ {TYPE_OPTIONS.map((option) => { + return ( + + ); + })} +
+ ); + } + + function renderSubscribersOptions() { + return ( +
+ +
+ ); + } + + function renderSubscriptionOptions() { + return ( +
+ {filteredGifts?.map((gift) => ( + + ))} +
+ ); + } + + function renderPremiumFeaturesLink() { + const info = lang('GiftPremiumListFeaturesAndTerms'); + const parts = info.match(/([^*]*)\*([^*]+)\*(.*)/); + + if (!parts || parts.length < 4) { + return undefined; + } + + return ( +

+ {parts[1]} + {parts[2]} + {parts[3]} +

+ ); + } + + function deleteParticipantsHandler(id: string) { + const filteredChannelIds = selectedChannelIds.filter((channelId) => channelId !== id); + setSelectedChannelIds(filteredChannelIds); + } + + return ( + +
+ + +

+ {renderText(lang('BoostingBoostsViaGifts'))} +

+
+ {renderText(lang('BoostingGetMoreBoost'))} +
+
+

+ {lang('BoostingBoostsViaGifts')} +

+
+ {dataPrepaidGiveaway ? ( +
+
+ +
+
+

+ {lang('BoostingTelegramPremiumCountPlural', dataPrepaidGiveaway.quantity)} +

+

{lang('PrepaidGiveawayMonths', dataPrepaidGiveaway.months)}

+
+
+
+ +
+ {dataPrepaidGiveaway.quantity * (giveawayBoostPerPremiumLimit ?? GIVEAWAY_BOOST_PER_PREMIUM)} +
+
+
+
+ ) : ( +
+ {renderTypeOptions()} +
+ )} + + {isRandomUsers && ( + <> + {!dataPrepaidGiveaway && ( + <> +
+
+

+ {lang('BoostingQuantityPrizes')} +

+
+ +
+ {boostQuantity} +
+
+
+ + +
+ +
+ {renderText(lang('BoostingChooseHowMany'))} +
+ + )} + +
+

+ {lang('BoostingChannelsIncludedGiveaway')} +

+ + + + + + {selectedChannelIds?.map((channelId) => { + return ( + deleteParticipantsHandler(channelId)} + rightElement={()} + > + + + ); + })} + + {selectedChannelIds.length < MAX_ADDITIONAL_CHANNELS && ( + + {lang('BoostingAddChannel')} + + )} +
+ +
+

+ {lang('BoostingEligibleUsers')} +

+ + {renderSubscribersOptions()} +
+ +
+ {renderText(lang('BoostGift.LimitSubscribersInfo'))} +
+ +
+
+

+ {lang('BoostingGiveawayAdditionalPrizes')} +

+ + +
+ + {shouldShowPrizes && ( +
+

+ {dataPrepaidGiveaway ? dataPrepaidGiveaway.quantity : selectedUserCount} +

+ +
+ )} +
+ + {shouldShowPrizes ? ( +
+ {prizeDescription?.length ? renderText(lang('BoostingGiveawayAdditionPrizeCountNameHint', + dataPrepaidGiveaway + ? [dataPrepaidGiveaway.quantity, prizeDescription, monthQuantity] + : [selectedUserCount, prizeDescription, monthQuantity], + undefined, + selectedMonthOption), ['simple_markdown']) : renderText(lang('BoostingGiveawayAdditionPrizeCountHint', + dataPrepaidGiveaway + ? [dataPrepaidGiveaway.quantity, monthQuantity] + : [selectedUserCount, monthQuantity], + undefined, + selectedMonthOption), ['simple_markdown'])} +
+ ) : ( +
+ {renderText(lang('BoostingGiveawayAdditionPrizeHint'))} +
+ )} + +
+
+

+ {lang('BoostingGiveawayShowWinners')} +

+ + +
+
+ +
+ {renderText(lang('BoostingGiveawayShowWinnersHint'))} +
+ +
+

+ {lang('BoostingDateWhenGiveawayEnds')} +

+ + +
+ + )} + + {!dataPrepaidGiveaway && ( + <> +
+

+ {lang('BoostingDurationOfPremium')} +

+ + {renderSubscriptionOptions()} +
+ +
+ {renderPremiumFeaturesLink()} +
+ + )} + + {selectedGiveawayOption && ( +
+ +
+ )} +
+ + + + +
+ ); +}; + +export default memo(withGlobal((global): StateProps => { + const { + giveawayModal, + } = selectTabState(global); + + return { + chatId: giveawayModal?.chatId, + gifts: giveawayModal?.gifts, + selectedMemberList: giveawayModal?.selectedMemberIds, + selectedChannelList: giveawayModal?.selectedChannelIds, + giveawayBoostPerPremiumLimit: global.appConfig?.giveawayBoostsPerPremium, + userSelectionLimit: global.appConfig?.giveawayAddPeersMax, + countrySelectionLimit: global.appConfig?.giveawayCountriesMax, + countryList: global.countryList.general, + prepaidGiveaway: giveawayModal?.prepaidGiveaway, + }; +})(GiveawayModal)); diff --git a/src/components/main/premium/GiveawayTypeOption.module.scss b/src/components/main/premium/GiveawayTypeOption.module.scss new file mode 100644 index 000000000..ff1120062 --- /dev/null +++ b/src/components/main/premium/GiveawayTypeOption.module.scss @@ -0,0 +1,99 @@ +.wrapper { + position: relative; + display: block; + padding-inline: 3.5rem 1rem; + border: none; + margin-bottom: 0; + + cursor: var(--custom-cursor, pointer); + + line-height: 1.5rem; +} + +.input { + position: absolute; + z-index: var(--z-below); + opacity: 0; + &:checked ~ .content { + &::before { + border-color: var(--color-primary); + } + + &::after { + opacity: 1; + } + } +} + +.input:not(:checked) ~ .content.notChecked::before, +.input:not(:checked) ~ .content.notChecked::after { + border-color: transparent; + opacity: 0; +} + +.content { + display: flex; + align-items: center; + padding: 0.5rem 0; + gap: 1.25rem; + + &::before, + &::after { + content: ""; + display: block; + position: absolute; + inset-inline-start: 1.0625rem; + top: 50%; + width: 1.25rem; + height: 1.25rem; + transform: translateY(-50%); + } + + &::before { + border: 2px solid var(--color-borders-input); + border-radius: 50%; + background-color: var(--color-background); + opacity: 1; + transition: border-color 0.1s ease, opacity 0.1s ease; + } + + &::after { + inset-inline-start: 1.375rem; + width: 0.625rem; + height: 0.625rem; + border-radius: 50%; + background: var(--color-primary); + opacity: 0; + transition: opacity 0.1s ease; + } +} + +.giveaway { + display: flex; + flex-direction: column; + font-size: 0.875rem; + color: var(--color-text-secondary); +} + +.title { + color: var(--color-text); + margin-bottom: 0; +} + +.link { + display: flex; + align-items: center; + + color: var(--color-links); +} + +.optionImg { + width: 2.75rem; + height: 2.75rem; +} + +@media (max-width: 450px) { + .contentText { + line-height: 1rem; + } +} diff --git a/src/components/main/premium/GiveawayTypeOption.tsx b/src/components/main/premium/GiveawayTypeOption.tsx new file mode 100644 index 000000000..db6eb7c31 --- /dev/null +++ b/src/components/main/premium/GiveawayTypeOption.tsx @@ -0,0 +1,90 @@ +import type { ChangeEvent } from 'react'; +import type { FC } from '../../../lib/teact/teact'; +import React, { memo } from '../../../lib/teact/teact'; + +import buildClassName from '../../../util/buildClassName'; + +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; + +import Icon from '../../common/Icon'; + +import styles from './GiveawayTypeOption.module.scss'; + +type ApiGiveawayType = 'random_users' | 'specific_users'; + +type OwnProps = { + option: ApiGiveawayType; + name: string; + text: string; + img: string; + checked?: boolean; + isLink: boolean; + className?: string; + onChange: (value: ApiGiveawayType) => void; + onClickAction?: () => void; + userNames?: string; + selectedMemberIds?: string[]; +}; + +const GiveawayTypeOption: FC = ({ + option, checked, + name, text, img, + isLink, onChange, onClickAction, className, + userNames, selectedMemberIds, +}) => { + const lang = useLang(); + + let displayText: string | undefined = lang(text); + if (isLink && selectedMemberIds?.length) { + displayText = selectedMemberIds.length > 2 ? `${selectedMemberIds.length}` : userNames; + } + + const handleChange = useLastCallback((e: ChangeEvent) => { + if (e.target.checked) { + onChange(option); + } + }); + + const handleClick = useLastCallback(() => { + onClickAction?.(); + }); + + return ( + + ); +}; + +export default memo(GiveawayTypeOption); diff --git a/src/components/main/premium/PremiumMainModal.tsx b/src/components/main/premium/PremiumMainModal.tsx index fab84f77a..f04a9efc4 100644 --- a/src/components/main/premium/PremiumMainModal.tsx +++ b/src/components/main/premium/PremiumMainModal.tsx @@ -166,6 +166,7 @@ const PremiumMainModal: FC = ({ if (premiumSlug) { openInvoice({ + type: 'slug', slug: premiumSlug, }); } else if (premiumBotUsername) { diff --git a/src/components/main/premium/PremiumSubscriptionOption.module.scss b/src/components/main/premium/PremiumSubscriptionOption.module.scss index ac70a1067..7c55417e0 100644 --- a/src/components/main/premium/PremiumSubscriptionOption.module.scss +++ b/src/components/main/premium/PremiumSubscriptionOption.module.scss @@ -12,6 +12,16 @@ line-height: 1.5rem; } +.giveawayWrapper { + position: relative; + display: block; + padding-inline: 3.6875rem 1rem; + + cursor: var(--custom-cursor, pointer); + + line-height: 1.5rem; +} + .active { box-shadow: 0 0 0 0.125rem var(--color-primary); } @@ -108,3 +118,10 @@ margin-inline-end: 0.5rem; align-self: baseline; } + +.giveawayDiscount { + font-size: 0.8125rem; + font-weight: 500; + border-radius: 0.375rem; + padding: 0.1875rem; +} diff --git a/src/components/main/premium/PremiumSubscriptionOption.tsx b/src/components/main/premium/PremiumSubscriptionOption.tsx index 7f510e11b..7f510ac50 100644 --- a/src/components/main/premium/PremiumSubscriptionOption.tsx +++ b/src/components/main/premium/PremiumSubscriptionOption.tsx @@ -2,6 +2,8 @@ import type { ChangeEvent } from 'react'; import type { FC } from '../../../lib/teact/teact'; import React, { memo, useCallback, useMemo } from '../../../lib/teact/teact'; +import type { ApiPremiumGiftCodeOption, ApiPremiumGiftOption } from '../../../api/types'; + import buildClassName from '../../../util/buildClassName'; import { formatCurrency } from '../../../util/formatCurrency'; @@ -10,11 +12,9 @@ import useLang from '../../../hooks/useLang'; import styles from './PremiumSubscriptionOption.module.scss'; type OwnProps = { - option: { - months: number; - currency: string; - amount: number; - }; + option: ApiPremiumGiftOption | ApiPremiumGiftCodeOption; + isGiveaway?: boolean; + userCount?: number; checked?: boolean; fullMonthlyAmount?: number; className?: string; @@ -22,11 +22,14 @@ type OwnProps = { }; const PremiumSubscriptionOption: FC = ({ - option, checked, fullMonthlyAmount, onChange, className, + option, checked, fullMonthlyAmount, + onChange, className, isGiveaway, userCount, }) => { const lang = useLang(); - const { months, amount, currency } = option; + const { + months, amount, currency, + } = option; const perMonth = Math.floor(amount / months); const discount = useMemo(() => { @@ -44,8 +47,8 @@ const PremiumSubscriptionOption: FC = ({ return ( diff --git a/src/components/ui/RadioGroup.module.scss b/src/components/ui/RadioGroup.module.scss new file mode 100644 index 000000000..c60a23e51 --- /dev/null +++ b/src/components/ui/RadioGroup.module.scss @@ -0,0 +1,10 @@ +.wrapper { + position: relative; + display: block; + border: none; + margin-bottom: 0; + + cursor: var(--custom-cursor, pointer); + + line-height: 1.5rem; +} diff --git a/src/components/ui/RadioGroup.tsx b/src/components/ui/RadioGroup.tsx index a19ee6e30..416b7e864 100644 --- a/src/components/ui/RadioGroup.tsx +++ b/src/components/ui/RadioGroup.tsx @@ -2,12 +2,14 @@ import type { ChangeEvent } from 'react'; import type { FC, TeactNode } from '../../lib/teact/teact'; import React, { memo, useCallback } from '../../lib/teact/teact'; +import useLastCallback from '../../hooks/useLastCallback'; + import Radio from './Radio'; -export type IRadioOption = { +export type IRadioOption = { label: TeactNode; subLabel?: string; - value: string; + value: T; hidden?: boolean; className?: string; }; @@ -20,6 +22,10 @@ type OwnProps = { disabled?: boolean; loadingOption?: string; onChange: (value: string, event: ChangeEvent) => void; + onClickAction?: (value: string) => void; + isLink?: boolean; + subLabelClassName?: string; + subLabel?: string | undefined; }; const RadioGroup: FC = ({ @@ -30,19 +36,28 @@ const RadioGroup: FC = ({ disabled, loadingOption, onChange, + onClickAction, + subLabelClassName, + isLink, + subLabel, }) => { const handleChange = useCallback((event: ChangeEvent) => { const { value } = event.currentTarget; onChange(value, event); }, [onChange]); + const onSubLabelClick = useLastCallback((value: string) => () => { + onClickAction?.(value); + }); + return (
{options.map((option) => (
diff --git a/src/components/ui/RangeSliderWithMarks.module.scss b/src/components/ui/RangeSliderWithMarks.module.scss new file mode 100644 index 000000000..4fe04bee9 --- /dev/null +++ b/src/components/ui/RangeSliderWithMarks.module.scss @@ -0,0 +1,132 @@ +@use '../../styles/mixins'; + +@mixin thumb-styles() { + border: none; + width: 1rem; + height: 1rem; + background: var(--color-links); + border-radius: 50%; + cursor: var(--custom-cursor, pointer); + transform: scale(1); + transition: transform 0.30s ease-in-out; + z-index: 2; + -webkit-appearance: none; + appearance: none; + + &:hover { + transform: scale(1.5); + } +} + +.dotWrapper { + width: 90%; + margin-left: 1rem; + padding: 1rem 0 0; +} + +.sliderContainer { + --fill-percentage: 0%; + position: relative; + width: 100%; +} + +.marksContainer { + width: 100%; + pointer-events: none; + display: flex; + justify-content: space-between; +} + +.mark { + font-size: 0.8125rem; + font-weight: 700; + color: var(--color-text-secondary); + position: relative; + display: flex; + justify-content: center; + width: 0.1875rem; +} + +.active { + color: var(--color-links); +} + +.slider { + position: relative; + -webkit-appearance: none; + width: 100%; + height: 0.1875rem; + outline: none; + transition: opacity 0.2s; + z-index: 1; + + background: linear-gradient(to right, + var(--color-links) 0%, + var(--color-links) var(--fill-percentage), + var(--color-text-secondary) var(--fill-percentage), + var(--color-text-secondary) 100%); +} + +.slider::before, +.slider::after { + content: ''; + position: absolute; + top: 0; + height: 100%; + transition: transform 0.2s ease; + z-index: -1; + transform-origin: left; +} + +.slider::before { + left: 0; + background-color: var(--color-links); + transform: scaleX(var(--fill-percentage-before)); +} + +.slider::after { + right: 0; + background: var(--color-text-secondary); + transform: scaleX(var(--fill-percentage-after)); + transform-origin: right; +} + +.slider::-webkit-slider-thumb { + @include thumb-styles(); +} + +.slider::-moz-range-thumb { + @include thumb-styles(); +} + +.slider::-ms-thumb { + @include thumb-styles(); +} + +.tickMarks { + position: absolute; + top: 2rem; + left: 0; + right: 0; + height: 100%; + display: flex; + justify-content: space-between; + pointer-events: none; + z-index: 1; +} + +.tick { + position: relative; + width: 0.25rem; + height: 0.5rem; + background-color: var(--color-text-secondary); + border-radius: 0.1875rem; +} + +.filled { + background-color: var(--color-links); +} + +.tickUnfilled { + background-color: var(--color-text-secondary); +} diff --git a/src/components/ui/RangeSliderWithMarks.tsx b/src/components/ui/RangeSliderWithMarks.tsx new file mode 100644 index 000000000..3d9135002 --- /dev/null +++ b/src/components/ui/RangeSliderWithMarks.tsx @@ -0,0 +1,85 @@ +import type { ChangeEvent } from 'react'; +import type { FC } from '../../lib/teact/teact'; +import React, { + memo, useLayoutEffect, useMemo, useRef, +} from '../../lib/teact/teact'; + +import buildClassName from '../../util/buildClassName'; + +import styles from './RangeSliderWithMarks.module.scss'; + +export type OwnProps = { + marks: number[]; + onChange: (value: number) => void; + rangeCount: number; +}; + +const RangeSliderWithMarks: FC = ({ marks, onChange, rangeCount }) => { + // eslint-disable-next-line no-null/no-null + const sliderRef = useRef(null); + + const fillPercentage = useMemo(() => { + return ((marks.indexOf(rangeCount) / (marks.length - 1)) * 100).toFixed(2); + }, [marks, rangeCount]); + + const rangeCountIndex = useMemo(() => marks.indexOf(rangeCount), [marks, rangeCount]); + + const rangeValue = useMemo(() => { + return marks.indexOf(rangeCount).toString(); + }, [marks, rangeCount]); + + useLayoutEffect(() => { + sliderRef.current!.style.setProperty('--fill-percentage', `${fillPercentage}%`); + }, [fillPercentage]); + + const handleChange = (event: ChangeEvent) => { + const index = parseInt(event.target.value, 10); + const newValue = marks[index]; + onChange(newValue); + }; + + return ( +
+
+
+
+ {marks.map((mark, index) => { + const isFilled = index <= rangeCountIndex; + return ( +
+ ); + })} +
+
+ {marks.map((mark) => ( +
+ {mark} +
+ ))} +
+ +
+ +
+ ); +}; + +export default memo(RangeSliderWithMarks); diff --git a/src/config.ts b/src/config.ts index b349e5aca..4126d9fee 100644 --- a/src/config.ts +++ b/src/config.ts @@ -312,6 +312,10 @@ export const GENERAL_TOPIC_ID = 1; export const STORY_EXPIRE_PERIOD = 86400; // 1 day export const STORY_VIEWERS_EXPIRE_PERIOD = 86400; // 1 day export const FRESH_AUTH_PERIOD = 86400; // 1 day +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 LIGHT_THEME_BG_COLOR = '#99BA92'; export const DARK_THEME_BG_COLOR = '#0F0F0F'; diff --git a/src/global/actions/api/bots.ts b/src/global/actions/api/bots.ts index ef8365491..e9e0c3d98 100644 --- a/src/global/actions/api/bots.ts +++ b/src/global/actions/api/bots.ts @@ -104,6 +104,7 @@ addActionHandler('clickBotInlineButton', (global, actions, payload): ActionRetur return; } actions.openInvoice({ + type: 'message', chatId: chat.id, messageId, tabId, diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index 529cb1205..32e46090c 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -1403,11 +1403,13 @@ addActionHandler('openTelegramLink', (global, actions, payload): ActionReturnTyp }); } else if (part1.startsWith('$')) { openInvoice({ + type: 'slug', slug: part1.substring(1), tabId, }); } else if (part1 === 'invoice') { openInvoice({ + type: 'slug', slug: part2, tabId, }); diff --git a/src/global/actions/api/payments.ts b/src/global/actions/api/payments.ts index 8ee5d161c..93e8f6ae0 100644 --- a/src/global/actions/api/payments.ts +++ b/src/global/actions/api/payments.ts @@ -7,9 +7,11 @@ import { DEBUG_PAYMENT_SMART_GLOCAL } from '../../../config'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { buildCollectionByKey, unique } from '../../../util/iteratees'; import * as langProvider from '../../../util/langProvider'; +import { getStripeError } from '../../../util/payments/stripe'; import { buildQueryString } from '../../../util/requestQuery'; import { callApi } from '../../../api/gramjs'; -import { getStripeError, isChatChannel, isChatSuperGroup } from '../../helpers'; +import { isChatChannel, isChatSuperGroup } from '../../helpers'; +import { getRequestInputInvoice } from '../../helpers/payments'; import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { addChats, @@ -48,38 +50,24 @@ addActionHandler('validateRequestedInfo', (global, actions, payload): ActionRetu return; } - if ('slug' in inputInvoice) { - void validateRequestedInfo(global, inputInvoice, requestInfo, saveInfo, tabId); - } else { - const chat = selectChat(global, inputInvoice.chatId); - if (!chat) { - return; - } - - void validateRequestedInfo(global, { - chat, - messageId: inputInvoice.messageId, - }, requestInfo, saveInfo, tabId); + const requestInputInvoice = getRequestInputInvoice(global, inputInvoice); + if (!requestInputInvoice) { + return; } + + validateRequestedInfo(global, requestInputInvoice, requestInfo, saveInfo, tabId); }); addActionHandler('openInvoice', async (global, actions, payload): Promise => { - const { tabId = getCurrentTabId() } = payload; - let invoice: ApiInvoice | undefined; - if ('slug' in payload) { - invoice = await getPaymentForm(global, { slug: payload.slug }, tabId); - } else { - const chat = selectChat(global, payload.chatId); - if (!chat) { - return; - } + const { tabId = getCurrentTabId(), ...inputInvoice } = payload; - invoice = await getPaymentForm(global, { - chat, - messageId: payload.messageId, - }, tabId); + const requestInputInvoice = getRequestInputInvoice(global, inputInvoice); + if (!requestInputInvoice) { + return; } + const invoice = await getPaymentForm(global, requestInputInvoice, tabId); + if (!invoice) { return; } @@ -200,21 +188,9 @@ addActionHandler('sendPaymentForm', async (global, actions, payload): Promise => { + const { + chatId, prepaidGiveaway, + tabId = getCurrentTabId(), + } = payload; + + const chat = selectChat(global, chatId); + if (!chat) return; + + const result = await callApi('getPremiumGiftCodeOptions', { + chat, + }); + + if (!result) { + return; + } + + global = getGlobal(); + + const isOpen = Boolean(chatId); + + global = updateTabState(global, { + giveawayModal: { + chatId, + gifts: result, + isOpen, + prepaidGiveaway, + }, + }, tabId); + setGlobal(global); +}); + +addActionHandler('closeGiveawayModal', (global, actions, payload): ActionReturnType => { + const { tabId = getCurrentTabId() } = payload || {}; + + return updateTabState(global, { + giveawayModal: undefined, + }, tabId); +}); + addActionHandler('openGiftPremiumModal', async (global, actions, payload): Promise => { const { forUserId, tabId = getCurrentTabId() } = payload || {}; const result = await callApi('fetchPremiumPromo'); @@ -484,7 +500,7 @@ addActionHandler('openBoostModal', async (global, actions, payload): Promise => { + const { + chatId, giveawayId, paymentPurpose, tabId = getCurrentTabId(), + } = payload; + + const chat = selectChat(global, chatId); + if (!chat) return; + + const additionalChannels = paymentPurpose?.additionalChannelIds?.map((id) => selectChat(global, id)).filter(Boolean); + + const result = await callApi('launchPrepaidGiveaway', { + chat, + giveawayId, + paymentPurpose: { + type: 'giveaway', + chat, + areWinnersVisible: paymentPurpose?.areWinnersVisible, + additionalChannels, + countries: paymentPurpose?.countries, + prizeDescription: paymentPurpose.prizeDescription, + untilDate: paymentPurpose.untilDate, + currency: paymentPurpose.currency, + amount: paymentPurpose.amount, + }, + }); + + if (!result) { + return; + } + + actions.openBoostStatistics({ chatId, tabId }); +}); diff --git a/src/global/helpers/index.ts b/src/global/helpers/index.ts index f9c672caf..d1e940697 100644 --- a/src/global/helpers/index.ts +++ b/src/global/helpers/index.ts @@ -4,7 +4,6 @@ export * from './messages'; export * from './messageSummary'; export * from './messageMedia'; export * from './localSearch'; -export * from './payments'; export * from './reactions'; export * from './bots'; export * from './media'; diff --git a/src/global/helpers/payments.ts b/src/global/helpers/payments.ts index fe8c20b4f..fb2077a90 100644 --- a/src/global/helpers/payments.ts +++ b/src/global/helpers/payments.ts @@ -1,51 +1,73 @@ -import type { ApiFieldError } from '../../api/types'; +import type { ApiInputInvoice, ApiRequestInputInvoice } from '../../api/types'; +import type { GlobalState } from '../types'; -const STRIPE_ERRORS: Record = { - missing_payment_information: { - field: 'cardNumber', - message: 'Incorrect card number', - }, - invalid_number: { - field: 'cardNumber', - message: 'Incorrect card number', - }, - number: { - field: 'cardNumber', - message: 'Incorrect card number', - }, - exp_year: { - field: 'expiry', - message: 'Incorrect year', - }, - exp_month: { - field: 'expiry', - message: 'Incorrect month', - }, - invalid_expiry_year: { - field: 'expiry', - message: 'Incorrect year', - }, - invalid_expiry_month: { - field: 'expiry', - message: 'Incorrect month', - }, - cvc: { - field: 'cvv', - message: 'Incorrect CVV', - }, - invalid_cvc: { - field: 'cvv', - message: 'Incorrect CVV', - }, -}; +import { selectChat, selectUser } from '../selectors'; -export function getStripeError(error: { - code: string; - message: string; - param?: string; -}) { - const { message: description, code, param } = error; - const { field, message } = param ? STRIPE_ERRORS[param] : STRIPE_ERRORS[code]; +export function getRequestInputInvoice( + global: T, inputInvoice: ApiInputInvoice, +): ApiRequestInputInvoice | undefined { + if (inputInvoice.type === 'slug') return inputInvoice; - return { field, message, description }; + if (inputInvoice.type === 'message') { + const chat = selectChat(global, inputInvoice.chatId); + if (!chat) { + return undefined; + } + return { + type: 'message', + chat, + messageId: inputInvoice.messageId, + }; + } + + if (inputInvoice.type === 'giftcode') { + const { + userIds, boostChannelId, amount, currency, option, + } = inputInvoice; + const users = userIds.map((id) => selectUser(global, id)).filter(Boolean); + const boostChannel = boostChannelId ? selectChat(global, boostChannelId) : undefined; + + return { + type: 'giveaway', + option, + purpose: { + type: 'giftcode', + amount, + currency, + users, + boostChannel, + }, + }; + } + + if (inputInvoice.type === 'giveaway') { + const { + chatId, additionalChannelIds, amount, currency, option, untilDate, areWinnersVisible, countries, + isOnlyForNewSubscribers, prizeDescription, + } = inputInvoice; + const chat = selectChat(global, chatId); + if (!chat) { + return undefined; + } + const additionalChannels = additionalChannelIds?.map((id) => selectChat(global, id)).filter(Boolean); + + return { + type: 'giveaway', + option, + purpose: { + type: 'giveaway', + amount, + currency, + chat, + additionalChannels, + untilDate, + areWinnersVisible, + countries, + isOnlyForNewSubscribers, + prizeDescription, + }, + }; + } + + return undefined; } diff --git a/src/global/selectors/localSearch.ts b/src/global/selectors/localSearch.ts index 22cf452f4..b108850bf 100644 --- a/src/global/selectors/localSearch.ts +++ b/src/global/selectors/localSearch.ts @@ -1,7 +1,7 @@ import type { GlobalState, TabArgs } from '../types'; import { getCurrentTabId } from '../../util/establishMultitabRole'; -import { buildChatThreadKey } from '../helpers'; +import { buildChatThreadKey } from '../helpers/localSearch'; import { selectCurrentMessageList } from './messages'; import { selectTabState } from './tabs'; diff --git a/src/global/selectors/ui.ts b/src/global/selectors/ui.ts index 4c3d77ed2..ee2d66bd2 100644 --- a/src/global/selectors/ui.ts +++ b/src/global/selectors/ui.ts @@ -4,7 +4,7 @@ import type { GlobalState, TabArgs } from '../types'; import { NewChatMembersProgress, RightColumnContent } from '../../types'; import { getCurrentTabId } from '../../util/establishMultitabRole'; -import { getMessageVideo, getMessageWebPageVideo } from '../helpers'; +import { getMessageVideo, getMessageWebPageVideo } from '../helpers/messageMedia'; import { selectCurrentTextSearch } from './localSearch'; import { selectCurrentManagement } from './management'; import { selectIsStatisticsShown } from './statistics'; diff --git a/src/global/selectors/users.ts b/src/global/selectors/users.ts index f46205071..a16f0115c 100644 --- a/src/global/selectors/users.ts +++ b/src/global/selectors/users.ts @@ -31,6 +31,10 @@ export function selectIsPremiumPurchaseBlocked(global: T) return global.appConfig?.isPremiumPurchaseBlocked ?? true; } +export function selectIsGiveawayGiftsPurchaseAvailable(global: T) { + return global.appConfig?.isGiveawayGiftsPurchaseAvailable ?? true; +} + // Slow, not to be used in `withGlobal` export function selectUserByPhoneNumber(global: T, phoneNumber: string) { const phoneNumberCleaned = phoneNumber.replace(/[^0-9]/g, ''); diff --git a/src/global/types.ts b/src/global/types.ts index a1dde2388..c25f75bce 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -44,7 +44,9 @@ import type { ApiPhoneCall, ApiPhoto, ApiPostStatistics, + ApiPremiumGiftCodeOption, ApiPremiumPromo, + ApiPrepaidGiveaway, ApiQuickReply, ApiReaction, ApiReactionKey, @@ -625,6 +627,15 @@ export type TabState = { isSuccess?: boolean; }; + giveawayModal?: { + chatId: string; + isOpen?: boolean; + gifts?: ApiPremiumGiftCodeOption[]; + selectedMemberIds?: string[]; + selectedChannelIds?: string[]; + prepaidGiveaway?: ApiPrepaidGiveaway; + }; + giftPremiumModal?: { isOpen?: boolean; forUserId?: string; @@ -1989,6 +2000,20 @@ export interface ActionPayloads { } & WithTabId; closeGiftCodeModal: WithTabId | undefined; + launchPrepaidGiveaway: { + chatId: string; + giveawayId: string; + paymentPurpose: { + additionalChannelIds?: string[]; + areWinnersVisible?: boolean; + countries?: string[]; + prizeDescription?: string; + untilDate: number; + currency: string; + amount: number; + }; + } & WithTabId; + checkChatlistInvite: { slug: string; } & WithTabId; @@ -2914,6 +2939,13 @@ export interface ActionPayloads { } & WithTabId) | undefined; closePremiumModal: WithTabId | undefined; + openGiveawayModal: ({ + chatId: string; + gifts?: number[] | undefined; + prepaidGiveaway?: ApiPrepaidGiveaway | undefined; + } & WithTabId); + closeGiveawayModal: WithTabId | undefined; + transcribeAudio: { chatId: string; messageId: number; diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 8fddbf65e..e54dee53c 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1525,9 +1525,11 @@ payments.getPaymentReceipt#2478d1cc peer:InputPeer msg_id:int = payments.Payment payments.validateRequestedInfo#b6c8f12b flags:# save:flags.0?true invoice:InputInvoice info:PaymentRequestedInfo = payments.ValidatedRequestedInfo; payments.sendPaymentForm#2d03522f flags:# form_id:long invoice:InputInvoice requested_info_id:flags.0?string shipping_option_id:flags.1?string credentials:InputPaymentCredentials tip_amount:flags.2?long = payments.PaymentResult; payments.getSavedInfo#227d824b = payments.SavedInfo; +payments.getPremiumGiftCodeOptions#2757ba54 flags:# boost_peer:flags.0?InputPeer = Vector; payments.checkGiftCode#8e51b4c1 slug:string = payments.CheckedGiftCode; payments.applyGiftCode#f6e26854 slug:string = Updates; payments.getGiveawayInfo#f4239425 peer:InputPeer msg_id:int = payments.GiveawayInfo; +payments.launchPrepaidGiveaway#5ff58f20 peer:InputPeer giveaway_id:long purpose:InputStorePaymentPurpose = Updates; phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall; phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall; phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index b064cc861..3283f6f85 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -343,5 +343,7 @@ "premium.getBoostsList", "payments.checkGiftCode", "payments.applyGiftCode", - "payments.getGiveawayInfo" + "payments.getGiveawayInfo", + "payments.getPremiumGiftCodeOptions", + "payments.launchPrepaidGiveaway" ] diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index 398c3e475..14d690522 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -101,8 +101,8 @@ $color-message-story-mention-to: #74bcff; --color-primary-shade: #{color.mix($color-primary, $color-black, 92%)}; --color-primary-shade-darker: #{color.mix($color-primary, $color-black, 84%)}; --color-primary-shade-rgb: #{toRGB(color.mix($color-primary, $color-black, 92%))}; - --color-primary-opacity: rgba(var(--color-primary), 0.25); - --color-primary-opacity-hover: rgba(var(--color-primary), 0.15); + --color-primary-opacity: rgba(var(--color-primary), 0.15); + --color-primary-opacity-hover: rgba(var(--color-primary), 0.25); --color-primary-tint: rgba(var(--color-primary), 0.1); --color-green: #{$color-green}; --color-green-darker: #{color.mix($color-green, $color-black, 84%)}; diff --git a/src/styles/themes.json b/src/styles/themes.json index cc06e0c43..081f4f5d1 100644 --- a/src/styles/themes.json +++ b/src/styles/themes.json @@ -1,7 +1,7 @@ { "--color-primary": ["#3390EC", "#8774E1"], - "--color-primary-opacity": ["#50A2E940", "#8378DB80"], - "--color-primary-opacity-hover": ["#50A2E926", "#8378DBA0"], + "--color-primary-opacity": ["#50A2E91E", "#8378DB1E"], + "--color-primary-opacity-hover": ["#50A2E940", "#8378DB40"], "--color-primary-tint": ["#3390ec1a", "#8774e11a"], "--color-primary-shade": ["#4a95d6", "#7b71c6"], "--color-background": ["#FFFFFF", "#212121"], diff --git a/src/util/deeplink.ts b/src/util/deeplink.ts index dccf11317..f519174da 100644 --- a/src/util/deeplink.ts +++ b/src/util/deeplink.ts @@ -150,7 +150,7 @@ export const processDeepLink = (url: string): boolean => { case 'invoice': { const { slug } = params; - openInvoice({ slug }); + openInvoice({ type: 'slug', slug }); break; } diff --git a/src/util/payments/stripe.ts b/src/util/payments/stripe.ts new file mode 100644 index 000000000..fe8c20b4f --- /dev/null +++ b/src/util/payments/stripe.ts @@ -0,0 +1,51 @@ +import type { ApiFieldError } from '../../api/types'; + +const STRIPE_ERRORS: Record = { + missing_payment_information: { + field: 'cardNumber', + message: 'Incorrect card number', + }, + invalid_number: { + field: 'cardNumber', + message: 'Incorrect card number', + }, + number: { + field: 'cardNumber', + message: 'Incorrect card number', + }, + exp_year: { + field: 'expiry', + message: 'Incorrect year', + }, + exp_month: { + field: 'expiry', + message: 'Incorrect month', + }, + invalid_expiry_year: { + field: 'expiry', + message: 'Incorrect year', + }, + invalid_expiry_month: { + field: 'expiry', + message: 'Incorrect month', + }, + cvc: { + field: 'cvv', + message: 'Incorrect CVV', + }, + invalid_cvc: { + field: 'cvv', + message: 'Incorrect CVV', + }, +}; + +export function getStripeError(error: { + code: string; + message: string; + param?: string; +}) { + const { message: description, code, param } = error; + const { field, message } = param ? STRIPE_ERRORS[param] : STRIPE_ERRORS[code]; + + return { field, message, description }; +}