Payments: Support tips and saving payment info, refactoring (#2097)

This commit is contained in:
Alexander Zinchuk 2022-11-01 18:53:44 +01:00
parent 47032259a2
commit 9a26a8270a
34 changed files with 1008 additions and 451 deletions

View File

@ -752,8 +752,8 @@ export function buildInvoice(media: GramJs.MessageMediaInvoice): ApiInvoice {
} = media; } = media;
return { return {
text,
title, title,
text,
photo: buildApiWebDocument(photo), photo: buildApiWebDocument(photo),
receiptMsgId, receiptMsgId,
amount: Number(totalAmount), amount: Number(totalAmount),

View File

@ -2,6 +2,7 @@ import type { Api as GramJs } from '../../../lib/gramjs';
import type { import type {
ApiInvoice, ApiPaymentSavedInfo, ApiPremiumPromo, ApiPremiumSubscriptionOption, ApiInvoice, ApiPaymentSavedInfo, ApiPremiumPromo, ApiPremiumSubscriptionOption,
ApiPaymentForm, ApiReceipt, ApiLabeledPrice, ApiPaymentCredentials,
} from '../../types'; } from '../../types';
import { buildApiDocument, buildApiMessageEntity, buildApiWebDocument } from './messages'; import { buildApiDocument, buildApiMessageEntity, buildApiWebDocument } from './messages';
@ -11,6 +12,7 @@ export function buildShippingOptions(shippingOptions: GramJs.ShippingOption[] |
if (!shippingOptions) { if (!shippingOptions) {
return undefined; return undefined;
} }
return Object.values(shippingOptions).map((option) => { return Object.values(shippingOptions).map((option) => {
return { return {
id: option.id, id: option.id,
@ -26,7 +28,7 @@ export function buildShippingOptions(shippingOptions: GramJs.ShippingOption[] |
}); });
} }
export function buildReceipt(receipt: GramJs.payments.PaymentReceipt) { export function buildApiReceipt(receipt: GramJs.payments.PaymentReceipt): ApiReceipt {
const { const {
invoice, invoice,
info, info,
@ -34,18 +36,19 @@ export function buildReceipt(receipt: GramJs.payments.PaymentReceipt) {
currency, currency,
totalAmount, totalAmount,
credentialsTitle, credentialsTitle,
tipAmount,
} = receipt; } = receipt;
const { shippingAddress, phone, name } = (info || {}); const { shippingAddress, phone, name } = (info || {});
const { prices } = invoice; const { prices } = invoice;
const mapedPrices = prices.map(({ label, amount }) => ({ const mappedPrices: ApiLabeledPrice[] = prices.map(({ label, amount }) => ({
label, label,
amount: amount.toJSNumber(), amount: amount.toJSNumber(),
})); }));
let shippingPrices; let shippingPrices: ApiLabeledPrice[] | undefined;
let shippingMethod; let shippingMethod: string | undefined;
if (shipping) { if (shipping) {
shippingPrices = shipping.prices.map(({ label, amount }) => { shippingPrices = shipping.prices.map(({ label, amount }) => {
@ -59,41 +62,43 @@ export function buildReceipt(receipt: GramJs.payments.PaymentReceipt) {
return { return {
currency, currency,
prices: mapedPrices, prices: mappedPrices,
info: { shippingAddress, phone, name }, info: { shippingAddress, phone, name },
totalAmount: totalAmount.toJSNumber(), totalAmount: totalAmount.toJSNumber(),
credentialsTitle, credentialsTitle,
shippingPrices, shippingPrices,
shippingMethod, shippingMethod,
tipAmount: tipAmount ? tipAmount.toJSNumber() : 0,
}; };
} }
export function buildPaymentForm(form: GramJs.payments.PaymentForm) { export function buildApiPaymentForm(form: GramJs.payments.PaymentForm): ApiPaymentForm {
const { const {
formId, formId,
canSaveCredentials, canSaveCredentials,
passwordMissing, passwordMissing: isPasswordMissing,
providerId, providerId,
nativeProvider, nativeProvider,
nativeParams, nativeParams,
savedInfo, savedInfo,
invoice, invoice,
savedCredentials,
} = form; } = form;
const { const {
test, test: isTest,
nameRequested, nameRequested: isNameRequested,
phoneRequested, phoneRequested: isPhoneRequested,
emailRequested, emailRequested: isEmailRequested,
shippingAddressRequested, shippingAddressRequested: isShippingAddressRequested,
flexible, flexible: isFlexible,
phoneToProvider, phoneToProvider: shouldSendPhoneToProvider,
emailToProvider, emailToProvider: shouldSendEmailToProvider,
currency, currency,
prices, prices,
} = invoice; } = invoice;
const mappedPrices = prices.map(({ label, amount }) => ({ const mappedPrices: ApiLabeledPrice[] = prices.map(({ label, amount }) => ({
label, label,
amount: amount.toJSNumber(), amount: amount.toJSNumber(),
})); }));
@ -107,30 +112,31 @@ export function buildPaymentForm(form: GramJs.payments.PaymentForm) {
return { return {
canSaveCredentials, canSaveCredentials,
passwordMissing, isPasswordMissing,
formId: String(formId), formId: String(formId),
providerId: String(providerId), providerId: String(providerId),
nativeProvider, nativeProvider,
savedInfo: cleanedInfo, savedInfo: cleanedInfo,
invoice: { invoiceContainer: {
test, isTest,
nameRequested, isNameRequested,
phoneRequested, isPhoneRequested,
emailRequested, isEmailRequested,
shippingAddressRequested, isShippingAddressRequested,
flexible, isFlexible,
phoneToProvider, shouldSendPhoneToProvider,
emailToProvider, shouldSendEmailToProvider,
currency, currency,
prices: mappedPrices, prices: mappedPrices,
}, },
nativeParams: { nativeParams: {
needCardholderName: nativeData.need_cardholder_name, needCardholderName: Boolean(nativeData?.need_cardholder_name),
needCountry: nativeData.need_country, needCountry: Boolean(nativeData?.need_country),
needZip: nativeData.need_zip, needZip: Boolean(nativeData?.need_zip),
publishableKey: nativeData.publishable_key, publishableKey: nativeData?.publishable_key,
publicToken: nativeData?.public_token, publicToken: nativeData?.public_token,
}, },
...(savedCredentials && { savedCredentials: buildApiPaymentCredentials(savedCredentials) }),
}; };
} }
@ -139,7 +145,7 @@ export function buildApiInvoiceFromForm(form: GramJs.payments.PaymentForm): ApiI
invoice, description: text, title, photo, invoice, description: text, title, photo,
} = form; } = form;
const { const {
test, currency, prices, recurring, recurringTermsUrl, test, currency, prices, recurring, recurringTermsUrl, maxTipAmount, suggestedTipAmounts,
} = invoice; } = invoice;
const totalAmount = prices.reduce((ac, cur) => ac + cur.amount.toJSNumber(), 0); const totalAmount = prices.reduce((ac, cur) => ac + cur.amount.toJSNumber(), 0);
@ -153,6 +159,8 @@ export function buildApiInvoiceFromForm(form: GramJs.payments.PaymentForm): ApiI
isTest: test, isTest: test,
isRecurring: recurring, isRecurring: recurring,
recurringTermsUrl, recurringTermsUrl,
maxTipAmount: maxTipAmount?.toJSNumber(),
...(suggestedTipAmounts && { suggestedTipAmounts: suggestedTipAmounts.map((tip) => tip.toJSNumber()) }),
}; };
} }
@ -184,3 +192,7 @@ function buildApiPremiumSubscriptionOption(option: GramJs.PremiumSubscriptionOpt
months, months,
}; };
} }
export function buildApiPaymentCredentials(credentials: GramJs.PaymentSavedCredentialsCard[]): ApiPaymentCredentials[] {
return credentials.map(({ id, title }) => ({ id, title }));
}

View File

@ -309,6 +309,10 @@ export function updateTwoFaSettings(params: TwoFaParams) {
return client.updateTwoFaSettings(params); return client.updateTwoFaSettings(params);
} }
export function getTmpPassword(currentPassword: string, ttl?: number) {
return client.getTmpPassword(currentPassword, ttl);
}
export async function fetchCurrentUser() { export async function fetchCurrentUser() {
const userFull = await invokeRequest(new GramJs.users.GetFullUser({ const userFull = await invokeRequest(new GramJs.users.GetFullUser({
id: new GramJs.InputUserSelf(), id: new GramJs.InputUserSelf(),

View File

@ -74,7 +74,7 @@ export {
} from './bots'; } from './bots';
export { export {
validateRequestedInfo, sendPaymentForm, getPaymentForm, getReceipt, fetchPremiumPromo, validateRequestedInfo, sendPaymentForm, getPaymentForm, getReceipt, fetchPremiumPromo, fetchTemporaryPaymentPassword,
} from './payments'; } from './payments';
export { export {

View File

@ -3,14 +3,23 @@ import { Api as GramJs } from '../../../lib/gramjs';
import { invokeRequest } from './client'; import { invokeRequest } from './client';
import { buildInputInvoice, buildInputPeer, buildShippingInfo } from '../gramjsBuilders'; import { buildInputInvoice, buildInputPeer, buildShippingInfo } from '../gramjsBuilders';
import { import {
buildShippingOptions, buildPaymentForm, buildReceipt, buildApiPremiumPromo, buildApiInvoiceFromForm, buildApiInvoiceFromForm,
buildApiPremiumPromo,
buildApiPaymentForm,
buildApiReceipt,
buildShippingOptions,
} from '../apiBuilders/payments'; } from '../apiBuilders/payments';
import type { import type {
ApiChat, OnApiUpdate, ApiRequestInputInvoice, ApiChat, OnApiUpdate, ApiRequestInputInvoice,
} from '../../types'; } from '../../types';
import localDb from '../localDb'; import localDb from '../localDb';
import { addEntitiesWithPhotosToLocalDb } from '../helpers'; import {
addEntitiesWithPhotosToLocalDb,
deserializeBytes,
serializeBytes,
} from '../helpers';
import { buildApiUser } from '../apiBuilders/users'; import { buildApiUser } from '../apiBuilders/users';
import { getTemporaryPaymentPassword } from './twoFaSettings';
let onUpdate: OnApiUpdate; let onUpdate: OnApiUpdate;
@ -56,22 +65,35 @@ export async function sendPaymentForm({
requestedInfoId, requestedInfoId,
shippingOptionId, shippingOptionId,
credentials, credentials,
savedCredentialId,
temporaryPassword,
tipAmount,
}: { }: {
inputInvoice: ApiRequestInputInvoice; inputInvoice: ApiRequestInputInvoice;
formId: string; formId: string;
credentials: any; credentials: any;
requestedInfoId?: string; requestedInfoId?: string;
shippingOptionId?: string; shippingOptionId?: string;
savedCredentialId?: string;
temporaryPassword?: string;
tipAmount?: number;
}) { }) {
const inputCredentials = temporaryPassword && savedCredentialId
? new GramJs.InputPaymentCredentialsSaved({
id: savedCredentialId,
tmpPassword: deserializeBytes(temporaryPassword),
})
: new GramJs.InputPaymentCredentials({
save: credentials.save,
data: new GramJs.DataJSON({ data: JSON.stringify(credentials.data) }),
});
const result = await invokeRequest(new GramJs.payments.SendPaymentForm({ const result = await invokeRequest(new GramJs.payments.SendPaymentForm({
formId: BigInt(formId), formId: BigInt(formId),
invoice: buildInputInvoice(inputInvoice), invoice: buildInputInvoice(inputInvoice),
requestedInfoId, requestedInfoId,
shippingOptionId, shippingOptionId,
credentials: new GramJs.InputPaymentCredentials({ credentials: inputCredentials,
save: credentials.save, ...(tipAmount && { tipAmount: BigInt(tipAmount) }),
data: new GramJs.DataJSON({ data: JSON.stringify(credentials.data) }),
}),
})); }));
if (result instanceof GramJs.payments.PaymentVerificationNeeded) { if (result instanceof GramJs.payments.PaymentVerificationNeeded) {
@ -99,8 +121,10 @@ export async function getPaymentForm(inputInvoice: ApiRequestInputInvoice) {
localDb.webDocuments[result.photo.url] = result.photo; localDb.webDocuments[result.photo.url] = result.photo;
} }
addEntitiesWithPhotosToLocalDb(result.users);
return { return {
form: buildPaymentForm(result), form: buildApiPaymentForm(result),
invoice: buildApiInvoiceFromForm(result), invoice: buildApiInvoiceFromForm(result),
}; };
} }
@ -110,11 +134,12 @@ export async function getReceipt(chat: ApiChat, msgId: number) {
peer: buildInputPeer(chat.id, chat.accessHash), peer: buildInputPeer(chat.id, chat.accessHash),
msgId, msgId,
})); }));
if (!result) { if (!result) {
return undefined; return undefined;
} }
return buildReceipt(result); return buildApiReceipt(result);
} }
export async function fetchPremiumPromo() { export async function fetchPremiumPromo() {
@ -135,3 +160,20 @@ export async function fetchPremiumPromo() {
users, users,
}; };
} }
export async function fetchTemporaryPaymentPassword(password: string) {
const result = await getTemporaryPaymentPassword(password);
if (!result) {
return undefined;
}
if ('error' in result) {
return result;
}
return {
value: serializeBytes(result.tmpPassword),
validUntil: result.validUntil,
};
}

View File

@ -3,7 +3,7 @@ import { Api as GramJs, errors } from '../../../lib/gramjs';
import type { OnApiUpdate } from '../../types'; import type { OnApiUpdate } from '../../types';
import { DEBUG } from '../../../config'; import { DEBUG } from '../../../config';
import { invokeRequest, updateTwoFaSettings } from './client'; import { invokeRequest, updateTwoFaSettings, getTmpPassword } from './client';
const ApiErrors: { [k: string]: string } = { const ApiErrors: { [k: string]: string } = {
EMAIL_UNCONFIRMED: 'Email unconfirmed', EMAIL_UNCONFIRMED: 'Email unconfirmed',
@ -48,6 +48,10 @@ function onRequestEmailCode(length: number) {
}); });
} }
export function getTemporaryPaymentPassword(password: string, ttl?: number) {
return getTmpPassword(password, ttl);
}
export async function checkPassword(currentPassword: string) { export async function checkPassword(currentPassword: string) {
try { try {
await updateTwoFaSettings({ isCheckPassword: true, currentPassword }); await updateTwoFaSettings({ isCheckPassword: true, currentPassword });

View File

@ -187,6 +187,13 @@ export interface ApiInvoice {
isTest?: boolean; isTest?: boolean;
isRecurring?: boolean; isRecurring?: boolean;
recurringTermsUrl?: string; recurringTermsUrl?: string;
maxTipAmount?: number;
suggestedTipAmounts?: number[];
}
export interface ApiPaymentCredentials {
id: string;
title: string;
} }
interface ApiGeoPoint { interface ApiGeoPoint {

View File

@ -1,4 +1,6 @@
import type { ApiDocument, ApiMessageEntity } from './messages'; import type { ApiDocument, ApiMessageEntity, ApiPaymentCredentials } from './messages';
import type { ApiWebDocument } from './bots';
import type { ApiInvoiceContainer } from '../../types';
export interface ApiShippingAddress { export interface ApiShippingAddress {
streetLine1: string; streetLine1: string;
@ -18,22 +20,13 @@ export interface ApiPaymentSavedInfo {
export interface ApiPaymentForm { export interface ApiPaymentForm {
canSaveCredentials?: boolean; canSaveCredentials?: boolean;
passwordMissing?: boolean; isPasswordMissing?: boolean;
formId: string;
providerId: string; providerId: string;
nativeProvider?: string; nativeProvider?: string;
savedInfo: any; savedInfo?: ApiPaymentSavedInfo;
invoice: { savedCredentials?: ApiPaymentCredentials[];
test?: boolean; invoiceContainer: ApiInvoiceContainer;
nameRequested?: boolean;
phoneRequested?: boolean;
emailRequested?: boolean;
shippingAddressRequested?: boolean;
flexible?: boolean;
phoneToProvider?: boolean;
emailToProvider?: boolean;
currency?: string;
prices?: ApiLabeledPrice[];
};
nativeParams: ApiPaymentFormNativeParams; nativeParams: ApiPaymentFormNativeParams;
} }
@ -51,6 +44,9 @@ export interface ApiLabeledPrice {
} }
export interface ApiReceipt { export interface ApiReceipt {
photo?: ApiWebDocument;
text?: string;
title?: string;
currency: string; currency: string;
prices: ApiLabeledPrice[]; prices: ApiLabeledPrice[];
info?: { info?: {
@ -58,6 +54,7 @@ export interface ApiReceipt {
phone?: string; phone?: string;
name?: string; name?: string;
}; };
tipAmount: number;
totalAmount: number; totalAmount: number;
credentialsTitle: string; credentialsTitle: string;
shippingPrices?: ApiLabeledPrice[]; shippingPrices?: ApiLabeledPrice[];

View File

@ -7,6 +7,7 @@ import React, {
import { MIN_PASSWORD_LENGTH } from '../../config'; import { MIN_PASSWORD_LENGTH } from '../../config';
import { IS_TOUCH_ENV, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment'; import { IS_TOUCH_ENV, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import stopEvent from '../../util/stopEvent';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useTimeout from '../../hooks/useTimeout'; import useTimeout from '../../hooks/useTimeout';
@ -17,6 +18,7 @@ type OwnProps = {
error?: string; error?: string;
hint?: string; hint?: string;
placeholder?: string; placeholder?: string;
description?: string;
isLoading?: boolean; isLoading?: boolean;
shouldDisablePasswordManager?: boolean; shouldDisablePasswordManager?: boolean;
shouldShowSubmit?: boolean; shouldShowSubmit?: boolean;
@ -26,7 +28,7 @@ type OwnProps = {
noRipple?: boolean; noRipple?: boolean;
onChangePasswordVisibility: (state: boolean) => void; onChangePasswordVisibility: (state: boolean) => void;
onInputChange?: (password: string) => void; onInputChange?: (password: string) => void;
onSubmit: (password: string) => void; onSubmit?: (password: string) => void;
}; };
const FOCUS_DELAY_TIMEOUT_MS = IS_SINGLE_COLUMN_LAYOUT ? 550 : 400; const FOCUS_DELAY_TIMEOUT_MS = IS_SINGLE_COLUMN_LAYOUT ? 550 : 400;
@ -38,6 +40,7 @@ const PasswordForm: FC<OwnProps> = ({
hint, hint,
placeholder = 'Password', placeholder = 'Password',
submitLabel = 'Next', submitLabel = 'Next',
description,
shouldShowSubmit, shouldShowSubmit,
shouldResetValue, shouldResetValue,
shouldDisablePasswordManager = false, shouldDisablePasswordManager = false,
@ -100,7 +103,7 @@ const PasswordForm: FC<OwnProps> = ({
} }
if (canSubmit) { if (canSubmit) {
onSubmit(password); onSubmit!(password);
} }
} }
@ -117,7 +120,7 @@ const PasswordForm: FC<OwnProps> = ({
} }
return ( return (
<form action="" onSubmit={handleSubmit} autoComplete="off"> <form action="" onSubmit={onSubmit ? handleSubmit : stopEvent} autoComplete="off">
<div <div
className={buildClassName('input-group password-input', password && 'touched', error && 'error')} className={buildClassName('input-group password-input', password && 'touched', error && 'error')}
dir={lang.isRtl ? 'rtl' : undefined} dir={lang.isRtl ? 'rtl' : undefined}
@ -145,7 +148,8 @@ const PasswordForm: FC<OwnProps> = ({
<i className={isPasswordVisible ? 'icon-eye' : 'icon-eye-closed'} /> <i className={isPasswordVisible ? 'icon-eye' : 'icon-eye-closed'} />
</div> </div>
</div> </div>
{(canSubmit || shouldShowSubmit) && ( {description && <p className="description">{description}</p>}
{onSubmit && (canSubmit || shouldShowSubmit) && (
<Button type="submit" ripple={!noRipple} isLoading={isLoading} disabled={!canSubmit}> <Button type="submit" ripple={!noRipple} isLoading={isLoading} disabled={!canSubmit}>
{submitLabel} {submitLabel}
</Button> </Button>

View File

@ -59,29 +59,54 @@
} }
} }
.invoice-info { .tipsList {
border-top: 1px var(--color-borders) solid;
padding: 1rem;
}
.checkout-info-item {
display: flex; display: flex;
padding: 0.75rem 0.5rem 1rem; flex-wrap: wrap;
text-align: left; justify-content: space-between;
gap: 0.5rem
} }
.checkout-info-item-icon { .tipsItem {
font-size: 1.5rem; border-radius: 1.375rem;
color: var(--color-text-secondary); padding: 0 0.75rem;
margin-right: 2rem; height: 2.5rem;
width: 1.5rem; min-width: 5rem;
line-height: 2.5rem;
text-align: center;
background: var(--color-primary-opacity);
color: var(--color-primary);
transition: background-color 200ms, color 200ms;
cursor: pointer;
font-weight: 500;
&:hover,
&:focus {
background: var(--color-primary-opacity-hover);
}
&_active {
color: var(--color-white);
background: var(--color-primary) !important;
}
:global(.theme-dark) & {
color: var(--color-white);
}
}
.invoice-info {
border-top: 0.0625rem var(--color-borders) solid;
padding: 0.5rem;
} }
.provider { .provider {
float: left;
background: no-repeat center; background: no-repeat center;
background-size: 2rem; background-size: 2rem;
border-radius: 1rem; border-radius: 1rem;
width: 1.5rem;
height: 1.5rem; height: 1.5rem;
margin-right: 2rem;
} }
.provider.stripe { .provider.stripe {

View File

@ -1,9 +1,12 @@
import React, { memo, useCallback } from '../../lib/teact/teact';
import { getActions } from '../../global';
import type { FC } from '../../lib/teact/teact'; import type { FC } from '../../lib/teact/teact';
import React, { memo } from '../../lib/teact/teact'; import type { FormEditDispatch } from '../../hooks/reducers/usePaymentReducer';
import type { LangCode, Price } from '../../types'; import type { LangCode, Price } from '../../types';
import type { ApiChat, ApiWebDocument } from '../../api/types'; import type { ApiChat, ApiInvoice, ApiPaymentCredentials } from '../../api/types';
import { PaymentStep } from '../../types';
import { getWebDocumentHash } from '../../global/helpers'; import { getWebDocumentHash } from '../../global/helpers';
import { formatCurrency } from '../../util/formatCurrency'; import { formatCurrency } from '../../util/formatCurrency';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
@ -15,18 +18,13 @@ import useMedia from '../../hooks/useMedia';
import Checkbox from '../ui/Checkbox'; import Checkbox from '../ui/Checkbox';
import Skeleton from '../ui/Skeleton'; import Skeleton from '../ui/Skeleton';
import SafeLink from '../common/SafeLink'; import SafeLink from '../common/SafeLink';
import ListItem from '../ui/ListItem';
import styles from './Checkout.module.scss'; import styles from './Checkout.module.scss';
export type OwnProps = { export type OwnProps = {
chat?: ApiChat; chat?: ApiChat;
invoiceContent?: { invoice?: ApiInvoice;
title?: string;
text?: string;
photo?: ApiWebDocument;
isRecurring?: boolean;
recurringTermsUrl?: string;
};
checkoutInfo?: { checkoutInfo?: {
paymentMethod?: string; paymentMethod?: string;
paymentProvider?: string; paymentProvider?: string;
@ -37,28 +35,41 @@ export type OwnProps = {
}; };
prices?: Price[]; prices?: Price[];
totalPrice?: number; totalPrice?: number;
needAddress?: boolean;
hasShippingOptions?: boolean;
tipAmount?: number;
shippingPrices?: Price[]; shippingPrices?: Price[];
currency: string; currency: string;
isTosAccepted?: boolean; isTosAccepted?: boolean;
dispatch?: FormEditDispatch;
onAcceptTos?: (isAccepted: boolean) => void; onAcceptTos?: (isAccepted: boolean) => void;
savedCredentials?: ApiPaymentCredentials[];
}; };
const Checkout: FC<OwnProps> = ({ const Checkout: FC<OwnProps> = ({
chat, chat,
invoiceContent, invoice,
prices, prices,
shippingPrices, shippingPrices,
checkoutInfo, checkoutInfo,
currency, currency,
totalPrice, totalPrice,
isTosAccepted, isTosAccepted,
dispatch,
onAcceptTos, onAcceptTos,
tipAmount,
needAddress,
hasShippingOptions,
savedCredentials,
}) => { }) => {
const { setPaymentStep } = getActions();
const lang = useLang(); const lang = useLang();
const isInteractive = Boolean(dispatch);
const { const {
photo, title, text, isRecurring, recurringTermsUrl, photo, title, text, isRecurring, recurringTermsUrl, suggestedTipAmounts, maxTipAmount,
} = invoiceContent || {}; } = invoice || {};
const { const {
paymentMethod, paymentMethod,
paymentProvider, paymentProvider,
@ -70,6 +81,48 @@ const Checkout: FC<OwnProps> = ({
const photoUrl = useMedia(getWebDocumentHash(photo)); const photoUrl = useMedia(getWebDocumentHash(photo));
const handleTipsClick = useCallback((tips: number) => {
dispatch!({ type: 'setTipAmount', payload: maxTipAmount ? Math.min(tips, maxTipAmount) : tips });
}, [dispatch, maxTipAmount]);
const handlePaymentMethodClick = useCallback(() => {
setPaymentStep({ step: savedCredentials?.length ? PaymentStep.SavedPayments : PaymentStep.PaymentInfo });
}, [savedCredentials?.length, setPaymentStep]);
const handleShippingAddressClick = useCallback(() => {
setPaymentStep({ step: PaymentStep.ShippingInfo });
}, [setPaymentStep]);
const handleShippingMethodClick = useCallback(() => {
setPaymentStep({ step: PaymentStep.Shipping });
}, [setPaymentStep]);
function renderTips() {
return (
<>
<div className={styles.priceInfoItem}>
<div className={styles.priceInfoItemTitle}>
{title}
</div>
<div>
{formatCurrency(tipAmount!, currency, lang.code)}
</div>
</div>
<div className={styles.tipsList}>
{suggestedTipAmounts!.map((tip) => (
<div
key={tip}
className={buildClassName(styles.tipsItem, tip === tipAmount && styles.tipsItem_active)}
onClick={dispatch ? () => handleTipsClick(tip === tipAmount ? 0 : tip) : undefined}
>
{formatCurrency(tip, currency, lang.code, true)}
</div>
))}
</div>
</>
);
}
function renderTosLink(url: string, isRtl?: boolean) { function renderTosLink(url: string, isRtl?: boolean) {
const langString = lang('PaymentCheckoutAcceptRecurrent', chat?.title); const langString = lang('PaymentCheckoutAcceptRecurrent', chat?.title);
const langStringSplit = langString.split('*'); const langStringSplit = langString.split('*');
@ -125,27 +178,53 @@ const Checkout: FC<OwnProps> = ({
{shippingPrices && shippingPrices.map((item) => ( {shippingPrices && shippingPrices.map((item) => (
renderPaymentItem(lang.code, item.label, item.amount, currency) renderPaymentItem(lang.code, item.label, item.amount, currency)
))} ))}
{suggestedTipAmounts && suggestedTipAmounts.length > 0 && renderTips()}
{totalPrice !== undefined && ( {totalPrice !== undefined && (
renderPaymentItem(lang.code, lang('Checkout.TotalAmount'), totalPrice, currency, true) renderPaymentItem(lang.code, lang('Checkout.TotalAmount'), totalPrice, currency, true)
)} )}
</div> </div>
<div className={styles.invoiceInfo}> <div className={styles.invoiceInfo}>
{paymentMethod && renderCheckoutItem('icon-card', paymentMethod, lang('PaymentCheckoutMethod'))} {renderCheckoutItem({
{paymentProvider && renderCheckoutItem( title: paymentMethod || savedCredentials?.[0].title,
buildClassName(styles.provider, styles[paymentProvider.toLowerCase()]), label: lang('PaymentCheckoutMethod'),
paymentProvider, icon: 'card',
lang('PaymentCheckoutProvider'), onClick: isInteractive ? handlePaymentMethodClick : undefined,
)} })}
{shippingAddress && renderCheckoutItem('icon-location', shippingAddress, lang('PaymentShippingAddress'))} {paymentProvider && renderCheckoutItem({
{name && renderCheckoutItem('icon-user', name, lang('PaymentCheckoutName'))} title: paymentProvider,
{phone && renderCheckoutItem('icon-phone', phone, lang('PaymentCheckoutPhoneNumber'))} label: lang('PaymentCheckoutProvider'),
{shippingMethod && renderCheckoutItem('icon-truck', shippingMethod, lang('PaymentCheckoutShippingMethod'))} customIcon: buildClassName(styles.provider, styles[paymentProvider.toLowerCase()]),
})}
{(needAddress || !isInteractive) && renderCheckoutItem({
title: shippingAddress,
label: lang('PaymentShippingAddress'),
icon: 'location',
onClick: isInteractive ? handleShippingAddressClick : undefined,
})}
{name && renderCheckoutItem({
title: name,
label: lang('PaymentCheckoutName'),
icon: 'user',
})}
{phone && renderCheckoutItem({
title: phone,
label: lang('PaymentCheckoutPhoneNumber'),
icon: 'phone',
})}
{(hasShippingOptions || !isInteractive) && renderCheckoutItem({
title: shippingMethod,
label: lang('PaymentCheckoutShippingMethod'),
icon: 'truck',
onClick: isInteractive ? handleShippingMethodClick : undefined,
})}
{isRecurring && renderTos(recurringTermsUrl!)} {isRecurring && renderTos(recurringTermsUrl!)}
</div> </div>
</div> </div>
); );
}; };
export default memo(Checkout);
function renderPaymentItem( function renderPaymentItem(
langCode: LangCode | undefined, title: string, value: number, currency: string, main = false, langCode: LangCode | undefined, title: string, value: number, currency: string, main = false,
) { ) {
@ -161,20 +240,35 @@ function renderPaymentItem(
); );
} }
function renderCheckoutItem(icon: string, title: string, data: string) { function renderCheckoutItem({
title,
label,
icon,
customIcon,
onClick,
}: {
title : string | undefined;
label: string | undefined;
icon?: string;
onClick?: NoneToVoidFunction;
customIcon?: string;
}) {
return ( return (
<div className={styles.checkoutInfoItem}> <ListItem
<i className={buildClassName(icon, styles.checkoutInfoItemIcon)}> </i> multiline={Boolean(title && label !== title)}
<div className={styles.checkoutInfoItemInfo}> icon={icon}
<div className={styles.checkoutInfoItemInfoTitle}> inactive={!onClick}
{title} onClick={onClick}
</div> >
<p className={styles.checkoutInfoItemInfoData}> {customIcon && <i className={customIcon} />}
{data} <div className={styles.checkoutInfoItemInfoTitle}>
</p> {title || label}
</div> </div>
</div> {title && label !== title && (
<p className={styles.checkoutInfoItemInfoData}>
{label}
</p>
)}
</ListItem>
); );
} }
export default memo(Checkout);

View File

@ -0,0 +1,70 @@
import React, { memo, useMemo, useState } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { FC } from '../../lib/teact/teact';
import type { ApiPaymentCredentials } from '../../api/types';
import type { FormState } from '../../hooks/reducers/usePaymentReducer';
import useLang from '../../hooks/useLang';
import PasswordMonkey from '../common/PasswordMonkey';
import PasswordForm from '../common/PasswordForm';
interface OwnProps {
isActive?: boolean;
state: FormState;
savedCredentials?: ApiPaymentCredentials[];
onPasswordChange: (password: string) => void;
}
interface StateProps {
error?: string;
passwordHint?: string;
savedCredentials?: ApiPaymentCredentials[];
}
const PasswordConfirm: FC<OwnProps & StateProps> = ({
isActive,
error,
state,
savedCredentials,
passwordHint,
onPasswordChange,
}) => {
const { clearPaymentError } = getActions();
const lang = useLang();
const [shouldShowPassword, setShouldShowPassword] = useState(false);
const cardName = useMemo(() => {
return savedCredentials?.length && state.savedCredentialId
? savedCredentials.find(({ id }) => id === state.savedCredentialId)?.title
: undefined;
}, [savedCredentials, state.savedCredentialId]);
return (
<div className="PaymentInfo">
<PasswordMonkey isBig isPasswordVisible={shouldShowPassword} />
<PasswordForm
error={error ? lang(error) : undefined}
hint={passwordHint}
description={lang('PaymentConfirmationMessage', cardName)}
placeholder={lang('Password')}
clearError={clearPaymentError}
shouldShowSubmit={false}
shouldResetValue={isActive}
isPasswordVisible={shouldShowPassword}
onChangePasswordVisibility={setShouldShowPassword}
onInputChange={onPasswordChange}
/>
</div>
);
};
export default memo(withGlobal<OwnProps>((global): StateProps => {
return {
error: global.payment.error?.message,
passwordHint: global.twoFaSettings.hint,
savedCredentials: global.payment.savedCredentials,
};
})(PasswordConfirm));

View File

@ -17,4 +17,11 @@
display: flex; display: flex;
} }
} }
.description {
font-size: 0.875rem;
color: var(--color-text-secondary);
margin-top: -0.75rem;
margin-bottom: 1.25rem;
}
} }

View File

@ -153,14 +153,16 @@ const PaymentInfo: FC<OwnProps> = ({
error={formErrors.billingZip} error={formErrors.billingZip}
/> />
)} )}
{ canSaveCredentials && ( <Checkbox
<Checkbox label={lang('PaymentCardSavePaymentInformation')}
label={lang('PaymentCardSavePaymentInformation')} checked={canSaveCredentials ? state.saveCredentials : false}
checked={state.saveCredentials} tabIndex={0}
tabIndex={0} onChange={handleChangeSaveCredentials}
onChange={handleChangeSaveCredentials} disabled={!canSaveCredentials}
/> />
) } <p className="description">
{lang(canSaveCredentials ? 'Checkout.NewCard.SaveInfoHelp' : 'Checkout.2FA.Text')}
</p>
</form> </form>
</div> </div>
); );

View File

@ -17,12 +17,11 @@ $modalHeaderAndFooterHeight: 8.375rem;
border-top-left-radius: var(--border-radius-default-small); border-top-left-radius: var(--border-radius-default-small);
border-top-right-radius: var(--border-radius-default-small); border-top-right-radius: var(--border-radius-default-small);
width: 100%; width: 100%;
padding: 0.25rem 1rem; padding: 0.5rem 1rem 0.25rem;
display: flex; display: flex;
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;
background: var(--color-background); background: var(--color-background);
border-bottom: 1px var(--color-borders) solid;
h3 { h3 {
margin-bottom: 0; margin-bottom: 0;
@ -33,7 +32,7 @@ $modalHeaderAndFooterHeight: 8.375rem;
} }
.Transition { .Transition {
height: min(25rem, 60vh); height: min(27rem, 60vh);
} }
.empty-content { .empty-content {
@ -51,6 +50,7 @@ $modalHeaderAndFooterHeight: 8.375rem;
.content { .content {
overflow: auto; overflow: auto;
overflow-x: hidden;
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative; position: relative;
@ -64,7 +64,7 @@ $modalHeaderAndFooterHeight: 8.375rem;
width: 100%; width: 100%;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background: var(--color-background); background: var(--color-background);
border-top: 1px var(--color-borders) solid; border-top: 0.0625rem var(--color-borders) solid;
button { button {
text-transform: none; text-transform: none;

View File

@ -5,17 +5,19 @@ import React, {
import { getActions, withGlobal } from '../../global'; import { getActions, withGlobal } from '../../global';
import type { GlobalState } from '../../global/types'; import type { GlobalState } from '../../global/types';
import type { ApiChat, ApiCountry } from '../../api/types'; import type { ApiChat, ApiCountry, ApiPaymentCredentials } from '../../api/types';
import type { ShippingOption, Price } from '../../types'; import type { Price, ShippingOption } from '../../types';
import { PaymentStep } from '../../types'; import type { FormState } from '../../hooks/reducers/usePaymentReducer';
import { PaymentStep } from '../../types';
import { selectChat } from '../../global/selectors'; import { selectChat } from '../../global/selectors';
import { formatCurrency } from '../../util/formatCurrency'; import { formatCurrency } from '../../util/formatCurrency';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { detectCardTypeText } from '../common/helpers/detectCardType'; import { detectCardTypeText } from '../common/helpers/detectCardType';
import type { FormState } from '../../hooks/reducers/usePaymentReducer'; import captureKeyboardListeners from '../../util/captureKeyboardListeners';
import usePaymentReducer from '../../hooks/reducers/usePaymentReducer';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useFlag from '../../hooks/useFlag';
import usePaymentReducer from '../../hooks/reducers/usePaymentReducer';
import ShippingInfo from './ShippingInfo'; import ShippingInfo from './ShippingInfo';
import Shipping from './Shipping'; import Shipping from './Shipping';
@ -26,6 +28,8 @@ import Modal from '../ui/Modal';
import Transition from '../ui/Transition'; import Transition from '../ui/Transition';
import Spinner from '../ui/Spinner'; import Spinner from '../ui/Spinner';
import ConfirmPayment from './ConfirmPayment'; import ConfirmPayment from './ConfirmPayment';
import SavedPaymentCredentials from './SavedPaymentCredentials';
import PasswordConfirm from './PasswordConfirm';
import './PaymentModal.scss'; import './PaymentModal.scss';
@ -40,13 +44,12 @@ export type OwnProps = {
type StateProps = { type StateProps = {
chat?: ApiChat; chat?: ApiChat;
nameRequested?: boolean; isNameRequested?: boolean;
shippingAddressRequested?: boolean; isShippingAddressRequested?: boolean;
phoneRequested?: boolean; isPhoneRequested?: boolean;
emailRequested?: boolean; isEmailRequested?: boolean;
flexible?: boolean; shouldSendPhoneToProvider?: boolean;
phoneToProvider?: boolean; shouldSendEmailToProvider?: boolean;
emailToProvider?: boolean;
currency?: string; currency?: string;
prices?: Price[]; prices?: Price[];
isProviderError: boolean; isProviderError: boolean;
@ -55,14 +58,21 @@ type StateProps = {
needZip?: boolean; needZip?: boolean;
confirmPaymentUrl?: string; confirmPaymentUrl?: string;
countryList: ApiCountry[]; countryList: ApiCountry[];
hasShippingOptions: boolean;
requestId?: string;
smartGlocalToken?: string;
stripeId?: string;
savedCredentials?: ApiPaymentCredentials[];
passwordValidUntil?: number;
}; };
type GlobalStateProps = Pick<GlobalState['payment'], ( type GlobalStateProps = Pick<GlobalState['payment'], (
'step' | 'shippingOptions' | 'step' | 'shippingOptions' |
'savedInfo' | 'canSaveCredentials' | 'nativeProvider' | 'passwordMissing' | 'invoiceContent' | 'savedInfo' | 'canSaveCredentials' | 'nativeProvider' | 'passwordMissing' | 'invoice' | 'error'
'error'
)>; )>;
const NETWORK_REQUEST_TIMEOUT_S = 3;
const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
isOpen, isOpen,
onClose, onClose,
@ -71,16 +81,16 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
shippingOptions, shippingOptions,
savedInfo, savedInfo,
canSaveCredentials, canSaveCredentials,
nameRequested, isNameRequested,
shippingAddressRequested, isShippingAddressRequested,
phoneRequested, isPhoneRequested,
emailRequested, isEmailRequested,
phoneToProvider, shouldSendPhoneToProvider,
emailToProvider, shouldSendEmailToProvider,
currency, currency,
passwordMissing, passwordMissing,
isProviderError, isProviderError,
invoiceContent, invoice,
nativeProvider, nativeProvider,
prices, prices,
needCardholderName, needCardholderName,
@ -89,23 +99,47 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
confirmPaymentUrl, confirmPaymentUrl,
error, error,
countryList, countryList,
hasShippingOptions,
requestId,
smartGlocalToken,
stripeId,
savedCredentials,
passwordValidUntil,
}) => { }) => {
const { const {
loadPasswordInfo,
validateRequestedInfo, validateRequestedInfo,
sendPaymentForm, sendPaymentForm,
setPaymentStep, setPaymentStep,
sendCredentialsInfo, sendCredentialsInfo,
clearPaymentError, clearPaymentError,
validatePaymentPassword,
} = getActions(); } = getActions();
const lang = useLang();
const [isModalOpen, openModal, closeModal] = useFlag();
const [paymentState, paymentDispatch] = usePaymentReducer(); const [paymentState, paymentDispatch] = usePaymentReducer();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isTosAccepted, setIsTosAccepted] = useState(false); const [isTosAccepted, setIsTosAccepted] = useState(false);
const lang = useLang(); const [twoFaPassword, setTwoFaPassword] = useState('');
const canRenderFooter = step !== PaymentStep.ConfirmPayment; const canRenderFooter = step !== PaymentStep.ConfirmPayment;
const setStep = useCallback((nextStep) => {
setPaymentStep({ step: nextStep });
}, [setPaymentStep]);
useEffect(() => { useEffect(() => {
if (step || error) { if (isOpen) {
setTwoFaPassword('');
loadPasswordInfo();
openModal();
}
}, [isOpen, loadPasswordInfo, openModal]);
useEffect(() => {
if (step !== undefined || error) {
setIsLoading(false); setIsLoading(false);
} }
}, [step, error]); }, [step, error]);
@ -148,6 +182,15 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
} }
}, [savedInfo, paymentDispatch, countryList]); }, [savedInfo, paymentDispatch, countryList]);
useEffect(() => {
if (savedCredentials?.length) {
paymentDispatch({
type: 'changeSavedCredentialId',
payload: savedCredentials[0].id,
});
}
}, [paymentDispatch, savedCredentials]);
const handleErrorModalClose = useCallback(() => { const handleErrorModalClose = useCallback(() => {
clearPaymentError(); clearPaymentError();
}, [clearPaymentError]); }, [clearPaymentError]);
@ -157,8 +200,8 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
return 0; return 0;
} }
return getTotalPrice(prices, shippingOptions, paymentState.shipping); return getTotalPrice(prices, shippingOptions, paymentState.shipping, paymentState.tipAmount);
}, [step, paymentState.shipping, prices, shippingOptions]); }, [step, prices, shippingOptions, paymentState.shipping, paymentState.tipAmount]);
const checkoutInfo = useMemo(() => { const checkoutInfo = useMemo(() => {
if (step !== PaymentStep.Checkout) { if (step !== PaymentStep.Checkout) {
@ -167,6 +210,10 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
return getCheckoutInfo(paymentState, shippingOptions, nativeProvider || ''); return getCheckoutInfo(paymentState, shippingOptions, nativeProvider || '');
}, [step, paymentState, shippingOptions, nativeProvider]); }, [step, paymentState, shippingOptions, nativeProvider]);
const handleNewCardClick = useCallback(() => {
setStep(PaymentStep.PaymentInfo);
}, [setStep]);
function renderError() { function renderError() {
if (!error) { if (!error) {
return undefined; return undefined;
@ -191,25 +238,43 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
function renderModalContent(currentStep: PaymentStep) { function renderModalContent(currentStep: PaymentStep) {
switch (currentStep) { switch (currentStep) {
case PaymentStep.ShippingInfo: case PaymentStep.Checkout:
return ( return (
<ShippingInfo <Checkout
state={paymentState} chat={chat}
prices={prices}
dispatch={paymentDispatch} dispatch={paymentDispatch}
needAddress={Boolean(shippingAddressRequested)} shippingPrices={paymentState.shipping && shippingOptions
needEmail={Boolean(emailRequested || emailToProvider)} ? getShippingPrices(shippingOptions, paymentState.shipping)
needPhone={Boolean(phoneRequested || phoneToProvider)} : undefined}
needName={Boolean(nameRequested)} totalPrice={totalPrice}
countryList={countryList} invoice={invoice}
checkoutInfo={checkoutInfo}
currency={currency!}
hasShippingOptions={hasShippingOptions}
tipAmount={paymentState.tipAmount}
needAddress={Boolean(isShippingAddressRequested)}
savedCredentials={savedCredentials}
isTosAccepted={isTosAccepted}
onAcceptTos={setIsTosAccepted}
/> />
); );
case PaymentStep.Shipping: case PaymentStep.SavedPayments:
return ( return (
<Shipping <SavedPaymentCredentials
state={paymentState} state={paymentState}
savedCredentials={savedCredentials}
dispatch={paymentDispatch} dispatch={paymentDispatch}
shippingOptions={shippingOptions || []} onNewCardClick={handleNewCardClick}
currency={currency!} />
);
case PaymentStep.ConfirmPassword:
return (
<PasswordConfirm
state={paymentState}
savedCredentials={savedCredentials}
onPasswordChange={setTwoFaPassword}
isActive={currentStep === step}
/> />
); );
case PaymentStep.PaymentInfo: case PaymentStep.PaymentInfo:
@ -224,20 +289,25 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
countryList={countryList} countryList={countryList}
/> />
); );
case PaymentStep.Checkout: case PaymentStep.ShippingInfo:
return ( return (
<Checkout <ShippingInfo
chat={chat} state={paymentState}
prices={prices} dispatch={paymentDispatch}
shippingPrices={paymentState.shipping && shippingOptions needAddress={Boolean(isShippingAddressRequested)}
? getShippingPrices(shippingOptions, paymentState.shipping) needEmail={Boolean(isEmailRequested || shouldSendEmailToProvider)}
: undefined} needPhone={Boolean(isPhoneRequested || shouldSendPhoneToProvider)}
totalPrice={totalPrice} needName={Boolean(isNameRequested)}
invoiceContent={invoiceContent} countryList={countryList}
checkoutInfo={checkoutInfo} />
);
case PaymentStep.Shipping:
return (
<Shipping
state={paymentState}
dispatch={paymentDispatch}
shippingOptions={shippingOptions || []}
currency={currency!} currency={currency!}
isTosAccepted={isTosAccepted}
onAcceptTos={setIsTosAccepted}
/> />
); );
case PaymentStep.ConfirmPayment: case PaymentStep.ConfirmPayment:
@ -268,48 +338,126 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
sendPaymentForm({ sendPaymentForm({
shippingOptionId: paymentState.shipping, shippingOptionId: paymentState.shipping,
saveCredentials: paymentState.saveCredentials, saveCredentials: paymentState.saveCredentials,
savedCredentialId: paymentState.savedCredentialId,
tipAmount: paymentState.tipAmount,
}); });
}, [sendPaymentForm, paymentState]); }, [sendPaymentForm, paymentState]);
const setStep = useCallback((nextStep) => {
setPaymentStep({ step: nextStep });
}, [setPaymentStep]);
const handleButtonClick = useCallback(() => { const handleButtonClick = useCallback(() => {
setIsLoading(true);
switch (step) { switch (step) {
case PaymentStep.ShippingInfo: case PaymentStep.ShippingInfo:
setIsLoading(true);
validateRequest(); validateRequest();
break; break;
case PaymentStep.Shipping: case PaymentStep.Shipping:
setStep(PaymentStep.PaymentInfo); setStep(PaymentStep.Checkout);
break; break;
case PaymentStep.SavedPayments:
setStep(PaymentStep.ConfirmPassword);
break;
case PaymentStep.ConfirmPassword:
if (twoFaPassword === '') {
return;
}
setIsLoading(true);
validatePaymentPassword({ password: twoFaPassword });
break;
case PaymentStep.PaymentInfo: case PaymentStep.PaymentInfo:
setIsLoading(true);
sendCredentials(); sendCredentials();
paymentDispatch({ type: 'changeSavedCredentialId', payload: '' });
break; break;
case PaymentStep.Checkout:
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(); sendForm();
break; break;
}
} }
}, [step, validateRequest, setStep, sendCredentials, sendForm]); }, [
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(() => { const handleModalClose = useCallback(() => {
paymentDispatch({ paymentDispatch({
type: 'resetState', type: 'resetState',
}); });
setIsTosAccepted(false); setIsTosAccepted(false);
}, [paymentDispatch]); onClose();
}, [onClose, paymentDispatch]);
const handleBackClick = useCallback(() => {
setStep(step === PaymentStep.ConfirmPassword ? PaymentStep.SavedPayments : PaymentStep.Checkout);
}, [setStep, step]);
const modalHeader = useMemo(() => { const modalHeader = useMemo(() => {
switch (step) { switch (step) {
case PaymentStep.Checkout:
return lang('PaymentCheckout');
case PaymentStep.ShippingInfo: case PaymentStep.ShippingInfo:
return lang('PaymentShippingInfo'); return lang('PaymentShippingInfo');
case PaymentStep.Shipping: case PaymentStep.Shipping:
return lang('PaymentShippingMethod'); return lang('PaymentShippingMethod');
case PaymentStep.SavedPayments:
return lang('PaymentCheckoutMethod');
case PaymentStep.ConfirmPassword:
return lang('Checkout.PasswordEntry.Title');
case PaymentStep.PaymentInfo: case PaymentStep.PaymentInfo:
return lang('PaymentCardInfo'); return lang('PaymentCardInfo');
case PaymentStep.Checkout:
return lang('PaymentCheckout');
case PaymentStep.ConfirmPayment: case PaymentStep.ConfirmPayment:
return lang('Checkout.WebConfirmation.Title'); return lang('Checkout.WebConfirmation.Title');
default: default:
@ -322,14 +470,15 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
: lang('Next'); : lang('Next');
const isSubmitDisabled = isLoading const isSubmitDisabled = isLoading
|| Boolean(step === PaymentStep.Checkout && invoiceContent?.isRecurring && !isTosAccepted); || Boolean(step === PaymentStep.Checkout && invoice?.isRecurring && !isTosAccepted);
if (isProviderError) { if (isProviderError) {
return ( return (
<Modal <Modal
className="error" className="error"
isOpen={isOpen} isOpen={isModalOpen}
onClose={onClose} onClose={closeModal}
onCloseAnimationEnd={handleModalClose}
> >
<p> <p>
Sorry, Telegram WebZ doesn&apos;t support payments with this provider yet. <br /> Sorry, Telegram WebZ doesn&apos;t support payments with this provider yet. <br />
@ -337,7 +486,7 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
</p> </p>
<Button <Button
isText isText
onClick={onClose} onClick={closeModal}
> >
{lang('OK')} {lang('OK')}
</Button> </Button>
@ -347,9 +496,9 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
return ( return (
<Modal <Modal
className={buildClassName('PaymentModal', invoiceContent?.isRecurring && 'recurring')} className={buildClassName('PaymentModal', invoice?.isRecurring && 'recurring')}
isOpen={isOpen} isOpen={isModalOpen}
onClose={onClose} onClose={closeModal}
onCloseAnimationEnd={handleModalClose} onCloseAnimationEnd={handleModalClose}
> >
<div className="header" dir={lang.isRtl ? 'rtl' : undefined}> <div className="header" dir={lang.isRtl ? 'rtl' : undefined}>
@ -358,10 +507,10 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
color="translucent" color="translucent"
round round
size="smaller" size="smaller"
onClick={onClose} onClick={step === PaymentStep.Checkout ? closeModal : handleBackClick}
ariaLabel="Close" ariaLabel="Close"
> >
<i className="icon-close" /> <i className={step === PaymentStep.Checkout ? 'icon-close' : 'icon-arrow-left'} />
</Button> </Button>
<h3>{modalHeader}</h3> <h3>{modalHeader}</h3>
</div> </div>
@ -401,29 +550,33 @@ export default memo(withGlobal<OwnProps>(
savedInfo, savedInfo,
canSaveCredentials, canSaveCredentials,
invoice, invoice,
invoiceContent, invoiceContainer,
nativeProvider, nativeProvider,
nativeParams, nativeParams,
passwordMissing, passwordMissing,
error, error,
confirmPaymentUrl, confirmPaymentUrl,
inputInvoice, inputInvoice,
requestId,
stripeCredentials,
smartGlocalCredentials,
savedCredentials,
temporaryPassword,
} = global.payment; } = global.payment;
const chat = inputInvoice && 'chatId' in inputInvoice ? selectChat(global, inputInvoice.chatId) : undefined; const chat = inputInvoice && 'chatId' in inputInvoice ? selectChat(global, inputInvoice.chatId) : undefined;
const isProviderError = Boolean(invoice && (!nativeProvider || !SUPPORTED_PROVIDERS.has(nativeProvider))); const isProviderError = Boolean(invoice && (!nativeProvider || !SUPPORTED_PROVIDERS.has(nativeProvider)));
const { needCardholderName, needCountry, needZip } = (nativeParams || {}); const { needCardholderName, needCountry, needZip } = (nativeParams || {});
const { const {
nameRequested, isNameRequested,
phoneRequested, isShippingAddressRequested,
emailRequested, isPhoneRequested,
shippingAddressRequested, isEmailRequested,
flexible, shouldSendPhoneToProvider,
phoneToProvider, shouldSendEmailToProvider,
emailToProvider,
currency, currency,
prices, prices,
} = (invoice || {}); } = (invoiceContainer || {});
return { return {
step, step,
@ -433,23 +586,28 @@ export default memo(withGlobal<OwnProps>(
canSaveCredentials, canSaveCredentials,
nativeProvider, nativeProvider,
passwordMissing, passwordMissing,
nameRequested, isNameRequested,
shippingAddressRequested, isShippingAddressRequested,
phoneRequested, isPhoneRequested,
emailRequested, isEmailRequested,
flexible, shouldSendPhoneToProvider,
phoneToProvider, shouldSendEmailToProvider,
emailToProvider,
currency, currency,
prices, prices,
isProviderError, isProviderError,
invoiceContent, invoice,
needCardholderName, needCardholderName,
needCountry, needCountry,
needZip, needZip,
error, error,
confirmPaymentUrl, confirmPaymentUrl,
countryList: global.countryList.general, countryList: global.countryList.general,
requestId,
hasShippingOptions: Boolean(shippingOptions?.length),
smartGlocalToken: smartGlocalCredentials?.token,
stripeId: stripeCredentials?.id,
savedCredentials,
passwordValidUntil: temporaryPassword?.validUntil,
}; };
}, },
)(PaymentModal)); )(PaymentModal));
@ -463,11 +621,16 @@ function getShippingPrices(shippingOptions: ShippingOption[], shippingOption: st
return option?.prices; return option?.prices;
} }
function getTotalPrice(prices: Price[] = [], shippingOptions: ShippingOption[] | undefined, shippingOption: string) { function getTotalPrice(
prices: Price[] = [],
shippingOptions: ShippingOption[] | undefined,
shippingOption: string,
tipAmount: number,
) {
const shippingPrices = shippingOptions const shippingPrices = shippingOptions
? getShippingPrices(shippingOptions, shippingOption) ? getShippingPrices(shippingOptions, shippingOption)
: []; : [];
let total = 0; let total = tipAmount;
const totalPrices = prices.concat(shippingPrices || []); const totalPrices = prices.concat(shippingPrices || []);
total = totalPrices.reduce((acc, cur) => { total = totalPrices.reduce((acc, cur) => {
return acc + cur.amount; return acc + cur.amount;
@ -477,7 +640,7 @@ function getTotalPrice(prices: Price[] = [], shippingOptions: ShippingOption[] |
function getCheckoutInfo(state: FormState, shippingOptions: ShippingOption[] | undefined, paymentProvider: string) { function getCheckoutInfo(state: FormState, shippingOptions: ShippingOption[] | undefined, paymentProvider: string) {
const cardTypeText = detectCardTypeText(state.cardNumber); const cardTypeText = detectCardTypeText(state.cardNumber);
const paymentMethod = `${cardTypeText} *${state.cardNumber.slice(-4)}`; const paymentMethod = cardTypeText && state.cardNumber ? `${cardTypeText} *${state.cardNumber.slice(-4)}` : undefined;
const shippingAddress = state.streetLine1 const shippingAddress = state.streetLine1
? `${state.streetLine1}, ${state.city}, ${state.countryIso2}` ? `${state.streetLine1}, ${state.city}, ${state.countryIso2}`
: undefined; : undefined;

View File

@ -1,11 +1,13 @@
import type { FC } from '../../lib/teact/teact'; import React, { memo, useMemo, useEffect } from '../../lib/teact/teact';
import React, { memo, useMemo } from '../../lib/teact/teact';
import { withGlobal } from '../../global'; import { withGlobal } from '../../global';
import type { FC } from '../../lib/teact/teact';
import type { Price } from '../../types'; import type { Price } from '../../types';
import type { ApiShippingAddress, ApiWebDocument } from '../../api/types'; import type { ApiShippingAddress, ApiWebDocument } from '../../api/types';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useFlag from '../../hooks/useFlag';
import Checkout from './Checkout'; import Checkout from './Checkout';
import Modal from '../ui/Modal'; import Modal from '../ui/Modal';
@ -21,6 +23,7 @@ export type OwnProps = {
type StateProps = { type StateProps = {
prices?: Price[]; prices?: Price[];
shippingPrices: any; shippingPrices: any;
tipAmount?: number;
totalAmount?: number; totalAmount?: number;
currency?: string; currency?: string;
info?: { info?: {
@ -40,6 +43,7 @@ const ReceiptModal: FC<OwnProps & StateProps> = ({
onClose, onClose,
prices, prices,
shippingPrices, shippingPrices,
tipAmount,
totalAmount, totalAmount,
currency, currency,
info, info,
@ -50,15 +54,35 @@ const ReceiptModal: FC<OwnProps & StateProps> = ({
shippingMethod, shippingMethod,
}) => { }) => {
const lang = useLang(); const lang = useLang();
const [isModalOpen, openModal, closeModal] = useFlag();
useEffect(() => {
if (isOpen) {
openModal();
}
}, [isOpen, openModal]);
const checkoutInfo = useMemo(() => { const checkoutInfo = useMemo(() => {
return getCheckoutInfo(credentialsTitle, info, shippingMethod); return getCheckoutInfo(credentialsTitle, info, shippingMethod);
}, [info, shippingMethod, credentialsTitle]); }, [info, shippingMethod, credentialsTitle]);
const invoice = useMemo(() => {
return {
photo,
text: text!,
title: title!,
amount: totalAmount!,
currency: currency!,
};
}, [currency, photo, text, title, totalAmount]);
return ( return (
<Modal <Modal
className="PaymentModal PaymentModal-receipt" className="PaymentModal PaymentModal-receipt"
isOpen={isOpen} isOpen={isModalOpen}
onClose={onClose} onClose={closeModal}
onCloseAnimationEnd={onClose}
> >
<div> <div>
<div className="header" dir={lang.isRtl ? 'rtl' : undefined}> <div className="header" dir={lang.isRtl ? 'rtl' : undefined}>
@ -67,7 +91,7 @@ const ReceiptModal: FC<OwnProps & StateProps> = ({
color="translucent" color="translucent"
round round
size="smaller" size="smaller"
onClick={onClose} onClick={closeModal}
ariaLabel="Close" ariaLabel="Close"
> >
<i className="icon-close" /> <i className="icon-close" />
@ -79,11 +103,8 @@ const ReceiptModal: FC<OwnProps & StateProps> = ({
prices={prices} prices={prices}
shippingPrices={shippingPrices} shippingPrices={shippingPrices}
totalPrice={totalAmount} totalPrice={totalAmount}
invoiceContent={{ tipAmount={tipAmount}
photo, invoice={invoice}
text,
title,
}}
checkoutInfo={checkoutInfo} checkoutInfo={checkoutInfo}
currency={currency!} currency={currency!}
/> />
@ -107,12 +128,14 @@ export default memo(withGlobal<OwnProps>(
photo, photo,
text, text,
title, title,
tipAmount,
} = (receipt || {}); } = (receipt || {});
return { return {
currency, currency,
prices, prices,
info, info,
tipAmount,
totalAmount, totalAmount,
credentialsTitle, credentialsTitle,
shippingPrices, shippingPrices,

View File

@ -0,0 +1,58 @@
import React, { memo, useCallback, useMemo } from '../../lib/teact/teact';
import type { FC } from '../../lib/teact/teact';
import type { ApiPaymentCredentials } from '../../api/types';
import type { FormState, FormEditDispatch } from '../../hooks/reducers/usePaymentReducer';
import { MEMO_EMPTY_ARRAY } from '../../util/memo';
import useLang from '../../hooks/useLang';
import Button from '../ui/Button';
import RadioGroup from '../ui/RadioGroup';
interface OwnProps {
state: FormState;
savedCredentials?: ApiPaymentCredentials[];
dispatch: FormEditDispatch;
onNewCardClick: NoneToVoidFunction;
}
const SavedPaymentCredentials: FC<OwnProps> = ({
state,
savedCredentials,
dispatch,
onNewCardClick,
}) => {
const lang = useLang();
const options = useMemo(() => {
return savedCredentials?.length
? savedCredentials.map(({ id, title }) => ({ label: title, value: id }))
: MEMO_EMPTY_ARRAY;
}, [savedCredentials]);
const onChange = useCallback((value) => {
dispatch({ type: 'changeSavedCredentialId', payload: value });
}, [dispatch]);
return (
<div className="PaymentInfo">
<form>
<h5>{lang('PaymentCardTitle')}</h5>
<RadioGroup
name="saved-credentials"
options={options}
selected={state.savedCredentialId}
onChange={onChange}
/>
<Button isText onClick={onNewCardClick}>
{lang('PaymentCheckoutMethodNewCard')}
</Button>
</form>
</div>
);
};
export default memo(SavedPaymentCredentials);

View File

@ -29,7 +29,7 @@ const Shipping: FC<OwnProps> = ({
const lang = useLang(); const lang = useLang();
useEffect(() => { useEffect(() => {
if (!shippingOptions || state.shipping) { if (!shippingOptions || !shippingOptions.length || state.shipping) {
return; return;
} }
dispatch({ type: 'changeShipping', payload: shippingOptions[0].id }); dispatch({ type: 'changeShipping', payload: shippingOptions[0].id });

View File

@ -4,8 +4,8 @@ import React, {
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import type { ApiCountry } from '../../api/types'; import type { ApiCountry } from '../../api/types';
import type { FormState, FormEditDispatch } from '../../hooks/reducers/usePaymentReducer'; import type { FormState, FormEditDispatch } from '../../hooks/reducers/usePaymentReducer';
import useFocusAfterAnimation from '../../hooks/useFocusAfterAnimation'; import useFocusAfterAnimation from '../../hooks/useFocusAfterAnimation';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';

View File

@ -1,8 +1,10 @@
import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { callApi } from '../../../api/gramjs';
import type { ApiChat, ApiInvoice, ApiRequestInputInvoice } from '../../../api/types';
import { PaymentStep } from '../../../types'; import { PaymentStep } from '../../../types';
import type { ApiChat, ApiRequestInputInvoice } from '../../../api/types';
import { DEBUG_PAYMENT_SMART_GLOCAL } from '../../../config';
import { import {
selectPaymentRequestId, selectPaymentRequestId,
selectProviderPublishableKey, selectProviderPublishableKey,
@ -14,11 +16,8 @@ import {
selectSmartGlocalCredentials, selectSmartGlocalCredentials,
selectPaymentInputInvoice, selectPaymentInputInvoice,
} from '../../selectors'; } from '../../selectors';
import { callApi } from '../../../api/gramjs';
import { getStripeError } from '../../helpers'; import { getStripeError } from '../../helpers';
import { buildQueryString } from '../../../util/requestQuery'; import { buildQueryString } from '../../../util/requestQuery';
import { DEBUG_PAYMENT_SMART_GLOCAL } from '../../../config';
import { import {
updateShippingOptions, updateShippingOptions,
setPaymentStep, setPaymentStep,
@ -28,19 +27,25 @@ import {
setReceipt, setReceipt,
clearPayment, clearPayment,
closeInvoice, closeInvoice,
setSmartGlocalCardInfo, addUsers, setInvoiceInfo, setSmartGlocalCardInfo, addUsers, setInvoiceInfo, updatePayment,
} from '../../reducers'; } from '../../reducers';
import { buildCollectionByKey } from '../../../util/iteratees'; import { buildCollectionByKey } from '../../../util/iteratees';
addActionHandler('validateRequestedInfo', (global, actions, payload) => { addActionHandler('validateRequestedInfo', (global, actions, payload) => {
const { requestInfo, saveInfo } = payload;
const inputInvoice = selectPaymentInputInvoice(global); const inputInvoice = selectPaymentInputInvoice(global);
if (!inputInvoice) return; if (!inputInvoice) {
return;
}
const { requestInfo, saveInfo } = payload;
if ('slug' in inputInvoice) { if ('slug' in inputInvoice) {
void validateRequestedInfo(inputInvoice, requestInfo, saveInfo); void validateRequestedInfo(inputInvoice, requestInfo, saveInfo);
} else { } else {
const chat = selectChat(global, inputInvoice.chatId); const chat = selectChat(global, inputInvoice.chatId);
if (!chat) return; if (!chat) {
return;
}
void validateRequestedInfo({ void validateRequestedInfo({
chat, chat,
messageId: inputInvoice.messageId, messageId: inputInvoice.messageId,
@ -48,42 +53,25 @@ addActionHandler('validateRequestedInfo', (global, actions, payload) => {
} }
}); });
async function validateRequestedInfo(inputInvoice: ApiRequestInputInvoice, requestInfo: any, shouldSave?: true) {
const result = await callApi('validateRequestedInfo', {
inputInvoice, requestInfo, shouldSave,
});
if (!result) {
return;
}
const { id, shippingOptions } = result;
if (!id) {
return;
}
let global = setRequestInfoId(getGlobal(), id);
if (shippingOptions) {
global = updateShippingOptions(global, shippingOptions);
global = setPaymentStep(global, PaymentStep.Shipping);
} else {
global = setPaymentStep(global, PaymentStep.PaymentInfo);
}
setGlobal(global);
}
addActionHandler('openInvoice', async (global, actions, payload) => { addActionHandler('openInvoice', async (global, actions, payload) => {
let invoice; let invoice: ApiInvoice | undefined;
if ('slug' in payload) { if ('slug' in payload) {
invoice = await getPaymentForm({ slug: payload.slug }); invoice = await getPaymentForm({ slug: payload.slug });
} else { } else {
const chat = selectChat(global, payload.chatId); const chat = selectChat(global, payload.chatId);
if (!chat) return; if (!chat) {
return;
}
invoice = await getPaymentForm({ invoice = await getPaymentForm({
chat, chat,
messageId: payload.messageId, messageId: payload.messageId,
}); });
} }
if (!invoice) return;
if (!invoice) {
return;
}
global = getGlobal(); global = getGlobal();
global = setInvoiceInfo(global, invoice); global = setInvoiceInfo(global, invoice);
@ -98,22 +86,18 @@ addActionHandler('openInvoice', async (global, actions, payload) => {
}); });
}); });
async function getPaymentForm(inputInvoice: ApiRequestInputInvoice) { async function getPaymentForm(inputInvoice: ApiRequestInputInvoice): Promise<ApiInvoice | undefined> {
const result = await callApi('getPaymentForm', inputInvoice); const result = await callApi('getPaymentForm', inputInvoice);
if (!result) { if (!result) {
return undefined; return undefined;
} }
const { form, invoice } = result; const { form, invoice } = result;
let global = setPaymentForm(getGlobal(), form); let global = setPaymentForm(getGlobal(), form);
let step = PaymentStep.PaymentInfo; global = setPaymentStep(global, PaymentStep.Checkout);
const {
shippingAddressRequested, nameRequested, phoneRequested, emailRequested,
} = global.payment.invoice || {};
if (shippingAddressRequested || nameRequested || phoneRequested || emailRequested) {
step = PaymentStep.ShippingInfo;
}
global = setPaymentStep(global, step);
setGlobal(global); setGlobal(global);
return invoice; return invoice;
} }
@ -179,17 +163,19 @@ addActionHandler('sendCredentialsInfo', (global, actions, payload) => {
} }
}); });
addActionHandler('sendPaymentForm', (global, actions, payload) => { addActionHandler('sendPaymentForm', async (global, actions, payload) => {
const { shippingOptionId, saveCredentials } = payload; const {
shippingOptionId, saveCredentials, savedCredentialId, tipAmount,
} = payload;
const inputInvoice = selectPaymentInputInvoice(global); const inputInvoice = selectPaymentInputInvoice(global);
const formId = selectPaymentFormId(global); const formId = selectPaymentFormId(global);
const requestInfoId = selectPaymentRequestId(global); const requestInfoId = selectPaymentRequestId(global);
const { nativeProvider } = global.payment; const { nativeProvider, temporaryPassword } = global.payment;
const publishableKey = nativeProvider === 'stripe' const publishableKey = nativeProvider === 'stripe'
? selectProviderPublishableKey(global) : selectProviderPublicToken(global); ? selectProviderPublishableKey(global) : selectProviderPublicToken(global);
if (!inputInvoice || !publishableKey || !formId || !nativeProvider) { if (!inputInvoice || !publishableKey || !formId || !nativeProvider) {
return undefined; return;
} }
let requestInputInvoice; let requestInputInvoice;
@ -200,7 +186,7 @@ addActionHandler('sendPaymentForm', (global, actions, payload) => {
} else { } else {
const chat = selectChat(global, inputInvoice.chatId); const chat = selectChat(global, inputInvoice.chatId);
if (!chat) { if (!chat) {
return undefined; return;
} }
requestInputInvoice = { requestInputInvoice = {
@ -209,18 +195,32 @@ addActionHandler('sendPaymentForm', (global, actions, payload) => {
}; };
} }
void sendPaymentForm(requestInputInvoice, formId, { setGlobal(updatePayment(global, { status: 'pending' }));
const credentials = {
save: saveCredentials, save: saveCredentials,
data: nativeProvider === 'stripe' ? selectStripeCredentials(global) : selectSmartGlocalCredentials(global), data: nativeProvider === 'stripe' ? selectStripeCredentials(global) : selectSmartGlocalCredentials(global),
}, requestInfoId, shippingOptionId);
return {
...global,
payment: {
...global.payment,
status: 'pending',
},
}; };
const result = await callApi('sendPaymentForm', {
inputInvoice: requestInputInvoice,
formId,
credentials,
requestedInfoId: requestInfoId,
shippingOptionId,
savedCredentialId,
temporaryPassword: temporaryPassword?.value,
tipAmount,
});
if (!result) {
return;
}
global = getGlobal();
global = clearPayment(global);
global = updatePayment(global, { status: 'paid' });
global = closeInvoice(global);
setGlobal(global);
}); });
async function sendStripeCredentials( async function sendStripeCredentials(
@ -288,10 +288,10 @@ async function sendSmartGlocalCredentials(
) { ) {
const params = { const params = {
card: { card: {
number: data.cardNumber.replace(/[^\d]+/g, ''), number: data.cardNumber.replace(/\D+/g, ''),
expiration_month: data.expiryMonth, expiration_month: data.expiryMonth,
expiration_year: data.expiryYear, expiration_year: data.expiryYear,
security_code: data.cvv.replace(/[^\d]+/g, ''), security_code: data.cvv.replace(/\D+/g, ''),
}, },
}; };
const url = DEBUG_PAYMENT_SMART_GLOCAL const url = DEBUG_PAYMENT_SMART_GLOCAL
@ -334,32 +334,8 @@ async function sendSmartGlocalCredentials(
setGlobal(global); setGlobal(global);
} }
async function sendPaymentForm(
inputInvoice: ApiRequestInputInvoice,
formId: string,
credentials: any,
requestedInfoId?: string,
shippingOptionId?: string,
) {
const result = await callApi('sendPaymentForm', {
inputInvoice, formId, credentials, requestedInfoId, shippingOptionId,
});
if (result === true) {
let global = clearPayment(getGlobal());
global = {
...global,
payment: {
...global.payment,
status: 'paid',
},
};
setGlobal(closeInvoice(global));
}
}
addActionHandler('setPaymentStep', (global, actions, payload = {}) => { addActionHandler('setPaymentStep', (global, actions, payload = {}) => {
return setPaymentStep(global, payload.step || PaymentStep.ShippingInfo); return setPaymentStep(global, payload.step ?? PaymentStep.Checkout);
}); });
addActionHandler('closePremiumModal', (global, actions, payload) => { addActionHandler('closePremiumModal', (global, actions, payload) => {
@ -431,3 +407,39 @@ addActionHandler('closeGiftPremiumModal', (global) => {
giftPremiumModal: { isOpen: false }, giftPremiumModal: { isOpen: false },
}); });
}); });
addActionHandler('validatePaymentPassword', async (global, actions, { password }) => {
const result = await callApi('fetchTemporaryPaymentPassword', password);
global = getGlobal();
if (!result) {
global = updatePayment(global, { error: { message: 'Unknown Error', field: 'password' } });
} else if ('error' in result) {
global = updatePayment(global, { error: { message: result.error, field: 'password' } });
} else {
global = updatePayment(global, { temporaryPassword: result, step: PaymentStep.Checkout });
}
setGlobal(global);
});
async function validateRequestedInfo(inputInvoice: ApiRequestInputInvoice, requestInfo: any, shouldSave?: true) {
const result = await callApi('validateRequestedInfo', {
inputInvoice, requestInfo, shouldSave,
});
if (!result) {
return;
}
const { id, shippingOptions } = result;
let global = setRequestInfoId(getGlobal(), id);
if (shippingOptions) {
global = updateShippingOptions(global, shippingOptions);
global = setPaymentStep(global, PaymentStep.Shipping);
} else {
global = setPaymentStep(global, PaymentStep.Checkout);
}
setGlobal(global);
}

View File

@ -2,11 +2,30 @@ import { addActionHandler } from '../../index';
import { IS_PRODUCTION_HOST } from '../../../util/environment'; import { IS_PRODUCTION_HOST } from '../../../util/environment';
import { clearPayment } from '../../reducers'; import { clearPayment } from '../../reducers';
import * as langProvider from '../../../util/langProvider';
import { formatCurrency } from '../../../util/formatCurrency';
import { selectChatMessage } from '../../selectors';
addActionHandler('apiUpdate', (global, actions, update) => { addActionHandler('apiUpdate', (global, actions, update) => {
switch (update['@type']) { switch (update['@type']) {
case 'updatePaymentStateCompleted': { case 'updatePaymentStateCompleted': {
const { inputInvoice } = global.payment; const { inputInvoice } = global.payment;
if (inputInvoice && 'chatId' in inputInvoice && 'messageId' in inputInvoice) {
const message = selectChatMessage(global, inputInvoice.chatId, inputInvoice.messageId);
if (message && message.content.invoice) {
const { amount, currency, title } = message.content.invoice;
actions.showNotification({
message: langProvider.getTranslation('PaymentInfoHint', [
formatCurrency(amount, currency, langProvider.getTranslation.code),
title,
]),
});
}
}
// On the production host, the payment frame receives a message with the payment event, // On the production host, the payment frame receives a message with the payment event,
// after which the payment form closes. In other cases, the payment form must be closed manually. // after which the payment form closes. In other cases, the payment form must be closed manually.
if (!IS_PRODUCTION_HOST) { if (!IS_PRODUCTION_HOST) {

View File

@ -1,6 +1,6 @@
import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { addActionHandler, getGlobal, setGlobal } from '../../index';
import type { ApiError } from '../../../api/types'; import type { ApiError, ApiNotification } from '../../../api/types';
import { APP_VERSION, DEBUG, GLOBAL_STATE_CACHE_CUSTOM_EMOJI_LIMIT } from '../../../config'; import { APP_VERSION, DEBUG, GLOBAL_STATE_CACHE_CUSTOM_EMOJI_LIMIT } from '../../../config';
import { IS_SINGLE_COLUMN_LAYOUT, IS_TABLET_COLUMN_LAYOUT } from '../../../util/environment'; import { IS_SINGLE_COLUMN_LAYOUT, IS_TABLET_COLUMN_LAYOUT } from '../../../util/environment';
@ -244,7 +244,7 @@ addActionHandler('showNotification', (global, actions, payload) => {
newNotifications.splice(existingNotificationIndex, 1); newNotifications.splice(existingNotificationIndex, 1);
} }
newNotifications.push(notification); newNotifications.push(notification as ApiNotification);
return { return {
...global, ...global,

View File

@ -4,37 +4,29 @@ import type {
ApiInvoice, ApiMessage, ApiPaymentForm, ApiReceipt, ApiInvoice, ApiMessage, ApiPaymentForm, ApiReceipt,
} from '../../api/types'; } from '../../api/types';
export function updatePayment(global: GlobalState, update: Partial<GlobalState['payment']>): GlobalState {
return {
...global,
payment: {
...global.payment,
...update,
},
};
}
export function updateShippingOptions( export function updateShippingOptions(
global: GlobalState, global: GlobalState,
shippingOptions: ShippingOption[], shippingOptions: ShippingOption[],
): GlobalState { ): GlobalState {
return { return updatePayment(global, { shippingOptions });
...global,
payment: {
...global.payment,
shippingOptions,
},
};
} }
export function setRequestInfoId(global: GlobalState, id: string): GlobalState { export function setRequestInfoId(global: GlobalState, id: string): GlobalState {
return { return updatePayment(global, { requestId: id });
...global,
payment: {
...global.payment,
requestId: id,
},
};
} }
export function setPaymentStep(global: GlobalState, step: PaymentStep): GlobalState { export function setPaymentStep(global: GlobalState, step: PaymentStep): GlobalState {
return { return updatePayment(global, { step });
...global,
payment: {
...global.payment,
step,
},
};
} }
export function setInvoiceInfo(global: GlobalState, invoice: ApiInvoice): GlobalState { export function setInvoiceInfo(global: GlobalState, invoice: ApiInvoice): GlobalState {
@ -47,71 +39,43 @@ export function setInvoiceInfo(global: GlobalState, invoice: ApiInvoice): Global
photo, photo,
isRecurring, isRecurring,
recurringTermsUrl, recurringTermsUrl,
maxTipAmount,
suggestedTipAmounts,
} = invoice; } = invoice;
return { return updatePayment(global, {
...global, invoice: {
payment: { title,
...global.payment, text,
invoiceContent: { photo,
title, amount,
text, currency,
photo, isTest,
amount, isRecurring,
currency, recurringTermsUrl,
isTest, maxTipAmount,
isRecurring, suggestedTipAmounts,
recurringTermsUrl,
},
}, },
}; });
} }
export function setStripeCardInfo(global: GlobalState, cardInfo: { type: string; id: string }): GlobalState { export function setStripeCardInfo(global: GlobalState, cardInfo: { type: string; id: string }): GlobalState {
return { return updatePayment(global, { stripeCredentials: { ...cardInfo } });
...global,
payment: {
...global.payment,
stripeCredentials: {
...cardInfo,
},
},
};
} }
export function setSmartGlocalCardInfo( export function setSmartGlocalCardInfo(
global: GlobalState, global: GlobalState,
cardInfo: { type: string; token: string }, cardInfo: { type: string; token: string },
): GlobalState { ): GlobalState {
return { return updatePayment(global, { smartGlocalCredentials: { ...cardInfo } });
...global,
payment: {
...global.payment,
smartGlocalCredentials: {
...cardInfo,
},
},
};
} }
export function setPaymentForm(global: GlobalState, form: ApiPaymentForm): GlobalState { export function setPaymentForm(global: GlobalState, form: ApiPaymentForm): GlobalState {
return { return updatePayment(global, { ...form });
...global,
payment: {
...global.payment,
...form,
},
};
} }
export function setConfirmPaymentUrl(global: GlobalState, url?: string): GlobalState { export function setConfirmPaymentUrl(global: GlobalState, url?: string): GlobalState {
return { return updatePayment(global, { confirmPaymentUrl: url });
...global,
payment: {
...global.payment,
confirmPaymentUrl: url,
},
};
} }
export function setReceipt( export function setReceipt(
@ -120,13 +84,7 @@ export function setReceipt(
message?: ApiMessage, message?: ApiMessage,
): GlobalState { ): GlobalState {
if (!receipt || !message) { if (!receipt || !message) {
return { return updatePayment(global, { receipt: undefined });
...global,
payment: {
...global.payment,
receipt: undefined,
},
};
} }
const { invoice: messageInvoice } = message.content; const { invoice: messageInvoice } = message.content;
@ -134,18 +92,14 @@ export function setReceipt(
photo, text, title, photo, text, title,
} = (messageInvoice || {}); } = (messageInvoice || {});
return { return updatePayment(global, {
...global, receipt: {
payment: { ...receipt,
...global.payment, photo,
receipt: { text,
...receipt, title,
photo,
text,
title,
},
}, },
}; });
} }
export function clearPayment(global: GlobalState): GlobalState { export function clearPayment(global: GlobalState): GlobalState {
@ -156,11 +110,5 @@ export function clearPayment(global: GlobalState): GlobalState {
} }
export function closeInvoice(global: GlobalState): GlobalState { export function closeInvoice(global: GlobalState): GlobalState {
return { return updatePayment(global, { isPaymentModalOpen: undefined });
...global,
payment: {
...global.payment,
isPaymentModalOpen: false,
},
};
} }

View File

@ -44,6 +44,8 @@ import type {
ApiInvoice, ApiInvoice,
ApiStickerSetInfo, ApiStickerSetInfo,
ApiChatType, ApiChatType,
ApiReceipt,
ApiPaymentCredentials,
} from '../api/types'; } from '../api/types';
import type { import type {
FocusDirection, FocusDirection,
@ -56,8 +58,7 @@ import type {
ManagementProgress, ManagementProgress,
PaymentStep, PaymentStep,
ShippingOption, ShippingOption,
Invoice, ApiInvoiceContainer,
Receipt,
ApiPrivacyKey, ApiPrivacyKey,
ApiPrivacySettings, ApiPrivacySettings,
ThemeKey, ThemeKey,
@ -481,8 +482,8 @@ export type GlobalState = {
requestId?: string; requestId?: string;
savedInfo?: ApiPaymentSavedInfo; savedInfo?: ApiPaymentSavedInfo;
canSaveCredentials?: boolean; canSaveCredentials?: boolean;
invoice?: Invoice; invoice?: ApiInvoice;
invoiceContent?: Omit<ApiInvoice, 'receiptMsgId'>; invoiceContainer?: Omit<ApiInvoiceContainer, 'receiptMsgId'>;
nativeProvider?: string; nativeProvider?: string;
providerId?: string; providerId?: string;
nativeParams?: ApiPaymentFormNativeParams; nativeParams?: ApiPaymentFormNativeParams;
@ -495,18 +496,19 @@ export type GlobalState = {
token: string; token: string;
}; };
passwordMissing?: boolean; passwordMissing?: boolean;
savedCredentials?: { savedCredentials?: ApiPaymentCredentials[];
id: string; receipt?: ApiReceipt;
title: string;
};
receipt?: Receipt;
error?: { error?: {
field?: string; field?: string;
message?: string; message?: string;
description: string; description?: string;
}; };
isPaymentModalOpen?: boolean; isPaymentModalOpen?: boolean;
confirmPaymentUrl?: string; confirmPaymentUrl?: string;
temporaryPassword?: {
value: string;
validUntil: number;
};
}; };
chatCreation?: { chatCreation?: {
@ -1107,6 +1109,15 @@ export interface ActionPayloads {
url?: string; url?: string;
}; };
closeUrlAuthModal: never; closeUrlAuthModal: never;
showNotification: {
localId?: string;
title?: string;
message: string;
className?: string;
actionText?: string;
action?: NoneToVoidFunction;
};
dismissNotification: { localId: string };
// Calls // Calls
requestCall: { requestCall: {
@ -1180,12 +1191,17 @@ export interface ActionPayloads {
// Invoice // Invoice
openInvoice: ApiInputInvoice; openInvoice: ApiInputInvoice;
// Payment
validatePaymentPassword: {
password: string;
};
} }
export type NonTypedActionNames = ( export type NonTypedActionNames = (
// system // system
'init' | 'reset' | 'disconnect' | 'initApi' | 'sync' | 'saveSession' | 'init' | 'reset' | 'disconnect' | 'initApi' | 'sync' | 'saveSession' |
'showNotification' | 'dismissNotification' | 'showDialog' | 'dismissDialog' | 'showDialog' | 'dismissDialog' |
// ui // ui
'toggleChatInfo' | 'setIsUiReady' | 'toggleLeftColumn' | 'toggleChatInfo' | 'setIsUiReady' | 'toggleLeftColumn' |
'toggleSafeLinkModal' | 'openHistoryCalendar' | 'closeHistoryCalendar' | 'disableContextMenuHint' | 'toggleSafeLinkModal' | 'openHistoryCalendar' | 'closeHistoryCalendar' | 'disableContextMenuHint' |

View File

@ -21,13 +21,16 @@ export type FormState = {
saveInfo: boolean; saveInfo: boolean;
saveCredentials: boolean; saveCredentials: boolean;
formErrors: Record<string, string>; formErrors: Record<string, string>;
tipAmount: number;
savedCredentialId: string;
}; };
export type FormActions = ( export type FormActions = (
'changeAddress1' | 'changeAddress2' | 'changeCity' | 'changeState' | 'changeCountry' | 'changeAddress1' | 'changeAddress2' | 'changeCity' | 'changeState' | 'changeCountry' |
'changePostCode' | 'changeFullName' | 'changeEmail' | 'changePhone' | 'changeShipping' | 'updateUserInfo' | 'changePostCode' | 'changeFullName' | 'changeEmail' | 'changePhone' | 'changeShipping' | 'updateUserInfo' |
'changeCardNumber' | 'changeCardholder' | 'changeExpiryDate' | 'changeCvvCode' | 'changeBillingCountry' | 'changeCardNumber' | 'changeCardholder' | 'changeExpiryDate' | 'changeCvvCode' | 'changeBillingCountry' |
'changeBillingZip' | 'changeSaveInfo' | 'changeSaveCredentials' | 'setFormErrors' | 'resetState' 'changeBillingZip' | 'changeSaveInfo' | 'changeSaveCredentials' | 'setFormErrors' | 'resetState' | 'setTipAmount' |
'changeSavedCredentialId'
); );
export type FormEditDispatch = Dispatch<FormActions>; export type FormEditDispatch = Dispatch<FormActions>;
@ -51,6 +54,8 @@ const INITIAL_STATE: FormState = {
saveInfo: true, saveInfo: true,
saveCredentials: false, saveCredentials: false,
formErrors: {}, formErrors: {},
tipAmount: 0,
savedCredentialId: '',
}; };
const reducer: StateReducer<FormState, FormActions> = (state, action) => { const reducer: StateReducer<FormState, FormActions> = (state, action) => {
@ -214,6 +219,16 @@ const reducer: StateReducer<FormState, FormActions> = (state, action) => {
...action.payload, ...action.payload,
}, },
}; };
case 'setTipAmount':
return {
...state,
tipAmount: action.payload,
};
case 'changeSavedCredentialId':
return {
...state,
savedCredentialId: action.payload,
};
case 'resetState': case 'resetState':
return { return {
...INITIAL_STATE, ...INITIAL_STATE,

View File

@ -1,4 +1,4 @@
import TelegramClient from './TelegramClient'; import type TelegramClient from './TelegramClient';
// eslint-disable-next-line import/no-named-default // eslint-disable-next-line import/no-named-default
import { default as Api } from '../tl/api'; import { default as Api } from '../tl/api';
import { generateRandomBytes } from '../Helpers'; import { generateRandomBytes } from '../Helpers';
@ -15,6 +15,8 @@ export interface TwoFaParams {
onEmailCodeError?: (err: Error) => void; onEmailCodeError?: (err: Error) => void;
} }
export type TmpPasswordResult = Api.account.TmpPassword | { error: string } | undefined;
/** /**
* Changes the 2FA settings of the logged in user. * Changes the 2FA settings of the logged in user.
Note that this method may be *incredibly* slow depending on the Note that this method may be *incredibly* slow depending on the
@ -121,3 +123,27 @@ export async function updateTwoFaSettings(
} }
} }
} }
export async function getTmpPassword(client: TelegramClient, currentPassword: string, ttl = 60) {
const pwd = await client.invoke(new Api.account.GetPassword());
if (!pwd) {
return undefined;
}
const inputPassword = await computeCheck(pwd, currentPassword);
try {
const result = await client.invoke(new Api.account.GetTmpPassword({
password: inputPassword,
period: ttl,
}));
return result;
} catch (err: any) {
if (err.message === 'PASSWORD_HASH_INVALID') {
return { error: err.message };
}
throw err;
}
}

View File

@ -3,7 +3,7 @@ import type { Api } from '..';
import type { BotAuthParams, UserAuthParams } from './auth'; import type { BotAuthParams, UserAuthParams } from './auth';
import type { uploadFile, UploadFileParams } from './uploadFile'; import type { uploadFile, UploadFileParams } from './uploadFile';
import type { downloadFile, DownloadFileParams } from './downloadFile'; import type { downloadFile, DownloadFileParams } from './downloadFile';
import type { TwoFaParams, updateTwoFaSettings } from './2fa'; import type { TwoFaParams, updateTwoFaSettings, TmpPasswordResult } from './2fa';
declare class TelegramClient { declare class TelegramClient {
constructor(...args: any); constructor(...args: any);
@ -18,6 +18,8 @@ declare class TelegramClient {
async updateTwoFaSettings(Params: TwoFaParams): ReturnType<typeof updateTwoFaSettings>; async updateTwoFaSettings(Params: TwoFaParams): ReturnType<typeof updateTwoFaSettings>;
async getTmpPassword(currentPassword: string, ttl?: number): Promise<TmpPasswordResult>;
// Untyped methods. // Untyped methods.
[prop: string]: any; [prop: string]: any;
} }

View File

@ -22,7 +22,7 @@ const {
} = require('./auth'); } = require('./auth');
const { downloadFile } = require('./downloadFile'); const { downloadFile } = require('./downloadFile');
const { uploadFile } = require('./uploadFile'); const { uploadFile } = require('./uploadFile');
const { updateTwoFaSettings } = require('./2fa'); const { updateTwoFaSettings, getTmpPassword } = require('./2fa');
const DEFAULT_DC_ID = 2; const DEFAULT_DC_ID = 2;
const WEBDOCUMENT_DC_ID = 4; const WEBDOCUMENT_DC_ID = 4;
@ -927,6 +927,10 @@ class TelegramClient {
return updateTwoFaSettings(this, params); return updateTwoFaSettings(this, params);
} }
getTmpPassword(currentPassword, ttl) {
return getTmpPassword(this, currentPassword, ttl);
}
// event region // event region
addEventHandler(callback, event) { addEventHandler(callback, event) {
this._eventBuilders.push([event, callback]); this._eventBuilders.push([event, callback]);

View File

@ -102,7 +102,8 @@ $color-message-reaction-own-hover: #b5e0a4;
--color-primary-shade: #{color.mix($color-primary, $color-black, 92%)}; --color-primary-shade: #{color.mix($color-primary, $color-black, 92%)};
--color-primary-shade-darker: #{color.mix($color-primary, $color-black, 84%)}; --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-shade-rgb: #{toRGB(color.mix($color-primary, $color-black, 92%))};
--color-primary-opacity: rgba(var(--color-primary), 0.5); --color-primary-opacity: rgba(var(--color-primary), 0.25);
--color-primary-opacity-hover: rgba(var(--color-primary), 0.15);
--color-green: #{$color-green}; --color-green: #{$color-green};
--color-green-darker: #{color.mix($color-green, $color-black, 84%)}; --color-green-darker: #{color.mix($color-green, $color-black, 84%)};

View File

@ -1,6 +1,7 @@
{ {
"--color-primary": ["#3390EC", "#8774E1"], "--color-primary": ["#3390EC", "#8774E1"],
"--color-primary-opacity": ["#50A2E980", "#8378DB80"], "--color-primary-opacity": ["#50A2E940", "#8378DB80"],
"--color-primary-opacity-hover": ["#50A2E926", "#8378DBA0"],
"--color-primary-shade": ["#4a95d6", "#7b71c6"], "--color-primary-shade": ["#4a95d6", "#7b71c6"],
"--color-background": ["#FFFFFF", "#212121"], "--color-background": ["#FFFFFF", "#212121"],
"--color-background-compact-menu": ["#FFFFFFBB", "#212121DD"], "--color-background-compact-menu": ["#FFFFFFBB", "#212121DD"],

View File

@ -3,7 +3,7 @@ import type {
ApiBotInlineMediaResult, ApiBotInlineResult, ApiBotInlineSwitchPm, ApiBotInlineMediaResult, ApiBotInlineResult, ApiBotInlineSwitchPm,
ApiChatInviteImporter, ApiChatInviteImporter,
ApiExportedInvite, ApiExportedInvite,
ApiLanguage, ApiMessage, ApiShippingAddress, ApiStickerSet, ApiWebDocument, ApiLanguage, ApiMessage, ApiStickerSet,
} from '../api/types'; } from '../api/types';
export type TextPart = TeactNode; export type TextPart = TeactNode;
@ -129,34 +129,17 @@ export interface Price {
amount: number; amount: number;
} }
export interface Invoice { export interface ApiInvoiceContainer {
isTest?: boolean;
isNameRequested?: boolean;
isPhoneRequested?: boolean;
isEmailRequested?: boolean;
isShippingAddressRequested?: boolean;
isFlexible?: boolean;
shouldSendPhoneToProvider?: boolean;
shouldSendEmailToProvider?: boolean;
currency?: string; currency?: string;
emailRequested?: boolean;
emailToProvider?: boolean;
flexible?: boolean;
nameRequested?: boolean;
phoneRequested?: boolean;
phoneToProvider?: boolean;
prices?: Price[]; prices?: Price[];
shippingAddressRequested?: boolean;
test?: boolean;
}
export interface Receipt {
currency: string;
prices: Price[];
info?: {
shippingAddress?: ApiShippingAddress;
phone?: string;
name?: string;
};
totalAmount: number;
credentialsTitle: string;
shippingPrices?: Price[];
shippingMethod?: string;
photo?: ApiWebDocument;
text?: string;
title?: string;
} }
export enum SettingsScreens { export enum SettingsScreens {
@ -346,10 +329,12 @@ export enum ProfileState {
} }
export enum PaymentStep { export enum PaymentStep {
Checkout,
SavedPayments,
ConfirmPassword,
PaymentInfo,
ShippingInfo, ShippingInfo,
Shipping, Shipping,
PaymentInfo,
Checkout,
ConfirmPayment, ConfirmPayment,
} }

View File

@ -1,10 +1,26 @@
import type { LangCode } from '../types'; import type { LangCode } from '../types';
export function formatCurrency(totalPrice: number, currency: string, locale: LangCode = 'en') { export function formatCurrency(
totalPrice: number,
currency: string,
locale: LangCode = 'en',
shouldOmitFractions = false,
) {
const price = totalPrice / 10 ** getCurrencyExp(currency);
if (shouldOmitFractions && price % 1 === 0) {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(price);
}
return new Intl.NumberFormat(locale, { return new Intl.NumberFormat(locale, {
style: 'currency', style: 'currency',
currency, currency,
}).format(totalPrice / 10 ** getCurrencyExp(currency)); }).format(price);
} }
function getCurrencyExp(currency: string) { function getCurrencyExp(currency: string) {

View File

@ -1,6 +1,6 @@
import type React from '../lib/teact/teact'; import type React from '../lib/teact/teact';
const stopEvent = (e: React.UIEvent | Event) => { const stopEvent = (e: React.UIEvent | Event | React.FormEvent) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
}; };