import type { FC } from '../../lib/teact/teact'; import React, { memo, useCallback, useEffect, useMemo, useState, } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; import type { ApiChat, ApiCountry, ApiInvoice, ApiLabeledPrice, ApiPaymentCredentials, ApiPaymentFormRegular, } from '../../api/types'; import type { TabState } from '../../global/types'; import type { FormState } from '../../hooks/reducers/usePaymentReducer'; import type { ShippingOption } from '../../types'; import type { PaymentFormSubmitEvent } from './ConfirmPayment'; import { PaymentStep } from '../../types'; import { getUserFullName } from '../../global/helpers'; import { selectChat, selectTabState, selectUser } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import captureKeyboardListeners from '../../util/captureKeyboardListeners'; import { formatCurrencyAsString } from '../../util/formatCurrency'; import { detectCardTypeText } from '../common/helpers/detectCardType'; import usePaymentReducer from '../../hooks/reducers/usePaymentReducer'; 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 Icon from '../common/icons/Icon'; import Button from '../ui/Button'; import Modal from '../ui/Modal'; import Spinner from '../ui/Spinner'; import Transition from '../ui/Transition'; import Checkout from './Checkout'; import ConfirmPayment from './ConfirmPayment'; import PasswordConfirm from './PasswordConfirm'; import PaymentInfo from './PaymentInfo'; import SavedPaymentCredentials from './SavedPaymentCredentials'; import Shipping from './Shipping'; import ShippingInfo from './ShippingInfo'; import './PaymentModal.scss'; const DEFAULT_PROVIDER = 'stripe'; const DONATE_PROVIDER = 'smartglocal'; const DONATE_PROVIDER_URL = 'https://payment.smart-glocal.com'; const SUPPORTED_PROVIDERS = new Set([DEFAULT_PROVIDER, DONATE_PROVIDER]); export type OwnProps = { isOpen?: boolean; onClose: NoneToVoidFunction; }; type StateProps = { step?: PaymentStep; chat?: ApiChat; nativeProvider?: string; invoice?: ApiInvoice; form?: ApiPaymentFormRegular; error?: TabState['payment']['error']; prices?: ApiLabeledPrice[]; isProviderError?: boolean; needCardholderName?: boolean; needCountry?: boolean; needZip?: boolean; confirmPaymentUrl?: string; countryList: ApiCountry[]; hasShippingOptions?: boolean; shippingOptions?: ShippingOption[]; requestId?: string; smartGlocalToken?: string; stripeId?: string; savedCredentials?: ApiPaymentCredentials[]; passwordValidUntil?: number; isExtendedMedia?: boolean; isPaymentFormUrl?: boolean; botName?: string; }; const NETWORK_REQUEST_TIMEOUT_S = 3; const PaymentModal: FC = ({ isOpen, onClose, step, shippingOptions, form, isProviderError, invoice, nativeProvider, needCardholderName, needCountry, needZip, confirmPaymentUrl, error, countryList, hasShippingOptions, requestId, smartGlocalToken, stripeId, savedCredentials, passwordValidUntil, isExtendedMedia, isPaymentFormUrl, botName, }) => { const { loadPasswordInfo, validateRequestedInfo, sendPaymentForm, setPaymentStep, sendCredentialsInfo, clearPaymentError, validatePaymentPassword, setSmartGlocalCardInfo, } = getActions(); const oldLang = useOldLang(); const lang = useLang(); const [isModalOpen, openModal, closeModal] = useFlag(); const [paymentState, paymentDispatch] = usePaymentReducer(); const [isLoading, setIsLoading] = useState(false); const [isTosAccepted, setIsTosAccepted] = useState(false); const [twoFaPassword, setTwoFaPassword] = useState(''); const prevStep = usePreviousDeprecated(step, true); const prevRequestId = usePreviousDeprecated(requestId); const canRenderFooter = step !== PaymentStep.ConfirmPayment; const setStep = useCallback((nextStep) => { setPaymentStep({ step: nextStep }); }, [setPaymentStep]); useEffect(() => { if (isOpen) { setTwoFaPassword(''); loadPasswordInfo(); openModal(); } }, [isOpen, loadPasswordInfo, openModal]); // Modal window can be closed by an event from the server side useEffect(() => { if (!isOpen && isModalOpen) { closeModal(); } }, [closeModal, isModalOpen, isOpen]); useEffect(() => { if (step !== undefined || error) { setIsLoading(false); } }, [step, error, requestId]); // When payment verification occurs and the `step` does not change, the card details must be requested useEffect(() => { if ( step === PaymentStep.Checkout && step === prevStep && requestId !== prevRequestId && !paymentState.savedCredentialId && !paymentState.cardNumber ) { setStep(PaymentStep.PaymentInfo); } }, [paymentState.cardNumber, paymentState.savedCredentialId, prevRequestId, prevStep, requestId, setStep, step]); useEffect(() => { if (error?.field) { paymentDispatch({ type: 'setFormErrors', payload: { [error.field]: error.messageKey, }, }); } }, [error, paymentDispatch]); useEffect(() => { if (form?.savedInfo) { const { name: fullName, phone, email, shippingAddress, } = form.savedInfo; const { countryIso2, ...shippingAddressRest } = shippingAddress || {}; const shippingCountry = countryIso2 && countryList.find(({ iso2 }) => iso2 === countryIso2)!.defaultName; paymentDispatch({ type: 'updateUserInfo', payload: { fullName, phone: phone && phone.charAt(0) !== '+' ? `+${phone}` : phone, email, ...(shippingCountry && { country: shippingCountry, countryIso2, ...shippingAddressRest, }), }, }); } }, [form, paymentDispatch, countryList]); useEffect(() => { if (savedCredentials?.length) { paymentDispatch({ type: 'changeSavedCredentialId', payload: savedCredentials[0].id, }); } }, [paymentDispatch, savedCredentials]); const handleErrorModalClose = useCallback(() => { clearPaymentError(); }, [clearPaymentError]); const totalPrice = useMemo(() => { if (step !== PaymentStep.Checkout) { return 0; } return getTotalPrice(invoice?.prices, shippingOptions, paymentState.shipping, paymentState.tipAmount); }, [step, invoice?.prices, shippingOptions, paymentState.shipping, paymentState.tipAmount]); const checkoutInfo = useMemo(() => { if (step !== PaymentStep.Checkout) { return undefined; } return getCheckoutInfo(paymentState, shippingOptions, nativeProvider || ''); }, [step, paymentState, shippingOptions, nativeProvider]); const handleNewCardClick = useCallback(() => { setStep(PaymentStep.PaymentInfo); }, [setStep]); const handleClearPaymentError = useCallback(() => { clearPaymentError(); }, [clearPaymentError]); function renderError() { if (!error) { return undefined; } return (

{error.descriptionKey ? lang.withRegular(error.descriptionKey) : lang('ErrorUnspecified')}

); } const sendForm = useCallback(() => { sendPaymentForm({ shippingOptionId: paymentState.shipping, saveCredentials: paymentState.saveCredentials, savedCredentialId: paymentState.savedCredentialId, tipAmount: paymentState.tipAmount, }); }, [sendPaymentForm, paymentState]); const handlePaymentFormSubmit = useCallback((eventData: PaymentFormSubmitEvent['eventData']) => { const { credentials } = eventData; setSmartGlocalCardInfo(credentials); sendForm(); }, [sendForm]); function renderModalContent(currentStep: PaymentStep) { switch (currentStep) { case PaymentStep.Checkout: return ( ); case PaymentStep.SavedPayments: return ( ); case PaymentStep.ConfirmPassword: return ( ); case PaymentStep.PaymentInfo: return ( ); case PaymentStep.ShippingInfo: return ( ); case PaymentStep.Shipping: return ( ); case PaymentStep.ConfirmPayment: return ( ); default: return undefined; } } const validateRequest = useCallback(() => { const { saveInfo } = paymentState; const requestInfo = getRequestInfo(paymentState); validateRequestedInfo({ requestInfo, saveInfo }); }, [validateRequestedInfo, paymentState]); const sendCredentials = useCallback(() => { const credentials = getCredentials(paymentState); sendCredentialsInfo({ credentials, }); }, [sendCredentialsInfo, paymentState]); const handleButtonClick = useLastCallback(() => { switch (step) { case PaymentStep.ShippingInfo: setIsLoading(true); validateRequest(); break; case PaymentStep.Shipping: setStep(PaymentStep.Checkout); break; case PaymentStep.SavedPayments: setStep(PaymentStep.ConfirmPassword); break; case PaymentStep.ConfirmPassword: if (twoFaPassword === '') { return; } setIsLoading(true); validatePaymentPassword({ password: twoFaPassword }); break; case PaymentStep.PaymentInfo: setIsLoading(true); sendCredentials(); paymentDispatch({ type: 'changeSavedCredentialId', payload: '' }); break; case PaymentStep.Checkout: { if (isPaymentFormUrl) { setIsLoading(true); setStep(PaymentStep.ConfirmPayment); return; } if (form?.savedInfo && !requestId && !paymentState.shipping) { setIsLoading(true); validateRequest(); return; } if ( paymentState.savedCredentialId && (!passwordValidUntil || passwordValidUntil <= (Date.now() / 1000 - NETWORK_REQUEST_TIMEOUT_S)) ) { setStep(PaymentStep.ConfirmPassword); return; } if ( !paymentState.savedCredentialId && ( (nativeProvider === DEFAULT_PROVIDER && !stripeId) || (nativeProvider === DONATE_PROVIDER && !smartGlocalToken) ) ) { setStep(PaymentStep.PaymentInfo); return; } const { phone, email, fullName } = paymentState; const shouldFillRequestedData = (invoice?.isEmailRequested && !email) || (invoice?.isPhoneRequested && !phone) || (invoice?.isNameRequested && !fullName); if ((invoice?.isShippingAddressRequested && !requestId) || shouldFillRequestedData) { setStep(PaymentStep.ShippingInfo); return; } if (invoice?.isShippingAddressRequested && !paymentState.shipping && shippingOptions?.length) { setStep(PaymentStep.Shipping); return; } setIsLoading(true); sendForm(); break; } } }); useEffect(() => { return step === PaymentStep.ConfirmPassword ? captureKeyboardListeners({ onEnter: handleButtonClick }) : undefined; }, [handleButtonClick, step]); const handleModalClose = useCallback(() => { paymentDispatch({ type: 'resetState', }); setIsTosAccepted(false); onClose(); }, [onClose, paymentDispatch]); const handleBackClick = useCallback(() => { setStep(step === PaymentStep.ConfirmPassword ? PaymentStep.SavedPayments : PaymentStep.Checkout); }, [setStep, step]); const modalHeader = useMemo(() => { switch (step) { case PaymentStep.Checkout: return oldLang('PaymentCheckout'); case PaymentStep.ShippingInfo: return oldLang('PaymentShippingInfo'); case PaymentStep.Shipping: return oldLang('PaymentShippingMethod'); case PaymentStep.SavedPayments: return oldLang('PaymentCheckoutMethod'); case PaymentStep.ConfirmPassword: return oldLang('Checkout.PasswordEntry.Title'); case PaymentStep.PaymentInfo: return oldLang('PaymentCardInfo'); case PaymentStep.ConfirmPayment: return oldLang('Checkout.WebConfirmation.Title'); default: return ''; } }, [step, oldLang]); const buttonText = step === PaymentStep.Checkout ? oldLang('Checkout.PayPrice', formatCurrencyAsString(totalPrice, invoice!.currency, oldLang.code)) : lang('PaymentInfoDone'); function getIsSubmitDisabled() { if (isLoading) { return true; } switch (step) { case PaymentStep.Checkout: return Boolean(invoice?.termsUrl) && !isTosAccepted; case PaymentStep.PaymentInfo: return Boolean( paymentState.cardNumber === '' || (needCardholderName && paymentState.cardholder === '') || paymentState.cvv === '' || paymentState.expiry === '', ); default: return false; } } if (isProviderError) { return (

Sorry, Telegram Web A doesn't support payments with this provider yet.
Please use one of our mobile apps to do this.

); } const isSubmitDisabled = getIsSubmitDisabled(); return (

{modalHeader}

{step !== undefined ? (
{renderModalContent(step)}
) : (
)} {canRenderFooter && (
)} {error && !error.field && renderError()}
); }; export default memo(withGlobal( (global): StateProps => { const { form, step, shippingOptions, error, confirmPaymentUrl, inputInvoice, requestId, stripeCredentials, smartGlocalCredentials, temporaryPassword, isExtendedMedia, url, } = selectTabState(global).payment; const { invoice, nativeParams, nativeProvider } = form || {}; const countryList = global.countryList.general; let providerName = form?.nativeProvider; if (!providerName && url) { providerName = url.startsWith(DONATE_PROVIDER_URL) ? DONATE_PROVIDER : undefined; } const chat = inputInvoice && 'chatId' in inputInvoice ? selectChat(global, inputInvoice.chatId!) : undefined; const isProviderError = Boolean(invoice && (!providerName || !SUPPORTED_PROVIDERS.has(providerName))); const { needCardholderName, needCountry, needZip } = (nativeParams || {}); const bot = form?.botId ? selectUser(global, form.botId) : undefined; const botName = getUserFullName(bot); return { step, chat, shippingOptions, nativeProvider: providerName, isProviderError, form, invoice, needCardholderName, needCountry, needZip, error, confirmPaymentUrl: confirmPaymentUrl ?? url, isPaymentFormUrl: Boolean(!nativeProvider && url), countryList, requestId, hasShippingOptions: Boolean(shippingOptions?.length), smartGlocalToken: smartGlocalCredentials?.token, stripeId: stripeCredentials?.id, passwordValidUntil: temporaryPassword?.validUntil, isExtendedMedia, botName, }; }, )(PaymentModal)); function findShippingOption(shippingOptions: ShippingOption[], optionId: string) { return shippingOptions.find(({ id }) => id === optionId); } function getShippingPrices(shippingOptions: ShippingOption[], shippingOption: string) { const option = findShippingOption(shippingOptions, shippingOption); return option?.prices; } function getTotalPrice( prices: ApiLabeledPrice[] = [], shippingOptions: ShippingOption[] | undefined, shippingOption: string, tipAmount: number, ) { const shippingPrices = shippingOptions ? getShippingPrices(shippingOptions, shippingOption) : []; let total = tipAmount; const totalPrices = prices.concat(shippingPrices || []); total = totalPrices.reduce((acc, cur) => { return acc + cur.amount; }, total); return total; } function getCheckoutInfo(state: FormState, shippingOptions: ShippingOption[] | undefined, paymentProvider: string) { const cardTypeText = detectCardTypeText(state.cardNumber); const paymentMethod = cardTypeText && state.cardNumber ? `${cardTypeText} *${state.cardNumber.slice(-4)}` : undefined; const shippingAddress = state.streetLine1 ? `${state.streetLine1}, ${state.city}, ${state.countryIso2}` : undefined; const { phone, fullName: name } = state; const shippingOption = shippingOptions ? findShippingOption(shippingOptions, state.shipping) : undefined; const shippingMethod = shippingOption?.title; return { paymentMethod, paymentProvider, shippingAddress, name, phone, shippingMethod, }; } function getRequestInfo(paymentState: FormState) { const { streetLine1, streetLine2, city, state, countryIso2, postCode, fullName: name, phone, email, } = paymentState; const shippingAddress = { streetLine1, streetLine2, city, state, countryIso2, postCode, }; return { name, phone, email, shippingAddress, }; } export type ApiCredentials = { data: { cardNumber: string; cardholder: string; expiryMonth: string; expiryYear: string; cvv: string; country: string; zip: string; }; }; function getCredentials(paymentState: FormState): ApiCredentials { const { cardNumber, cardholder, expiry, cvv, billingCountry, billingZip, } = paymentState; const [expiryMonth, expiryYear] = expiry.split('/'); const data = { cardNumber, cardholder, expiryMonth, expiryYear, cvv, country: billingCountry, zip: billingZip, }; return { data, }; }