TelegramPWA/src/components/payment/PaymentModal.tsx
2025-01-27 23:51:10 +01:00

777 lines
22 KiB
TypeScript

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<OwnProps & StateProps> = ({
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 (
<Modal
className="error"
isOpen={Boolean(error)}
onClose={handleErrorModalClose}
>
<h4>{error.descriptionKey ? lang.withRegular(error.descriptionKey) : lang('ErrorUnspecified')}</h4>
<div className="dialog-buttons mt-2">
<Button
isText
onClick={handleClearPaymentError}
>
{oldLang('OK')}
</Button>
</div>
</Modal>
);
}
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 (
<Checkout
title={form!.title}
description={form!.description}
photo={form!.photo}
dispatch={paymentDispatch}
shippingPrices={paymentState.shipping && shippingOptions
? getShippingPrices(shippingOptions, paymentState.shipping)
: undefined}
totalPrice={totalPrice}
invoice={invoice!}
checkoutInfo={checkoutInfo}
isPaymentFormUrl={isPaymentFormUrl}
hasShippingOptions={hasShippingOptions}
tipAmount={paymentState.tipAmount}
needAddress={Boolean(invoice?.isShippingAddressRequested)}
savedCredentials={savedCredentials}
isTosAccepted={isTosAccepted}
onAcceptTos={setIsTosAccepted}
botName={botName}
/>
);
case PaymentStep.SavedPayments:
return (
<SavedPaymentCredentials
state={paymentState}
savedCredentials={savedCredentials}
dispatch={paymentDispatch}
onNewCardClick={handleNewCardClick}
/>
);
case PaymentStep.ConfirmPassword:
return (
<PasswordConfirm
state={paymentState}
savedCredentials={savedCredentials}
onPasswordChange={setTwoFaPassword}
isActive={currentStep === step}
/>
);
case PaymentStep.PaymentInfo:
return (
<PaymentInfo
state={paymentState}
dispatch={paymentDispatch}
canSaveCredentials={Boolean(!form!.isPasswordMissing && form!.canSaveCredentials)}
needCardholderName={needCardholderName}
needCountry={needCountry}
needZip={needZip}
countryList={countryList!}
/>
);
case PaymentStep.ShippingInfo:
return (
<ShippingInfo
state={paymentState}
dispatch={paymentDispatch}
needAddress={Boolean(invoice?.isShippingAddressRequested)}
needEmail={Boolean(invoice?.isEmailRequested || invoice?.isEmailSentToProvider)}
needPhone={Boolean(invoice?.isPhoneRequested || invoice?.isPhoneSentToProvider)}
needName={Boolean(invoice?.isNameRequested)}
countryList={countryList!}
/>
);
case PaymentStep.Shipping:
return (
<Shipping
state={paymentState}
dispatch={paymentDispatch}
shippingOptions={shippingOptions || []}
currency={invoice!.currency}
/>
);
case PaymentStep.ConfirmPayment:
return (
<ConfirmPayment
url={confirmPaymentUrl!}
noRedirect={isExtendedMedia}
onPaymentFormSubmit={handlePaymentFormSubmit}
onClose={closeModal}
/>
);
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 (
<Modal
className="error"
isOpen={isModalOpen}
onClose={closeModal}
onCloseAnimationEnd={handleModalClose}
>
<p>
Sorry, Telegram Web A doesn&apos;t support payments with this provider yet. <br />
Please use one of our mobile apps to do this.
</p>
<div className="dialog-buttons mt-2">
<Button
isText
onClick={closeModal}
>
{oldLang('OK')}
</Button>
</div>
</Modal>
);
}
const isSubmitDisabled = getIsSubmitDisabled();
return (
<Modal
className={buildClassName('PaymentModal', invoice?.isRecurring && 'recurring')}
isOpen={isModalOpen}
onClose={closeModal}
onCloseAnimationEnd={handleModalClose}
>
<div className="header" dir={oldLang.isRtl ? 'rtl' : undefined}>
<Button
className="close-button"
color="translucent"
round
size="smaller"
onClick={step === PaymentStep.Checkout ? closeModal : handleBackClick}
ariaLabel="Close"
>
<Icon name={step === PaymentStep.Checkout ? 'close' : 'arrow-left'} />
</Button>
<h3>{modalHeader}</h3>
</div>
{step !== undefined ? (
<Transition
name="slide"
activeKey={step}
shouldCleanup
cleanupOnlyKey={PaymentStep.ConfirmPayment}
>
<div className="content custom-scroll">
{renderModalContent(step)}
</div>
</Transition>
) : (
<div className="empty-content">
<Spinner color="gray" />
</div>
)}
{canRenderFooter && (
<div className="footer">
<Button
className="button-text"
type="submit"
onClick={handleButtonClick}
disabled={isSubmitDisabled}
isLoading={isLoading}
>
{buttonText}
</Button>
</div>
)}
{error && !error.field && renderError()}
</Modal>
);
};
export default memo(withGlobal<OwnProps>(
(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,
};
}