TelegramPWA/src/components/payment/PaymentModal.tsx
2023-07-23 10:09:10 +02:00

781 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 { TabState } from '../../global/types';
import type { ApiChat, ApiCountry, ApiPaymentCredentials } from '../../api/types';
import type { Price, ShippingOption } from '../../types';
import type { FormState } from '../../hooks/reducers/usePaymentReducer';
import { PaymentStep } from '../../types';
import { selectChat, selectTabState } from '../../global/selectors';
import { formatCurrency } from '../../util/formatCurrency';
import buildClassName from '../../util/buildClassName';
import { detectCardTypeText } from '../common/helpers/detectCardType';
import captureKeyboardListeners from '../../util/captureKeyboardListeners';
import useLang from '../../hooks/useLang';
import useFlag from '../../hooks/useFlag';
import usePaymentReducer from '../../hooks/reducers/usePaymentReducer';
import usePrevious from '../../hooks/usePrevious';
import ShippingInfo from './ShippingInfo';
import Shipping from './Shipping';
import Checkout from './Checkout';
import PaymentInfo from './PaymentInfo';
import Button from '../ui/Button';
import Modal from '../ui/Modal';
import Transition from '../ui/Transition';
import Spinner from '../ui/Spinner';
import ConfirmPayment from './ConfirmPayment';
import SavedPaymentCredentials from './SavedPaymentCredentials';
import PasswordConfirm from './PasswordConfirm';
import './PaymentModal.scss';
const DEFAULT_PROVIDER = 'stripe';
const DONATE_PROVIDER = 'smartglocal';
const SUPPORTED_PROVIDERS = new Set([DEFAULT_PROVIDER, DONATE_PROVIDER]);
export type OwnProps = {
isOpen?: boolean;
onClose: NoneToVoidFunction;
};
type StateProps = {
chat?: ApiChat;
isNameRequested?: boolean;
isShippingAddressRequested?: boolean;
isPhoneRequested?: boolean;
isEmailRequested?: boolean;
shouldSendPhoneToProvider?: boolean;
shouldSendEmailToProvider?: boolean;
currency?: string;
prices?: Price[];
isProviderError: boolean;
needCardholderName?: boolean;
needCountry?: boolean;
needZip?: boolean;
confirmPaymentUrl?: string;
countryList: ApiCountry[];
hasShippingOptions: boolean;
requestId?: string;
smartGlocalToken?: string;
stripeId?: string;
savedCredentials?: ApiPaymentCredentials[];
passwordValidUntil?: number;
isExtendedMedia?: boolean;
};
type GlobalStateProps = Pick<TabState['payment'], (
'step' | 'shippingOptions' |
'savedInfo' | 'canSaveCredentials' | 'nativeProvider' | 'passwordMissing' | 'invoice' | 'error'
)>;
const NETWORK_REQUEST_TIMEOUT_S = 3;
const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
isOpen,
onClose,
step,
chat,
shippingOptions,
savedInfo,
canSaveCredentials,
isNameRequested,
isShippingAddressRequested,
isPhoneRequested,
isEmailRequested,
shouldSendPhoneToProvider,
shouldSendEmailToProvider,
currency,
passwordMissing,
isProviderError,
invoice,
nativeProvider,
prices,
needCardholderName,
needCountry,
needZip,
confirmPaymentUrl,
error,
countryList,
hasShippingOptions,
requestId,
smartGlocalToken,
stripeId,
savedCredentials,
passwordValidUntil,
isExtendedMedia,
}) => {
const {
loadPasswordInfo,
validateRequestedInfo,
sendPaymentForm,
setPaymentStep,
sendCredentialsInfo,
clearPaymentError,
validatePaymentPassword,
} = getActions();
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 = usePrevious(step, true);
const prevRequestId = usePrevious(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.message,
},
});
}
}, [error, paymentDispatch]);
useEffect(() => {
if (savedInfo) {
const {
name: fullName, phone, email, shippingAddress,
} = 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,
}),
},
});
}
}, [savedInfo, 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(prices, shippingOptions, paymentState.shipping, paymentState.tipAmount);
}, [step, 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.description || 'Error'}</h4>
<p>{error.description || 'Error'}</p>
<div className="dialog-buttons mt-2">
<Button
isText
onClick={handleClearPaymentError}
>
{lang('OK')}
</Button>
</div>
</Modal>
);
}
function renderModalContent(currentStep: PaymentStep) {
switch (currentStep) {
case PaymentStep.Checkout:
return (
<Checkout
chat={chat}
prices={prices}
dispatch={paymentDispatch}
shippingPrices={paymentState.shipping && shippingOptions
? getShippingPrices(shippingOptions, paymentState.shipping)
: undefined}
totalPrice={totalPrice}
invoice={invoice}
checkoutInfo={checkoutInfo}
currency={currency!}
hasShippingOptions={hasShippingOptions}
tipAmount={paymentState.tipAmount}
needAddress={Boolean(isShippingAddressRequested)}
savedCredentials={savedCredentials}
isTosAccepted={isTosAccepted}
onAcceptTos={setIsTosAccepted}
/>
);
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(!passwordMissing && canSaveCredentials)}
needCardholderName={needCardholderName}
needCountry={needCountry}
needZip={needZip}
countryList={countryList}
/>
);
case PaymentStep.ShippingInfo:
return (
<ShippingInfo
state={paymentState}
dispatch={paymentDispatch}
needAddress={Boolean(isShippingAddressRequested)}
needEmail={Boolean(isEmailRequested || shouldSendEmailToProvider)}
needPhone={Boolean(isPhoneRequested || shouldSendPhoneToProvider)}
needName={Boolean(isNameRequested)}
countryList={countryList}
/>
);
case PaymentStep.Shipping:
return (
<Shipping
state={paymentState}
dispatch={paymentDispatch}
shippingOptions={shippingOptions || []}
currency={currency!}
/>
);
case PaymentStep.ConfirmPayment:
return (
<ConfirmPayment
url={confirmPaymentUrl!}
noRedirect={isExtendedMedia}
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 sendForm = useCallback(() => {
sendPaymentForm({
shippingOptionId: paymentState.shipping,
saveCredentials: paymentState.saveCredentials,
savedCredentialId: paymentState.savedCredentialId,
tipAmount: paymentState.tipAmount,
});
}, [sendPaymentForm, paymentState]);
const handleButtonClick = useCallback(() => {
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 (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 = (isEmailRequested && !email)
|| (isPhoneRequested && !phone)
|| (isNameRequested && !fullName);
if ((isShippingAddressRequested && !requestId) || shouldFillRequestedData) {
setStep(PaymentStep.ShippingInfo);
return;
}
if (isShippingAddressRequested && !paymentState.shipping) {
setStep(PaymentStep.Shipping);
return;
}
setIsLoading(true);
sendForm();
break;
}
}
}, [
isEmailRequested, isNameRequested, isPhoneRequested, isShippingAddressRequested, nativeProvider, passwordValidUntil,
paymentDispatch, paymentState, requestId, savedInfo, sendCredentials, sendForm, setStep, smartGlocalToken, step,
stripeId, twoFaPassword, validatePaymentPassword, validateRequest,
]);
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 lang('PaymentCheckout');
case PaymentStep.ShippingInfo:
return lang('PaymentShippingInfo');
case PaymentStep.Shipping:
return lang('PaymentShippingMethod');
case PaymentStep.SavedPayments:
return lang('PaymentCheckoutMethod');
case PaymentStep.ConfirmPassword:
return lang('Checkout.PasswordEntry.Title');
case PaymentStep.PaymentInfo:
return lang('PaymentCardInfo');
case PaymentStep.ConfirmPayment:
return lang('Checkout.WebConfirmation.Title');
default:
return '';
}
}, [step, lang]);
const buttonText = step === PaymentStep.Checkout
? lang('Checkout.PayPrice', formatCurrency(totalPrice, currency!, lang.code))
: lang('Next');
function getIsSubmitDisabled() {
if (isLoading) {
return true;
}
switch (step) {
case PaymentStep.Checkout:
return Boolean(invoice?.isRecurring && !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}
>
{lang('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={lang.isRtl ? 'rtl' : undefined}>
<Button
className="close-button"
color="translucent"
round
size="smaller"
onClick={step === PaymentStep.Checkout ? closeModal : handleBackClick}
ariaLabel="Close"
>
<i className={buildClassName(
'icon', step === PaymentStep.Checkout ? 'icon-close' : 'icon-arrow-left',
)}
/>
</Button>
<h3>{modalHeader}</h3>
</div>
{step !== undefined ? (
<Transition name="slide" activeKey={step}>
<div className="content custom-scroll">
{renderModalContent(step)}
</div>
</Transition>
) : (
<div className="empty-content">
<Spinner color="gray" />
</div>
)}
{canRenderFooter && (
<div className="footer">
<Button
type="submit"
onClick={handleButtonClick}
disabled={isSubmitDisabled}
isLoading={isLoading}
>
{buttonText}
</Button>
</div>
)}
{error && !error.field && renderError()}
</Modal>
);
};
export default memo(withGlobal<OwnProps>(
(global): StateProps & GlobalStateProps => {
const {
step,
shippingOptions,
savedInfo,
canSaveCredentials,
invoice,
invoiceContainer,
nativeProvider,
nativeParams,
passwordMissing,
error,
confirmPaymentUrl,
inputInvoice,
requestId,
stripeCredentials,
smartGlocalCredentials,
savedCredentials,
temporaryPassword,
isExtendedMedia,
} = selectTabState(global).payment;
const chat = inputInvoice && 'chatId' in inputInvoice ? selectChat(global, inputInvoice.chatId) : undefined;
const isProviderError = Boolean(invoice && (!nativeProvider || !SUPPORTED_PROVIDERS.has(nativeProvider)));
const { needCardholderName, needCountry, needZip } = (nativeParams || {});
const {
isNameRequested,
isShippingAddressRequested,
isPhoneRequested,
isEmailRequested,
shouldSendPhoneToProvider,
shouldSendEmailToProvider,
currency,
prices,
} = (invoiceContainer || {});
return {
step,
chat,
shippingOptions,
savedInfo,
canSaveCredentials,
nativeProvider,
passwordMissing,
isNameRequested,
isShippingAddressRequested,
isPhoneRequested,
isEmailRequested,
shouldSendPhoneToProvider,
shouldSendEmailToProvider,
currency,
prices,
isProviderError,
invoice,
needCardholderName,
needCountry,
needZip,
error,
confirmPaymentUrl,
countryList: global.countryList.general,
requestId,
hasShippingOptions: Boolean(shippingOptions?.length),
smartGlocalToken: smartGlocalCredentials?.token,
stripeId: stripeCredentials?.id,
savedCredentials,
passwordValidUntil: temporaryPassword?.validUntil,
isExtendedMedia,
};
},
)(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: Price[] = [],
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,
};
}