Payment Modal: Support Payments 2.0 (#1375)

This commit is contained in:
Alexander Zinchuk 2021-08-16 14:21:22 +03:00
parent 94526b2362
commit af8248dcf2
32 changed files with 333 additions and 231 deletions

View File

@ -29,7 +29,6 @@ import { getApiChatIdFromMtpPeer } from './chats';
import { buildStickerFromDocument } from './symbols';
import { buildApiPhoto, buildApiThumbnailFromStripped } from './common';
import { interpolateArray } from '../../../util/waveform';
import { getCurrencySign } from '../../../components/middle/helpers/getCurrencySign';
import { buildPeer } from '../gramjsBuilders';
import { addPhotoToLocalDb, resolveMessageApiChatId } from '../helpers';
@ -113,6 +112,7 @@ type UniversalMessage = (
export function buildApiMessageWithChatId(chatId: number, mtpMessage: UniversalMessage): ApiMessage {
const fromId = mtpMessage.fromId ? getApiChatIdFromMtpPeer(mtpMessage.fromId) : undefined;
const peerId = mtpMessage.peerId ? getApiChatIdFromMtpPeer(mtpMessage.peerId) : undefined;
const isChatWithSelf = !fromId && chatId === currentUserId;
const isOutgoing = (mtpMessage.out && !mtpMessage.post) || (isChatWithSelf && !mtpMessage.fwdFrom);
@ -131,7 +131,8 @@ export function buildApiMessageWithChatId(chatId: number, mtpMessage: UniversalM
};
}
const action = mtpMessage.action && buildAction(mtpMessage.action, fromId, Boolean(mtpMessage.post), isOutgoing);
const action = mtpMessage.action
&& buildAction(mtpMessage.action, fromId, peerId, Boolean(mtpMessage.post), isOutgoing);
if (action) {
content.action = action;
}
@ -500,13 +501,15 @@ export function buildInvoice(media: GramJs.MessageMediaInvoice): ApiInvoice {
const {
description: text, title, photo, test, totalAmount, currency, receiptMsgId,
} = media;
const currencySign = getCurrencySign(currency);
return {
text,
title,
photoUrl: photo && photo.url,
receiptMsgId,
description: `${currencySign}${(Number(totalAmount) / 100).toFixed(2)} ${test ? 'TEST INVOICE' : ''}`,
amount: Number(totalAmount),
currency,
isTest: test,
};
}
@ -567,6 +570,7 @@ export function buildWebPage(media: GramJs.TypeMessageMedia): ApiWebPage | undef
function buildAction(
action: GramJs.TypeMessageAction,
senderId: number | undefined,
targetPeerId: number | undefined,
isChannelPost: boolean,
isOutgoing: boolean,
): ApiAction | undefined {
@ -574,7 +578,9 @@ function buildAction(
return undefined;
}
let text = '';
let amount: number | undefined;
let currency: string | undefined;
let text: string;
const translationValues = [];
let type: ApiAction['type'] = 'other';
let photo: ApiPhoto | undefined;
@ -661,10 +667,13 @@ function buildAction(
translationValues.push('%action_origin%');
type = 'contactSignUp';
} else if (action instanceof GramJs.MessageActionPaymentSent) {
const currencySign = getCurrencySign(action.currency);
const amount = (Number(action.totalAmount) / 100).toFixed(2);
text = 'Notification.PaymentSent';
translationValues.push(currencySign, amount, '%product%');
amount = Number(action.totalAmount);
currency = action.currency;
text = 'PaymentSuccessfullyPaid';
if (targetPeerId) {
targetUserIds.push(targetPeerId);
}
translationValues.push('%payment_amount%', '%target_user%', '%product%');
} else if (action instanceof GramJs.MessageActionGroupCall) {
if (action.duration) {
const mins = Math.max(Math.round(action.duration / 60), 1);
@ -691,6 +700,8 @@ function buildAction(
targetUserIds,
targetChatId,
photo, // TODO Only used internally now, will be used for the UI in future
amount,
currency,
translationValues,
};
}
@ -739,7 +750,7 @@ function buildReplyButtons(message: UniversalMessage): ApiReplyKeyboard | undefi
type = 'requestPoll';
} else if (button instanceof GramJs.KeyboardButtonBuy) {
if (media instanceof GramJs.MessageMediaInvoice && media.receiptMsgId) {
text = 'Receipt';
text = 'PaymentReceipt';
value = media.receiptMsgId;
}
type = 'buy';

View File

@ -63,6 +63,7 @@ export function buildReceipt(receipt: GramJs.payments.PaymentReceipt) {
export function buildPaymentForm(form: GramJs.payments.PaymentForm) {
const {
formId,
canSaveCredentials,
passwordMissing,
providerId,
@ -94,6 +95,7 @@ export function buildPaymentForm(form: GramJs.payments.PaymentForm) {
return {
canSaveCredentials,
passwordMissing,
formId: String(formId),
providerId,
nativeProvider,
savedInfo,

View File

@ -1,13 +1,17 @@
import BigInt from 'big-integer';
import { Api as GramJs } from '../../../lib/gramjs';
import { invokeRequest } from './client';
import { buildShippingInfo } from '../gramjsBuilders';
import { buildInputPeer, buildShippingInfo } from '../gramjsBuilders';
import { buildShippingOptions, buildPaymentForm, buildReceipt } from '../apiBuilders/payments';
import { ApiChat } from '../../types';
export async function validateRequestedInfo({
chat,
messageId,
requestInfo,
shouldSave,
}: {
chat: ApiChat;
messageId: number;
requestInfo: GramJs.TypePaymentRequestedInfo;
shouldSave?: boolean;
@ -16,6 +20,7 @@ export async function validateRequestedInfo({
shippingOptions: any;
} | undefined> {
const result = await invokeRequest(new GramJs.payments.ValidateRequestedInfo({
peer: buildInputPeer(chat.id, chat.accessHash),
msgId: messageId,
save: shouldSave || undefined,
info: buildShippingInfo(requestInfo),
@ -23,10 +28,12 @@ export async function validateRequestedInfo({
if (!result) {
return undefined;
}
const { id, shippingOptions } = result;
if (!id) {
return undefined;
}
return {
id,
shippingOptions: buildShippingOptions(shippingOptions),
@ -34,17 +41,23 @@ export async function validateRequestedInfo({
}
export function sendPaymentForm({
chat,
messageId,
formId,
requestedInfoId,
shippingOptionId,
credentials,
}: {
chat: ApiChat;
messageId: number;
formId: string;
credentials: any;
requestedInfoId?: string;
shippingOptionId?: string;
}) {
return invokeRequest(new GramJs.payments.SendPaymentForm({
formId: BigInt(formId),
peer: buildInputPeer(chat.id, chat.accessHash),
msgId: messageId,
requestedInfoId,
shippingOptionId,
@ -56,13 +69,16 @@ export function sendPaymentForm({
}
export async function getPaymentForm({
messageId,
chat, messageId,
}: {
chat: ApiChat;
messageId: number;
}) {
const result = await invokeRequest(new GramJs.payments.GetPaymentForm({
peer: buildInputPeer(chat.id, chat.accessHash),
msgId: messageId,
}));
if (!result) {
return undefined;
}
@ -70,10 +86,14 @@ export async function getPaymentForm({
return buildPaymentForm(result);
}
export async function getReceipt(msgId: number) {
const result = await invokeRequest(new GramJs.payments.GetPaymentReceipt({ msgId }));
export async function getReceipt(chat: ApiChat, msgId: number) {
const result = await invokeRequest(new GramJs.payments.GetPaymentReceipt({
peer: buildInputPeer(chat.id, chat.accessHash),
msgId,
}));
if (!result) {
return undefined;
}
return buildReceipt(result);
}

View File

@ -131,8 +131,10 @@ export interface ApiInvoice {
text: string;
title: string;
photoUrl?: string;
description?: string;
amount: number;
currency: string;
receiptMsgId?: number;
isTest?: boolean;
}
export type ApiNewPoll = {
@ -150,6 +152,8 @@ export interface ApiAction {
targetChatId?: number;
type: 'historyClear' | 'contactSignUp' | 'chatCreate' | 'other';
photo?: ApiPhoto;
amount?: number;
currency?: string;
translationValues: string[];
}

View File

@ -318,6 +318,11 @@ export type ApiError = {
textParams?: Record<string, string>;
};
export type ApiFieldError = {
field: string;
message: string;
};
export type ApiInviteInfo = {
title: string;
hash: string;

View File

@ -117,7 +117,7 @@ const AuthPhoneNumber: FC<StateProps & DispatchProps> = ({
const handleLangChange = useCallback(() => {
markIsLoading();
setLanguage(suggestedLanguage!, () => {
void setLanguage(suggestedLanguage, () => {
unmarkIsLoading();
setSettingOption({ language: suggestedLanguage });
@ -153,7 +153,7 @@ const AuthPhoneNumber: FC<StateProps & DispatchProps> = ({
if (!isPreloadInitiated) {
isPreloadInitiated = true;
preloadFonts();
preloadImage(monkeyPath);
void preloadImage(monkeyPath);
}
const { value, selectionStart, selectionEnd } = e.target;

View File

@ -64,7 +64,7 @@ const AuthCode: FC<StateProps & DispatchProps> = ({
const handleLangChange = useCallback(() => {
markIsLoading();
setLanguage(suggestedLanguage!, () => {
void setLanguage(suggestedLanguage, () => {
unmarkIsLoading();
setSettingOption({ language: suggestedLanguage });

View File

@ -1,3 +1,5 @@
import { LangCode } from '../../../types';
export function getSuggestedLanguage() {
let suggestedLanguage = navigator.language;
@ -5,5 +7,5 @@ export function getSuggestedLanguage() {
suggestedLanguage = suggestedLanguage.substr(0, 2);
}
return suggestedLanguage;
return suggestedLanguage as LangCode;
}

View File

@ -10,6 +10,7 @@ import {
isChat,
} from '../../../modules/helpers';
import trimText from '../../../util/trimText';
import { formatCurrency } from '../../../util/formatCurrency';
import { TextPart } from './renderMessageText';
import renderText from './renderText';
@ -37,16 +38,30 @@ export function renderActionMessageText(
if (!message.content.action) {
return [];
}
const { text, translationValues } = message.content.action;
const {
text, translationValues, amount, currency,
} = message.content.action;
const content: TextPart[] = [];
const textOptions: ActionMessageTextOptions = { ...options, maxTextLength: 32 };
const translationKey = text === 'Chat.Service.Group.UpdatedPinnedMessage1' && !targetMessage
? 'Message.PinnedGenericMessage'
: text;
let unprocessed: string;
let processed = processPlaceholder(
lang(translationKey, translationValues && translationValues.length ? translationValues : undefined),
let unprocessed = lang(translationKey, translationValues && translationValues.length ? translationValues : undefined);
let processed: TextPart[];
if (unprocessed.includes('%payment_amount%')) {
processed = processPlaceholder(
unprocessed,
'%payment_amount%',
formatCurrency(amount!, currency, lang.code),
);
unprocessed = processed.pop() as string;
content.push(...processed);
}
processed = processPlaceholder(
unprocessed,
'%action_origin%',
actionOrigin
? (!options.isEmbedded && renderOriginContent(lang, actionOrigin, options.asPlain)) || NBSP

View File

@ -4,7 +4,7 @@ import React, {
import { withGlobal } from '../../../lib/teact/teactn';
import { GlobalActions } from '../../../global/types';
import { ISettings, SettingsScreens } from '../../../types';
import { ISettings, LangCode, SettingsScreens } from '../../../types';
import { ApiLanguage } from '../../../api/types';
import { setLanguage } from '../../../util/langProvider';
@ -46,7 +46,7 @@ const SettingsLanguage: FC<OwnProps & StateProps & DispatchProps> = ({
setSelectedLanguage(langCode);
markIsLoading();
setLanguage(langCode, () => {
void setLanguage(langCode as LangCode, () => {
unmarkIsLoading();
setSettingOption({ language: langCode });

View File

@ -1,17 +0,0 @@
const CURRENCIES: Record<string, string> = {
USD: '$',
EUR: '€',
GBP: '£',
JPY: '¥',
RUB: '₽',
UAH: '₴',
INR: '₹',
AED: 'د.إ',
};
export function getCurrencySign(currency: string | undefined): string {
if (!currency) {
return '';
}
return CURRENCIES[currency] || '';
}

View File

@ -48,12 +48,18 @@
}
i {
font-size: 0.75rem;
font-size: .875rem;
position: absolute;
right: 0.125rem;
top: 0.125rem;
right: .1875rem;
top: .1875rem;
display: block;
transform: rotate(-45deg);
&.icon-arrow-right {
font-size: .75rem;
top: .125rem;
right: .125rem;
transform: rotate(-45deg);
}
}
}

View File

@ -4,6 +4,7 @@ import { ApiKeyboardButton, ApiMessage } from '../../../api/types';
import { RE_TME_LINK } from '../../../config';
import renderText from '../../common/helpers/renderText';
import useLang from '../../../hooks/useLang';
import Button from '../../ui/Button';
@ -15,6 +16,8 @@ type OwnProps = {
};
const InlineButtons: FC<OwnProps> = ({ message, onClick }) => {
const lang = useLang();
return (
<div className="InlineButtons">
{message.inlineButtons!.map((row) => (
@ -26,7 +29,8 @@ const InlineButtons: FC<OwnProps> = ({ message, onClick }) => {
disabled={button.type === 'NOT_SUPPORTED'}
onClick={() => onClick({ button })}
>
{renderText(button.text)}
{renderText(lang(button.text))}
{button.type === 'buy' && <i className="icon-card" />}
{button.type === 'url' && !button.value!.match(RE_TME_LINK) && <i className="icon-arrow-right" />}
</Button>
))}

View File

@ -26,6 +26,10 @@
border-radius: var(--border-radius-messages-small);
color: var(--color-text);
font-weight: 500;
span {
margin-left: .5rem;
}
}
}

View File

@ -3,7 +3,9 @@ import React, { FC, memo } from '../../../lib/teact/teact';
import { ApiMessage } from '../../../api/types';
import { getMessageInvoice } from '../../../modules/helpers';
import { formatCurrency } from '../../../util/formatCurrency';
import renderText from '../../common/helpers/renderText';
import useLang from '../../../hooks/useLang';
import './Invoice.scss';
@ -14,12 +16,15 @@ type OwnProps = {
const Invoice: FC<OwnProps> = ({
message,
}) => {
const lang = useLang();
const invoice = getMessageInvoice(message);
const {
title,
text,
description,
amount,
currency,
isTest,
photoUrl,
} = invoice!;
@ -41,9 +46,10 @@ const Invoice: FC<OwnProps> = ({
alt=""
/>
)}
{description && (
<p className="description-text">{renderText(description, ['emoji', 'br'])}</p>
)}
<p className="description-text">
{formatCurrency(amount, currency, lang.code)}
{isTest && <span>{lang('PaymentTestInvoice')}</span>}
</p>
</div>
</div>
);

View File

@ -716,11 +716,7 @@ const Message: FC<OwnProps & StateProps & DispatchProps> = ({
onCancelMediaTransfer={handleCancelUpload}
/>
)}
{invoice && (
<Invoice
message={message}
/>
)}
{invoice && <Invoice message={message} />}
</div>
);
}

View File

@ -2,14 +2,16 @@ import React, {
FC, memo,
} from '../../lib/teact/teact';
import { Price } from '../../types';
import { LangCode, Price } from '../../types';
import { formatCurrency } from '../../util/formatCurrency';
import useLang from '../../hooks/useLang';
import './Checkout.scss';
export type OwnProps = {
invoiceContent?: {
title?: string;
description?: string;
text?: string;
photoUrl?: string;
};
@ -35,8 +37,9 @@ const Checkout: FC<OwnProps> = ({
currency,
totalPrice,
}) => {
// eslint-disable-next-line no-null/no-null
const { photoUrl, title, text } = (invoiceContent || {});
const lang = useLang();
const { photoUrl, title, text } = invoiceContent || {};
const {
paymentMethod,
paymentProvider,
@ -45,26 +48,25 @@ const Checkout: FC<OwnProps> = ({
phone,
shippingMethod,
} = (checkoutInfo || {});
return (
<div className="Checkout">
<div className="description has-image">
{ photoUrl && (
<img src={photoUrl} alt="" />
)}
{photoUrl && <img src={photoUrl} alt="" />}
<div className="text">
<h5>{ title }</h5>
<p>{ text }</p>
<h5>{title}</h5>
<p>{text}</p>
</div>
</div>
<div className="price-info">
{ prices && prices.map((item) => (
renderPaymentItem(item.label, item.amount, currency, false)
renderPaymentItem(lang.code, item.label, item.amount, currency)
)) }
{ shippingPrices && shippingPrices.map((item) => (
renderPaymentItem(item.label, item.amount, currency, false)
renderPaymentItem(lang.code, item.label, item.amount, currency)
)) }
{ totalPrice !== undefined && (
renderPaymentItem('Total', totalPrice, currency, true)
renderPaymentItem(lang.code, lang('Checkout.TotalAmount'), totalPrice, currency, true)
) }
</div>
<div className="invoice-info">
@ -79,14 +81,16 @@ const Checkout: FC<OwnProps> = ({
);
};
function renderPaymentItem(title: string, value: number, currency?: string, main = false) {
function renderPaymentItem(
langCode: LangCode | undefined, title: string, value: number, currency?: string, main = false,
) {
return (
<div className={`price-info-item ${main ? 'price-info-item-main' : ''}`}>
<div className="title">
{ title }
</div>
<div className="value">
{ `${currency || ''} ${(value / 100).toFixed(2)}` }
{formatCurrency(value, currency, langCode)}
</div>
</div>
);

View File

@ -5,12 +5,10 @@ import { withGlobal } from '../../lib/teact/teactn';
import { GlobalActions, GlobalState } from '../../global/types';
import { PaymentStep, ShippingOption, Price } from '../../types';
import { ApiError, ApiInviteInfo } from '../../api/types';
import { pick } from '../../util/iteratees';
import { getCurrencySign } from '../middle/helpers/getCurrencySign';
import { formatCurrency } from '../../util/formatCurrency';
import { detectCardTypeText } from '../common/helpers/detectCardType';
import { getShippingErrors } from '../../modules/helpers/payments';
import usePaymentReducer, { FormState } from '../../hooks/reducers/usePaymentReducer';
import useLang from '../../hooks/useLang';
@ -46,7 +44,6 @@ type StateProps = {
needCardholderName?: boolean;
needCountry?: boolean;
needZip?: boolean;
globalDialogs?: (ApiError | ApiInviteInfo)[];
};
type GlobalStateProps = Pick<GlobalState['payment'], 'step' | 'shippingOptions' |
@ -79,7 +76,6 @@ const Invoice: FC<OwnProps & StateProps & GlobalStateProps & DispatchProps> = ({
needCountry,
needZip,
error,
globalDialogs,
validateRequestedInfo,
sendPaymentForm,
setPaymentStep,
@ -87,36 +83,26 @@ const Invoice: FC<OwnProps & StateProps & GlobalStateProps & DispatchProps> = ({
clearPaymentError,
}) => {
const [paymentState, paymentDispatch] = usePaymentReducer();
const currencySign = getCurrencySign(currency);
const [isLoading, setIsLoading] = useState(false);
const lang = useLang();
useEffect(() => {
if (step || error || globalDialogs) {
if (step || error) {
setIsLoading(false);
}
}, [step, error, globalDialogs]);
}, [step, error]);
useEffect(() => {
if (error && error.field) {
paymentDispatch({
type: 'setFormErrors',
payload: {
[error.field]: error.fieldError,
[error.field]: error.message,
},
});
return;
}
if (globalDialogs && globalDialogs.length) {
const errors = getShippingErrors(globalDialogs);
paymentDispatch({
type: 'setFormErrors',
payload: {
...errors,
},
});
}
}, [error, globalDialogs, paymentDispatch]);
}, [error, paymentDispatch]);
useEffect(() => {
if (savedInfo) {
@ -178,8 +164,8 @@ const Invoice: FC<OwnProps & StateProps & GlobalStateProps & DispatchProps> = ({
);
}
function renderModalContent(cuurentStep: PaymentStep) {
switch (cuurentStep) {
function renderModalContent(currentStep: PaymentStep) {
switch (currentStep) {
case PaymentStep.ShippingInfo:
return (
<ShippingInfo
@ -197,7 +183,7 @@ const Invoice: FC<OwnProps & StateProps & GlobalStateProps & DispatchProps> = ({
state={paymentState}
dispatch={paymentDispatch}
shippingOptions={shippingOptions || []}
currency={currencySign}
currency={currency}
/>
);
case PaymentStep.PaymentInfo:
@ -221,7 +207,7 @@ const Invoice: FC<OwnProps & StateProps & GlobalStateProps & DispatchProps> = ({
totalPrice={totalPrice}
invoiceContent={invoiceContent}
checkoutInfo={checkoutInfo}
currency={currencySign}
currency={currency}
/>
);
default:
@ -287,11 +273,11 @@ const Invoice: FC<OwnProps & StateProps & GlobalStateProps & DispatchProps> = ({
const buttonText = useMemo(() => {
switch (step) {
case PaymentStep.Checkout:
return lang('Checkout.PayPrice', `${currencySign}${(totalPrice / 100).toFixed(2)}`);
return lang('Checkout.PayPrice', formatCurrency(totalPrice, currency, lang.code));
default:
return lang('Next');
}
}, [step, lang, currencySign, totalPrice]);
}, [step, lang, currency, totalPrice]);
if (isProviderError) {
return (
@ -412,7 +398,6 @@ export default memo(withGlobal<OwnProps>(
needCountry,
needZip,
error,
globalDialogs: global.dialogs,
};
},
(setGlobal, actions): DispatchProps => {

View File

@ -4,10 +4,9 @@ import React, {
import { withGlobal } from '../../lib/teact/teactn';
import { Price } from '../../types';
import { ApiShippingAddress } from '../../api/types/payments';
import { ApiShippingAddress } from '../../api/types';
import useLang from '../../hooks/useLang';
import { getCurrencySign } from '../middle/helpers/getCurrencySign';
import Checkout from './Checkout';
import Modal from '../ui/Modal';
@ -52,10 +51,10 @@ const ReceiptModal: FC<OwnProps & StateProps> = ({
shippingMethod,
}) => {
const lang = useLang();
const currencySign = getCurrencySign(currency);
const checkoutInfo = useMemo(() => {
return getCheckoutInfo(credentialsTitle, info, shippingMethod);
}, [info, shippingMethod, credentialsTitle]);
return (
<Modal
className="PaymentModal PaymentModal-receipt"
@ -87,7 +86,7 @@ const ReceiptModal: FC<OwnProps & StateProps> = ({
title,
}}
checkoutInfo={checkoutInfo}
currency={currencySign}
currency={currency}
/>
</div>
</div>
@ -100,7 +99,7 @@ export default memo(withGlobal<OwnProps>(
const { receipt } = global.payment;
const {
currency,
prices: mapedPrices,
prices,
info,
totalAmount,
credentialsTitle,
@ -113,7 +112,7 @@ export default memo(withGlobal<OwnProps>(
return {
currency,
prices: mapedPrices,
prices,
info,
totalAmount,
credentialsTitle,

View File

@ -2,9 +2,11 @@ import React, {
FC, useCallback, memo, useMemo, useEffect,
} from '../../lib/teact/teact';
import { ShippingOption } from '../../types/index';
import { ShippingOption } from '../../types';
import { formatCurrency } from '../../util/formatCurrency';
import { FormState, FormEditDispatch } from '../../hooks/reducers/usePaymentReducer';
import useLang from '../../hooks/useLang';
import RadioGroup from '../ui/RadioGroup';
@ -13,7 +15,7 @@ import './Shipping.scss';
export type OwnProps = {
state: FormState;
shippingOptions: ShippingOption[];
currency: string;
currency?: string;
dispatch: FormEditDispatch;
};
@ -23,6 +25,8 @@ const Shipping: FC<OwnProps> = ({
currency,
dispatch,
}) => {
const lang = useLang();
useEffect(() => {
if (!shippingOptions || state.shipping) {
return;
@ -36,9 +40,9 @@ const Shipping: FC<OwnProps> = ({
const options = useMemo(() => (shippingOptions.map(({ id: value, title: label, amount }) => ({
label,
subLabel: `${currency} ${String(amount / 100)}`,
subLabel: formatCurrency(amount, currency, lang.code),
value,
}))), [shippingOptions, currency]);
}))), [shippingOptions, currency, lang.code]);
return (
<div className="Shipping">

View File

@ -19,6 +19,7 @@ import {
ApiSession,
ApiNewPoll,
ApiInviteInfo,
ApiFieldError,
} from '../api/types';
import {
FocusDirection,
@ -344,18 +345,22 @@ export type GlobalState = {
};
payment: {
chatId?: number;
messageId?: number;
step?: PaymentStep;
shippingOptions?: ShippingOption[];
formId?: string;
requestId?: string;
savedInfo?: ApiPaymentSavedInfo;
canSaveCredentials?: boolean;
invoice?: Invoice;
invoiceContent?: {
title?: string;
text?: string;
description?: string;
photoUrl?: string;
amount?: number;
currency?: string;
isTest?: boolean;
};
nativeProvider?: string;
providerId?: number;
@ -377,7 +382,7 @@ export type GlobalState = {
receipt?: Receipt;
error?: {
field?: string;
fieldError?: string;
message?: string;
description: string;
};
isPaymentModalOpen?: boolean;
@ -501,7 +506,7 @@ export type ActionTypes = (
'loadWebPagePreview' | 'clearWebPagePreview' | 'loadWallpapers' | 'uploadWallpaper' | 'setDeviceToken' |
'deleteDeviceToken' |
// payment
'openPaymentModal' | 'closePaymentModal' |
'openPaymentModal' | 'closePaymentModal' | 'addPaymentError' |
'validateRequestedInfo' | 'setPaymentStep' | 'sendPaymentForm' | 'getPaymentForm' | 'getReceipt' |
'sendCredentialsInfo' | 'setInvoiceMessageInfo' | 'clearPaymentError' | 'clearReceipt'
);

View File

@ -55,9 +55,9 @@ addReducer('clickInlineButton', (global, actions, payload) => {
if (value) {
actions.getReceipt({ receiptMessageId: value, chatId: chat.id, messageId });
} else {
actions.getPaymentForm({ messageId });
actions.getPaymentForm({ chat, messageId });
actions.setInvoiceMessageInfo(selectChatMessage(global, chat.id, messageId));
actions.openPaymentModal({ messageId });
actions.openPaymentModal({ chatId: chat.id, messageId });
}
break;
}

View File

@ -1,16 +1,20 @@
import { addReducer, getGlobal, setGlobal } from '../../../lib/teact/teactn';
import { PaymentStep } from '../../../types/index';
import { callApi } from '../../../api/gramjs';
import { PaymentStep } from '../../../types';
import { ApiChat } from '../../../api/types';
import {
selectPaymentMessageId,
selectPaymentRequestId,
selectProviderPublishableKey,
selectStripeCredentials,
selectChatMessage,
selectPaymentChatId,
selectChat,
selectPaymentFormId,
} from '../../selectors';
import { getStripeError } from '../../helpers/payments';
import { callApi } from '../../../api/gramjs';
import { getStripeError } from '../../helpers';
import { buildQueryString } from '../../../util/requestQuery';
import {
@ -27,22 +31,28 @@ import {
addReducer('validateRequestedInfo', (global, actions, payload) => {
const { requestInfo, saveInfo } = payload;
const chatId = selectPaymentChatId(global);
const chat = chatId && selectChat(global, chatId);
const messageId = selectPaymentMessageId(global);
if (!messageId) {
if (!chat || !messageId) {
return;
}
validateRequestedInfo(messageId, requestInfo, saveInfo);
void validateRequestedInfo(chat, messageId, requestInfo, saveInfo);
});
async function validateRequestedInfo(messageId: number, requestInfo: any, shouldSave?: true) {
const result = await callApi('validateRequestedInfo', { messageId, requestInfo, shouldSave });
async function validateRequestedInfo(chat: ApiChat, messageId: number, requestInfo: any, shouldSave?: true) {
const result = await callApi('validateRequestedInfo', {
chat, messageId, requestInfo, shouldSave,
});
if (!result) {
return;
}
const { id, shippingOptions } = result;
if (!id) {
return;
}
let global = setRequestInfoId(getGlobal(), id);
if (shippingOptions) {
global = updateShippingOptions(global, shippingOptions);
@ -54,16 +64,16 @@ async function validateRequestedInfo(messageId: number, requestInfo: any, should
}
addReducer('getPaymentForm', (global, actions, payload) => {
const { messageId } = payload;
if (!messageId) {
const { chat, messageId } = payload;
if (!chat || !messageId) {
return;
}
getPaymentForm(messageId);
void getPaymentForm(chat, messageId);
});
async function getPaymentForm(messageId: number) {
const result = await callApi('getPaymentForm', { messageId });
async function getPaymentForm(chat: ApiChat, messageId: number) {
const result = await callApi('getPaymentForm', { chat, messageId });
if (!result) {
return;
}
@ -82,19 +92,22 @@ async function getPaymentForm(messageId: number) {
addReducer('getReceipt', (global, actions, payload) => {
const { receiptMessageId, chatId, messageId } = payload;
if (!messageId || !receiptMessageId || !chatId) {
const chat = chatId && selectChat(global, chatId);
if (!messageId || !receiptMessageId || !chat) {
return;
}
getReceipt(messageId, receiptMessageId, chatId);
void getReceipt(chat, messageId, receiptMessageId);
});
async function getReceipt(messageId: number, receiptMessageId: number, chatId: number) {
const result = await callApi('getReceipt', receiptMessageId);
async function getReceipt(chat: ApiChat, messageId: number, receiptMessageId: number) {
const result = await callApi('getReceipt', chat, receiptMessageId);
if (!result) {
return;
}
let global = getGlobal();
const message = selectChatMessage(global, chatId, messageId);
const message = selectChatMessage(global, chat.id, messageId);
global = setReceipt(global, result, message);
setGlobal(global);
}
@ -126,34 +139,40 @@ addReducer('sendCredentialsInfo', (global, actions, payload) => {
}
const { credentials } = payload;
const { data } = credentials;
sendStipeCredentials(data, publishableKey);
void sendStripeCredentials(data, publishableKey);
});
addReducer('sendPaymentForm', (global, actions, payload) => {
const { shippingOptionId, saveCredentials } = payload;
const chatId = selectPaymentChatId(global);
const chat = chatId && selectChat(global, chatId);
const messageId = selectPaymentMessageId(global);
const formId = selectPaymentFormId(global);
const requestInfoId = selectPaymentRequestId(global);
const publishableKey = selectProviderPublishableKey(global);
const stripeCredentials = selectStripeCredentials(global);
if (!messageId || !publishableKey) {
if (!chat || !messageId || !publishableKey || !formId) {
return;
}
sendPaymentForm(messageId, {
void sendPaymentForm(chat, messageId, formId, {
save: saveCredentials,
data: stripeCredentials,
}, requestInfoId, shippingOptionId);
});
async function sendStipeCredentials(data: {
cardNumber: string;
cardholder?: string;
expiryMonth: string;
expiryYear: string;
cvv: string;
country: string;
zip: string;
},
publishableKey: string) {
async function sendStripeCredentials(
data: {
cardNumber: string;
cardholder?: string;
expiryMonth: string;
expiryYear: string;
cvv: string;
country: string;
zip: string;
},
publishableKey: string,
) {
const query = buildQueryString({
'card[number]': data.cardNumber,
'card[exp_month]': data.expiryMonth,
@ -195,13 +214,15 @@ publishableKey: string) {
}
async function sendPaymentForm(
chat: ApiChat,
messageId: number,
formId: string,
credentials: any,
requestedInfoId?: string,
shippingOptionId?: string,
) {
const result = await callApi('sendPaymentForm', {
messageId, credentials, requestedInfoId, shippingOptionId,
chat, messageId, formId, credentials, requestedInfoId, shippingOptionId,
});
if (result) {
const global = clearPayment(getGlobal());

View File

@ -18,6 +18,7 @@ import { updateUser } from '../../reducers';
import { setLanguage } from '../../../util/langProvider';
import { selectNotifySettings } from '../../selectors';
import { forceWebsync } from '../../../util/websync';
import { getShippingError } from '../../../util/getReadableErrorText';
addReducer('apiUpdate', (global, actions, update: ApiUpdate) => {
if (DEBUG) {
@ -61,7 +62,10 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => {
actions.signOut();
}
if (actions.showDialog) {
const paymentShippingError = getShippingError(update.error);
if (paymentShippingError) {
actions.addPaymentError({ error: paymentShippingError });
} else if (actions.showDialog) {
actions.showDialog({ data: { ...update.error, hasErrorKey: true } });
}
@ -71,8 +75,10 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => {
function onUpdateApiReady(global: GlobalState) {
const { hasWebNotifications, hasPushNotifications } = selectNotifySettings(global);
if (hasWebNotifications && hasPushNotifications) subscribe();
setLanguage(global.settings.byKey.language);
if (hasWebNotifications && hasPushNotifications) {
void subscribe();
}
void setLanguage(global.settings.byKey.language);
}
function onUpdateAuthorizationState(update: ApiUpdateAuthorizationState) {

View File

@ -17,7 +17,7 @@ addReducer('init', (global) => {
const { animationLevel, messageTextSize, language } = global.settings.byKey;
const theme = selectTheme(global);
setLanguage(language, undefined, true);
void setLanguage(language, undefined, true);
document.documentElement.style.setProperty(
'--composer-text-size', `${Math.max(messageTextSize, IS_IOS ? 16 : 15)}px`,

View File

@ -1,14 +1,14 @@
import { addReducer } from '../../../lib/teact/teactn';
import {
clearPayment, closeInvoice,
} from '../../reducers';
import { clearPayment, closeInvoice } from '../../reducers';
addReducer('openPaymentModal', (global, actions, payload) => {
const { messageId } = payload;
const { chatId, messageId } = payload;
return {
...global,
payment: {
...global.payment,
chatId,
messageId,
isPaymentModalOpen: true,
},
@ -19,3 +19,15 @@ addReducer('closePaymentModal', (global) => {
const newGlobal = clearPayment(global);
return closeInvoice(newGlobal);
});
addReducer('addPaymentError', (global, actions, payload) => {
const { error } = payload!;
return {
...global,
payment: {
...global.payment,
error,
},
};
});

View File

@ -1,41 +1,41 @@
import { ApiError, ApiInviteInfo } from '../../api/types';
import { ApiFieldError } from '../../api/types';
const STRIPE_ERRORS: Record<string, Record<string, string>> = {
const STRIPE_ERRORS: Record<string, ApiFieldError> = {
missing_payment_information: {
field: 'cardNumber',
fieldError: 'Incorrect card number',
message: 'Incorrect card number',
},
invalid_number: {
field: 'cardNumber',
fieldError: 'Incorrect card number',
message: 'Incorrect card number',
},
number: {
field: 'cardNumber',
fieldError: 'Incorrect card number',
message: 'Incorrect card number',
},
exp_year: {
field: 'expiry',
fieldError: 'Incorrect year',
message: 'Incorrect year',
},
exp_month: {
field: 'expiry',
fieldError: 'Incorrect month',
message: 'Incorrect month',
},
invalid_expiry_year: {
field: 'expiry',
fieldError: 'Incorrect year',
message: 'Incorrect year',
},
invalid_expiry_month: {
field: 'expiry',
fieldError: 'Incorrect month',
message: 'Incorrect month',
},
cvc: {
field: 'cvv',
fieldError: 'Incorrect CVV',
message: 'Incorrect CVV',
},
invalid_cvc: {
field: 'cvv',
fieldError: 'Incorrect CVV',
message: 'Incorrect CVV',
},
};
@ -44,65 +44,8 @@ export function getStripeError(error: {
message: string;
param?: string;
}) {
const { message, code, param } = error;
const { field, fieldError, description } = param ? STRIPE_ERRORS[param] : STRIPE_ERRORS[code];
return {
field,
fieldError,
description: description || message,
};
}
const SHIPPING_ERRORS: Record<string, Record<string, string>> = {
ADDRESS_STREET_LINE1_INVALID: {
field: 'streetLine1',
fieldError: 'Incorrect street address',
},
ADDRESS_STREET_LINE2_INVALID: {
field: 'streetLine2',
fieldError: 'Incorrect street address',
},
ADDRESS_CITY_INVALID: {
field: 'city',
fieldError: 'Incorrect city',
},
ADDRESS_COUNTRY_INVALID: {
field: 'countryIso2',
fieldError: 'Incorrect country',
},
ADDRESS_POSTCODE_INVALID: {
field: 'postCode',
fieldError: 'Incorrect post code',
},
ADDRESS_STATE_INVALID: {
field: 'state',
fieldError: 'Incorrect state',
},
REQ_INFO_NAME_INVALID: {
field: 'fullName',
fieldError: 'Incorrect name',
},
REQ_INFO_PHONE_INVALID: {
field: 'phone',
fieldError: 'Incorrect phone',
},
REQ_INFO_EMAIL_INVALID: {
field: 'email',
fieldError: 'Incorrect email',
},
};
export function getShippingErrors(dialogs: (ApiError | ApiInviteInfo)[]) {
return Object.values(dialogs).reduce((acc, cur) => {
if (!('hasErrorKey' in cur) || !cur.hasErrorKey) return acc;
const error = SHIPPING_ERRORS[cur.message];
if (error) {
acc = {
...acc,
[error.field]: error.fieldError,
};
}
return acc;
}, {});
const { message: description, code, param } = error;
const { field, message } = param ? STRIPE_ERRORS[param] : STRIPE_ERRORS[code];
return { field, message, description};
}

View File

@ -20,7 +20,7 @@ export function setRequestInfoId(global: GlobalState, id: string): GlobalState {
...global,
payment: {
...global.payment,
formId: id,
requestId: id,
},
};
}
@ -42,7 +42,9 @@ export function setInvoiceMessageInfo(global: GlobalState, message: ApiMessage):
const {
title,
text,
description,
amount,
currency,
isTest,
photoUrl,
} = message.content.invoice;
return {
@ -52,8 +54,10 @@ export function setInvoiceMessageInfo(global: GlobalState, message: ApiMessage):
invoiceContent: {
title,
text,
description,
photoUrl,
amount,
currency,
isTest,
},
},
};

View File

@ -1,14 +1,22 @@
import { GlobalState } from '../../global/types';
export function selectPaymentChatId(global: GlobalState) {
return global.payment.chatId;
}
export function selectPaymentMessageId(global: GlobalState) {
return global.payment.messageId;
}
export function selectPaymentRequestId(global: GlobalState) {
export function selectPaymentFormId(global: GlobalState) {
return global.payment.formId;
}
export function selectPaymentRequestId(global: GlobalState) {
return global.payment.requestId;
}
export function selectProviderPublishableKey(global: GlobalState) {
return global.payment.nativeParams ? global.payment.nativeParams.publishableKey : undefined;
}

View File

@ -0,0 +1,8 @@
import { LangCode } from '../types';
export function formatCurrency(totalPrice: number, currency?: string, locale: LangCode = 'en') {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(currency === 'JPY' ? totalPrice : totalPrice / 100);
}

View File

@ -1,4 +1,4 @@
import { ApiError } from '../api/types';
import { ApiError, ApiFieldError } from '../api/types';
const READABLE_ERROR_MESSAGES: Record<string, string> = {
CHAT_RESTRICTED: 'You can\'t send messages in this chat, you were restricted',
@ -64,6 +64,45 @@ const READABLE_ERROR_MESSAGES: Record<string, string> = {
WALLPAPER_DIMENSIONS_INVALID: 'The wallpaper dimensions are invalid, please select another file',
};
export const SHIPPING_ERRORS: Record<string, ApiFieldError> = {
ADDRESS_STREET_LINE1_INVALID: {
field: 'streetLine1',
message: 'Incorrect street address',
},
ADDRESS_STREET_LINE2_INVALID: {
field: 'streetLine2',
message: 'Incorrect street address',
},
ADDRESS_CITY_INVALID: {
field: 'city',
message: 'Incorrect city',
},
ADDRESS_COUNTRY_INVALID: {
field: 'countryIso2',
message: 'Incorrect country',
},
ADDRESS_POSTCODE_INVALID: {
field: 'postCode',
message: 'Incorrect post code',
},
ADDRESS_STATE_INVALID: {
field: 'state',
message: 'Incorrect state',
},
REQ_INFO_NAME_INVALID: {
field: 'fullName',
message: 'Incorrect name',
},
REQ_INFO_PHONE_INVALID: {
field: 'phone',
message: 'Incorrect phone',
},
REQ_INFO_EMAIL_INVALID: {
field: 'email',
message: 'Incorrect email',
},
};
export default function getReadableErrorText(error: ApiError) {
const { message, isSlowMode, textParams } = error;
// Currently, Telegram API doesn't return `SLOWMODE_WAIT_X` error as described in the docs
@ -79,3 +118,7 @@ export default function getReadableErrorText(error: ApiError) {
}
return errorMessage;
}
export function getShippingError(error: ApiError): ApiFieldError | undefined {
return SHIPPING_ERRORS[error.message];
}

View File

@ -1,4 +1,7 @@
import { getGlobal } from '../lib/teact/teactn';
import { ApiLangPack, ApiLangString } from '../api/types';
import { LangCode } from '../types';
import {
DEFAULT_LANG_CODE, DEFAULT_LANG_PACK, LANG_CACHE_NAME, LANG_PACKS,
@ -7,13 +10,12 @@ import * as cacheApi from './cacheApi';
import { callApi } from '../api/gramjs';
import { createCallbackManager } from './callbacks';
import { formatInteger } from './textFormat';
import { getGlobal } from '../lib/teact/teactn';
interface LangFn {
(key: string, value?: any, format?: 'i'): any;
isRtl?: boolean;
code?: string;
code?: LangCode;
}
const SUBSTITUTION_REGEX = /%\d?\$?[sdf@]/g;
@ -95,7 +97,7 @@ export async function getTranslationForLangString(langCode: string, key: string)
return processTranslation(translateString, key);
}
export async function setLanguage(langCode: string, callback?: NoneToVoidFunction, withFallback = false) {
export async function setLanguage(langCode: LangCode, callback?: NoneToVoidFunction, withFallback = false) {
if (langPack && langCode === currentLangCode) {
if (callback) {
callback();