From 9a26a8270a15feb293fcf758d377f72259070f27 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Tue, 1 Nov 2022 18:53:44 +0100 Subject: [PATCH] Payments: Support tips and saving payment info, refactoring (#2097) --- src/api/gramjs/apiBuilders/messages.ts | 2 +- src/api/gramjs/apiBuilders/payments.ts | 74 ++-- src/api/gramjs/methods/client.ts | 4 + src/api/gramjs/methods/index.ts | 2 +- src/api/gramjs/methods/payments.ts | 58 ++- src/api/gramjs/methods/twoFaSettings.ts | 6 +- src/api/types/messages.ts | 7 + src/api/types/payments.ts | 27 +- src/components/common/PasswordForm.tsx | 12 +- src/components/payment/Checkout.module.scss | 51 ++- src/components/payment/Checkout.tsx | 166 +++++++-- src/components/payment/PasswordConfirm.tsx | 70 ++++ src/components/payment/PaymentInfo.scss | 7 + src/components/payment/PaymentInfo.tsx | 18 +- src/components/payment/PaymentModal.scss | 8 +- src/components/payment/PaymentModal.tsx | 341 +++++++++++++----- src/components/payment/ReceiptModal.tsx | 43 ++- .../payment/SavedPaymentCredentials.tsx | 58 +++ src/components/payment/Shipping.tsx | 2 +- src/components/payment/ShippingInfo.tsx | 2 +- src/global/actions/api/payments.ts | 180 ++++----- src/global/actions/apiUpdaters/payments.ts | 19 + src/global/actions/ui/misc.ts | 4 +- src/global/reducers/payments.ts | 134 +++---- src/global/types.ts | 38 +- src/hooks/reducers/usePaymentReducer.ts | 17 +- src/lib/gramjs/client/2fa.ts | 28 +- src/lib/gramjs/client/TelegramClient.d.ts | 4 +- src/lib/gramjs/client/TelegramClient.js | 6 +- src/styles/_variables.scss | 3 +- src/styles/themes.json | 3 +- src/types/index.ts | 43 +-- src/util/formatCurrency.ts | 20 +- src/util/stopEvent.ts | 2 +- 34 files changed, 1008 insertions(+), 451 deletions(-) create mode 100644 src/components/payment/PasswordConfirm.tsx create mode 100644 src/components/payment/SavedPaymentCredentials.tsx diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 1c35479f9..563955905 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -752,8 +752,8 @@ export function buildInvoice(media: GramJs.MessageMediaInvoice): ApiInvoice { } = media; return { - text, title, + text, photo: buildApiWebDocument(photo), receiptMsgId, amount: Number(totalAmount), diff --git a/src/api/gramjs/apiBuilders/payments.ts b/src/api/gramjs/apiBuilders/payments.ts index 30640fe2a..8e60c9b17 100644 --- a/src/api/gramjs/apiBuilders/payments.ts +++ b/src/api/gramjs/apiBuilders/payments.ts @@ -2,6 +2,7 @@ import type { Api as GramJs } from '../../../lib/gramjs'; import type { ApiInvoice, ApiPaymentSavedInfo, ApiPremiumPromo, ApiPremiumSubscriptionOption, + ApiPaymentForm, ApiReceipt, ApiLabeledPrice, ApiPaymentCredentials, } from '../../types'; import { buildApiDocument, buildApiMessageEntity, buildApiWebDocument } from './messages'; @@ -11,6 +12,7 @@ export function buildShippingOptions(shippingOptions: GramJs.ShippingOption[] | if (!shippingOptions) { return undefined; } + return Object.values(shippingOptions).map((option) => { return { 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 { invoice, info, @@ -34,18 +36,19 @@ export function buildReceipt(receipt: GramJs.payments.PaymentReceipt) { currency, totalAmount, credentialsTitle, + tipAmount, } = receipt; const { shippingAddress, phone, name } = (info || {}); const { prices } = invoice; - const mapedPrices = prices.map(({ label, amount }) => ({ + const mappedPrices: ApiLabeledPrice[] = prices.map(({ label, amount }) => ({ label, amount: amount.toJSNumber(), })); - let shippingPrices; - let shippingMethod; + let shippingPrices: ApiLabeledPrice[] | undefined; + let shippingMethod: string | undefined; if (shipping) { shippingPrices = shipping.prices.map(({ label, amount }) => { @@ -59,41 +62,43 @@ export function buildReceipt(receipt: GramJs.payments.PaymentReceipt) { return { currency, - prices: mapedPrices, + prices: mappedPrices, info: { shippingAddress, phone, name }, totalAmount: totalAmount.toJSNumber(), credentialsTitle, shippingPrices, shippingMethod, + tipAmount: tipAmount ? tipAmount.toJSNumber() : 0, }; } -export function buildPaymentForm(form: GramJs.payments.PaymentForm) { +export function buildApiPaymentForm(form: GramJs.payments.PaymentForm): ApiPaymentForm { const { formId, canSaveCredentials, - passwordMissing, + passwordMissing: isPasswordMissing, providerId, nativeProvider, nativeParams, savedInfo, invoice, + savedCredentials, } = form; const { - test, - nameRequested, - phoneRequested, - emailRequested, - shippingAddressRequested, - flexible, - phoneToProvider, - emailToProvider, + test: isTest, + nameRequested: isNameRequested, + phoneRequested: isPhoneRequested, + emailRequested: isEmailRequested, + shippingAddressRequested: isShippingAddressRequested, + flexible: isFlexible, + phoneToProvider: shouldSendPhoneToProvider, + emailToProvider: shouldSendEmailToProvider, currency, prices, } = invoice; - const mappedPrices = prices.map(({ label, amount }) => ({ + const mappedPrices: ApiLabeledPrice[] = prices.map(({ label, amount }) => ({ label, amount: amount.toJSNumber(), })); @@ -107,30 +112,31 @@ export function buildPaymentForm(form: GramJs.payments.PaymentForm) { return { canSaveCredentials, - passwordMissing, + isPasswordMissing, formId: String(formId), providerId: String(providerId), nativeProvider, savedInfo: cleanedInfo, - invoice: { - test, - nameRequested, - phoneRequested, - emailRequested, - shippingAddressRequested, - flexible, - phoneToProvider, - emailToProvider, + invoiceContainer: { + isTest, + isNameRequested, + isPhoneRequested, + isEmailRequested, + isShippingAddressRequested, + isFlexible, + shouldSendPhoneToProvider, + shouldSendEmailToProvider, currency, prices: mappedPrices, }, nativeParams: { - needCardholderName: nativeData.need_cardholder_name, - needCountry: nativeData.need_country, - needZip: nativeData.need_zip, - publishableKey: nativeData.publishable_key, + needCardholderName: Boolean(nativeData?.need_cardholder_name), + needCountry: Boolean(nativeData?.need_country), + needZip: Boolean(nativeData?.need_zip), + publishableKey: nativeData?.publishable_key, 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, } = form; const { - test, currency, prices, recurring, recurringTermsUrl, + test, currency, prices, recurring, recurringTermsUrl, maxTipAmount, suggestedTipAmounts, } = invoice; 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, isRecurring: recurring, recurringTermsUrl, + maxTipAmount: maxTipAmount?.toJSNumber(), + ...(suggestedTipAmounts && { suggestedTipAmounts: suggestedTipAmounts.map((tip) => tip.toJSNumber()) }), }; } @@ -184,3 +192,7 @@ function buildApiPremiumSubscriptionOption(option: GramJs.PremiumSubscriptionOpt months, }; } + +export function buildApiPaymentCredentials(credentials: GramJs.PaymentSavedCredentialsCard[]): ApiPaymentCredentials[] { + return credentials.map(({ id, title }) => ({ id, title })); +} diff --git a/src/api/gramjs/methods/client.ts b/src/api/gramjs/methods/client.ts index 120fcfa19..0fa20bc58 100644 --- a/src/api/gramjs/methods/client.ts +++ b/src/api/gramjs/methods/client.ts @@ -309,6 +309,10 @@ export function updateTwoFaSettings(params: TwoFaParams) { return client.updateTwoFaSettings(params); } +export function getTmpPassword(currentPassword: string, ttl?: number) { + return client.getTmpPassword(currentPassword, ttl); +} + export async function fetchCurrentUser() { const userFull = await invokeRequest(new GramJs.users.GetFullUser({ id: new GramJs.InputUserSelf(), diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 1dedb6dd5..be5429a16 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -74,7 +74,7 @@ export { } from './bots'; export { - validateRequestedInfo, sendPaymentForm, getPaymentForm, getReceipt, fetchPremiumPromo, + validateRequestedInfo, sendPaymentForm, getPaymentForm, getReceipt, fetchPremiumPromo, fetchTemporaryPaymentPassword, } from './payments'; export { diff --git a/src/api/gramjs/methods/payments.ts b/src/api/gramjs/methods/payments.ts index 65838cf56..89c0f79cd 100644 --- a/src/api/gramjs/methods/payments.ts +++ b/src/api/gramjs/methods/payments.ts @@ -3,14 +3,23 @@ import { Api as GramJs } from '../../../lib/gramjs'; import { invokeRequest } from './client'; import { buildInputInvoice, buildInputPeer, buildShippingInfo } from '../gramjsBuilders'; import { - buildShippingOptions, buildPaymentForm, buildReceipt, buildApiPremiumPromo, buildApiInvoiceFromForm, + buildApiInvoiceFromForm, + buildApiPremiumPromo, + buildApiPaymentForm, + buildApiReceipt, + buildShippingOptions, } from '../apiBuilders/payments'; import type { ApiChat, OnApiUpdate, ApiRequestInputInvoice, } from '../../types'; import localDb from '../localDb'; -import { addEntitiesWithPhotosToLocalDb } from '../helpers'; +import { + addEntitiesWithPhotosToLocalDb, + deserializeBytes, + serializeBytes, +} from '../helpers'; import { buildApiUser } from '../apiBuilders/users'; +import { getTemporaryPaymentPassword } from './twoFaSettings'; let onUpdate: OnApiUpdate; @@ -56,22 +65,35 @@ export async function sendPaymentForm({ requestedInfoId, shippingOptionId, credentials, + savedCredentialId, + temporaryPassword, + tipAmount, }: { inputInvoice: ApiRequestInputInvoice; formId: string; credentials: any; requestedInfoId?: 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({ formId: BigInt(formId), invoice: buildInputInvoice(inputInvoice), requestedInfoId, shippingOptionId, - credentials: new GramJs.InputPaymentCredentials({ - save: credentials.save, - data: new GramJs.DataJSON({ data: JSON.stringify(credentials.data) }), - }), + credentials: inputCredentials, + ...(tipAmount && { tipAmount: BigInt(tipAmount) }), })); if (result instanceof GramJs.payments.PaymentVerificationNeeded) { @@ -99,8 +121,10 @@ export async function getPaymentForm(inputInvoice: ApiRequestInputInvoice) { localDb.webDocuments[result.photo.url] = result.photo; } + addEntitiesWithPhotosToLocalDb(result.users); + return { - form: buildPaymentForm(result), + form: buildApiPaymentForm(result), invoice: buildApiInvoiceFromForm(result), }; } @@ -110,11 +134,12 @@ export async function getReceipt(chat: ApiChat, msgId: number) { peer: buildInputPeer(chat.id, chat.accessHash), msgId, })); + if (!result) { return undefined; } - return buildReceipt(result); + return buildApiReceipt(result); } export async function fetchPremiumPromo() { @@ -135,3 +160,20 @@ export async function fetchPremiumPromo() { 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, + }; +} diff --git a/src/api/gramjs/methods/twoFaSettings.ts b/src/api/gramjs/methods/twoFaSettings.ts index 52ddd22f5..6c84c6106 100644 --- a/src/api/gramjs/methods/twoFaSettings.ts +++ b/src/api/gramjs/methods/twoFaSettings.ts @@ -3,7 +3,7 @@ import { Api as GramJs, errors } from '../../../lib/gramjs'; import type { OnApiUpdate } from '../../types'; import { DEBUG } from '../../../config'; -import { invokeRequest, updateTwoFaSettings } from './client'; +import { invokeRequest, updateTwoFaSettings, getTmpPassword } from './client'; const ApiErrors: { [k: string]: string } = { 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) { try { await updateTwoFaSettings({ isCheckPassword: true, currentPassword }); diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 8e546a18d..d030fca1a 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -187,6 +187,13 @@ export interface ApiInvoice { isTest?: boolean; isRecurring?: boolean; recurringTermsUrl?: string; + maxTipAmount?: number; + suggestedTipAmounts?: number[]; +} + +export interface ApiPaymentCredentials { + id: string; + title: string; } interface ApiGeoPoint { diff --git a/src/api/types/payments.ts b/src/api/types/payments.ts index 083aadf0b..e8578f8ea 100644 --- a/src/api/types/payments.ts +++ b/src/api/types/payments.ts @@ -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 { streetLine1: string; @@ -18,22 +20,13 @@ export interface ApiPaymentSavedInfo { export interface ApiPaymentForm { canSaveCredentials?: boolean; - passwordMissing?: boolean; + isPasswordMissing?: boolean; + formId: string; providerId: string; nativeProvider?: string; - savedInfo: any; - invoice: { - test?: boolean; - nameRequested?: boolean; - phoneRequested?: boolean; - emailRequested?: boolean; - shippingAddressRequested?: boolean; - flexible?: boolean; - phoneToProvider?: boolean; - emailToProvider?: boolean; - currency?: string; - prices?: ApiLabeledPrice[]; - }; + savedInfo?: ApiPaymentSavedInfo; + savedCredentials?: ApiPaymentCredentials[]; + invoiceContainer: ApiInvoiceContainer; nativeParams: ApiPaymentFormNativeParams; } @@ -51,6 +44,9 @@ export interface ApiLabeledPrice { } export interface ApiReceipt { + photo?: ApiWebDocument; + text?: string; + title?: string; currency: string; prices: ApiLabeledPrice[]; info?: { @@ -58,6 +54,7 @@ export interface ApiReceipt { phone?: string; name?: string; }; + tipAmount: number; totalAmount: number; credentialsTitle: string; shippingPrices?: ApiLabeledPrice[]; diff --git a/src/components/common/PasswordForm.tsx b/src/components/common/PasswordForm.tsx index f7ee50db0..aa121759c 100644 --- a/src/components/common/PasswordForm.tsx +++ b/src/components/common/PasswordForm.tsx @@ -7,6 +7,7 @@ import React, { import { MIN_PASSWORD_LENGTH } from '../../config'; import { IS_TOUCH_ENV, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment'; import buildClassName from '../../util/buildClassName'; +import stopEvent from '../../util/stopEvent'; import useLang from '../../hooks/useLang'; import useTimeout from '../../hooks/useTimeout'; @@ -17,6 +18,7 @@ type OwnProps = { error?: string; hint?: string; placeholder?: string; + description?: string; isLoading?: boolean; shouldDisablePasswordManager?: boolean; shouldShowSubmit?: boolean; @@ -26,7 +28,7 @@ type OwnProps = { noRipple?: boolean; onChangePasswordVisibility: (state: boolean) => void; onInputChange?: (password: string) => void; - onSubmit: (password: string) => void; + onSubmit?: (password: string) => void; }; const FOCUS_DELAY_TIMEOUT_MS = IS_SINGLE_COLUMN_LAYOUT ? 550 : 400; @@ -38,6 +40,7 @@ const PasswordForm: FC = ({ hint, placeholder = 'Password', submitLabel = 'Next', + description, shouldShowSubmit, shouldResetValue, shouldDisablePasswordManager = false, @@ -100,7 +103,7 @@ const PasswordForm: FC = ({ } if (canSubmit) { - onSubmit(password); + onSubmit!(password); } } @@ -117,7 +120,7 @@ const PasswordForm: FC = ({ } return ( -
+
= ({
- {(canSubmit || shouldShowSubmit) && ( + {description &&

{description}

} + {onSubmit && (canSubmit || shouldShowSubmit) && ( diff --git a/src/components/payment/Checkout.module.scss b/src/components/payment/Checkout.module.scss index 4e704f30b..3b5ba33c9 100644 --- a/src/components/payment/Checkout.module.scss +++ b/src/components/payment/Checkout.module.scss @@ -59,29 +59,54 @@ } } -.invoice-info { - border-top: 1px var(--color-borders) solid; - padding: 1rem; -} - -.checkout-info-item { +.tipsList { display: flex; - padding: 0.75rem 0.5rem 1rem; - text-align: left; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.5rem } -.checkout-info-item-icon { - font-size: 1.5rem; - color: var(--color-text-secondary); - margin-right: 2rem; - width: 1.5rem; +.tipsItem { + border-radius: 1.375rem; + padding: 0 0.75rem; + height: 2.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 { + float: left; background: no-repeat center; background-size: 2rem; border-radius: 1rem; + width: 1.5rem; height: 1.5rem; + margin-right: 2rem; } .provider.stripe { diff --git a/src/components/payment/Checkout.tsx b/src/components/payment/Checkout.tsx index 46831b436..aed2560e8 100644 --- a/src/components/payment/Checkout.tsx +++ b/src/components/payment/Checkout.tsx @@ -1,9 +1,12 @@ +import React, { memo, useCallback } from '../../lib/teact/teact'; +import { getActions } from '../../global'; + 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 { ApiChat, ApiWebDocument } from '../../api/types'; +import type { ApiChat, ApiInvoice, ApiPaymentCredentials } from '../../api/types'; +import { PaymentStep } from '../../types'; import { getWebDocumentHash } from '../../global/helpers'; import { formatCurrency } from '../../util/formatCurrency'; import buildClassName from '../../util/buildClassName'; @@ -15,18 +18,13 @@ import useMedia from '../../hooks/useMedia'; import Checkbox from '../ui/Checkbox'; import Skeleton from '../ui/Skeleton'; import SafeLink from '../common/SafeLink'; +import ListItem from '../ui/ListItem'; import styles from './Checkout.module.scss'; export type OwnProps = { chat?: ApiChat; - invoiceContent?: { - title?: string; - text?: string; - photo?: ApiWebDocument; - isRecurring?: boolean; - recurringTermsUrl?: string; - }; + invoice?: ApiInvoice; checkoutInfo?: { paymentMethod?: string; paymentProvider?: string; @@ -37,28 +35,41 @@ export type OwnProps = { }; prices?: Price[]; totalPrice?: number; + needAddress?: boolean; + hasShippingOptions?: boolean; + tipAmount?: number; shippingPrices?: Price[]; currency: string; isTosAccepted?: boolean; + dispatch?: FormEditDispatch; onAcceptTos?: (isAccepted: boolean) => void; + savedCredentials?: ApiPaymentCredentials[]; }; const Checkout: FC = ({ chat, - invoiceContent, + invoice, prices, shippingPrices, checkoutInfo, currency, totalPrice, isTosAccepted, + dispatch, onAcceptTos, + tipAmount, + needAddress, + hasShippingOptions, + savedCredentials, }) => { + const { setPaymentStep } = getActions(); + const lang = useLang(); + const isInteractive = Boolean(dispatch); const { - photo, title, text, isRecurring, recurringTermsUrl, - } = invoiceContent || {}; + photo, title, text, isRecurring, recurringTermsUrl, suggestedTipAmounts, maxTipAmount, + } = invoice || {}; const { paymentMethod, paymentProvider, @@ -70,6 +81,48 @@ const Checkout: FC = ({ 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 ( + <> +
+
+ {title} +
+
+ {formatCurrency(tipAmount!, currency, lang.code)} +
+
+
+ {suggestedTipAmounts!.map((tip) => ( +
handleTipsClick(tip === tipAmount ? 0 : tip) : undefined} + > + {formatCurrency(tip, currency, lang.code, true)} +
+ ))} +
+ + ); + } + function renderTosLink(url: string, isRtl?: boolean) { const langString = lang('PaymentCheckoutAcceptRecurrent', chat?.title); const langStringSplit = langString.split('*'); @@ -125,27 +178,53 @@ const Checkout: FC = ({ {shippingPrices && shippingPrices.map((item) => ( renderPaymentItem(lang.code, item.label, item.amount, currency) ))} + {suggestedTipAmounts && suggestedTipAmounts.length > 0 && renderTips()} {totalPrice !== undefined && ( renderPaymentItem(lang.code, lang('Checkout.TotalAmount'), totalPrice, currency, true) )}
- {paymentMethod && renderCheckoutItem('icon-card', paymentMethod, lang('PaymentCheckoutMethod'))} - {paymentProvider && renderCheckoutItem( - buildClassName(styles.provider, styles[paymentProvider.toLowerCase()]), - paymentProvider, - lang('PaymentCheckoutProvider'), - )} - {shippingAddress && renderCheckoutItem('icon-location', shippingAddress, lang('PaymentShippingAddress'))} - {name && renderCheckoutItem('icon-user', name, lang('PaymentCheckoutName'))} - {phone && renderCheckoutItem('icon-phone', phone, lang('PaymentCheckoutPhoneNumber'))} - {shippingMethod && renderCheckoutItem('icon-truck', shippingMethod, lang('PaymentCheckoutShippingMethod'))} + {renderCheckoutItem({ + title: paymentMethod || savedCredentials?.[0].title, + label: lang('PaymentCheckoutMethod'), + icon: 'card', + onClick: isInteractive ? handlePaymentMethodClick : undefined, + })} + {paymentProvider && renderCheckoutItem({ + title: paymentProvider, + label: lang('PaymentCheckoutProvider'), + 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!)}
); }; +export default memo(Checkout); + function renderPaymentItem( 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 ( -
- -
-
- {title} -
-

- {data} -

+ + {customIcon && } +
+ {title || label}
-
+ {title && label !== title && ( +

+ {label} +

+ )} + ); } - -export default memo(Checkout); diff --git a/src/components/payment/PasswordConfirm.tsx b/src/components/payment/PasswordConfirm.tsx new file mode 100644 index 000000000..753ebdfd7 --- /dev/null +++ b/src/components/payment/PasswordConfirm.tsx @@ -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 = ({ + 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 ( +
+ + + +
+ ); +}; + +export default memo(withGlobal((global): StateProps => { + return { + error: global.payment.error?.message, + passwordHint: global.twoFaSettings.hint, + savedCredentials: global.payment.savedCredentials, + }; +})(PasswordConfirm)); diff --git a/src/components/payment/PaymentInfo.scss b/src/components/payment/PaymentInfo.scss index 85bbd7191..476792ec2 100644 --- a/src/components/payment/PaymentInfo.scss +++ b/src/components/payment/PaymentInfo.scss @@ -17,4 +17,11 @@ display: flex; } } + + .description { + font-size: 0.875rem; + color: var(--color-text-secondary); + margin-top: -0.75rem; + margin-bottom: 1.25rem; + } } diff --git a/src/components/payment/PaymentInfo.tsx b/src/components/payment/PaymentInfo.tsx index 5ceaa2072..d5f0812f5 100644 --- a/src/components/payment/PaymentInfo.tsx +++ b/src/components/payment/PaymentInfo.tsx @@ -153,14 +153,16 @@ const PaymentInfo: FC = ({ error={formErrors.billingZip} /> )} - { canSaveCredentials && ( - - ) } + +

+ {lang(canSaveCredentials ? 'Checkout.NewCard.SaveInfoHelp' : 'Checkout.2FA.Text')} +

); diff --git a/src/components/payment/PaymentModal.scss b/src/components/payment/PaymentModal.scss index a3b9799a5..37666be46 100644 --- a/src/components/payment/PaymentModal.scss +++ b/src/components/payment/PaymentModal.scss @@ -17,12 +17,11 @@ $modalHeaderAndFooterHeight: 8.375rem; border-top-left-radius: var(--border-radius-default-small); border-top-right-radius: var(--border-radius-default-small); width: 100%; - padding: 0.25rem 1rem; + padding: 0.5rem 1rem 0.25rem; display: flex; align-items: center; flex-direction: row; background: var(--color-background); - border-bottom: 1px var(--color-borders) solid; h3 { margin-bottom: 0; @@ -33,7 +32,7 @@ $modalHeaderAndFooterHeight: 8.375rem; } .Transition { - height: min(25rem, 60vh); + height: min(27rem, 60vh); } .empty-content { @@ -51,6 +50,7 @@ $modalHeaderAndFooterHeight: 8.375rem; .content { overflow: auto; + overflow-x: hidden; width: 100%; height: 100%; position: relative; @@ -64,7 +64,7 @@ $modalHeaderAndFooterHeight: 8.375rem; width: 100%; padding: 0.75rem 1rem; background: var(--color-background); - border-top: 1px var(--color-borders) solid; + border-top: 0.0625rem var(--color-borders) solid; button { text-transform: none; diff --git a/src/components/payment/PaymentModal.tsx b/src/components/payment/PaymentModal.tsx index 41cb4d9e5..ef57e5b2f 100644 --- a/src/components/payment/PaymentModal.tsx +++ b/src/components/payment/PaymentModal.tsx @@ -5,17 +5,19 @@ import React, { import { getActions, withGlobal } from '../../global'; import type { GlobalState } from '../../global/types'; -import type { ApiChat, ApiCountry } from '../../api/types'; -import type { ShippingOption, Price } from '../../types'; -import { PaymentStep } from '../../types'; +import type { ApiChat, ApiCountry, ApiPaymentCredentials } from '../../api/types'; +import type { Price, ShippingOption } from '../../types'; +import type { FormState } from '../../hooks/reducers/usePaymentReducer'; +import { PaymentStep } from '../../types'; import { selectChat } from '../../global/selectors'; import { formatCurrency } from '../../util/formatCurrency'; import buildClassName from '../../util/buildClassName'; import { detectCardTypeText } from '../common/helpers/detectCardType'; -import type { FormState } from '../../hooks/reducers/usePaymentReducer'; -import usePaymentReducer from '../../hooks/reducers/usePaymentReducer'; +import captureKeyboardListeners from '../../util/captureKeyboardListeners'; import useLang from '../../hooks/useLang'; +import useFlag from '../../hooks/useFlag'; +import usePaymentReducer from '../../hooks/reducers/usePaymentReducer'; import ShippingInfo from './ShippingInfo'; import Shipping from './Shipping'; @@ -26,6 +28,8 @@ import Modal from '../ui/Modal'; import Transition from '../ui/Transition'; import Spinner from '../ui/Spinner'; import ConfirmPayment from './ConfirmPayment'; +import SavedPaymentCredentials from './SavedPaymentCredentials'; +import PasswordConfirm from './PasswordConfirm'; import './PaymentModal.scss'; @@ -40,13 +44,12 @@ export type OwnProps = { type StateProps = { chat?: ApiChat; - nameRequested?: boolean; - shippingAddressRequested?: boolean; - phoneRequested?: boolean; - emailRequested?: boolean; - flexible?: boolean; - phoneToProvider?: boolean; - emailToProvider?: boolean; + isNameRequested?: boolean; + isShippingAddressRequested?: boolean; + isPhoneRequested?: boolean; + isEmailRequested?: boolean; + shouldSendPhoneToProvider?: boolean; + shouldSendEmailToProvider?: boolean; currency?: string; prices?: Price[]; isProviderError: boolean; @@ -55,14 +58,21 @@ type StateProps = { needZip?: boolean; confirmPaymentUrl?: string; countryList: ApiCountry[]; + hasShippingOptions: boolean; + requestId?: string; + smartGlocalToken?: string; + stripeId?: string; + savedCredentials?: ApiPaymentCredentials[]; + passwordValidUntil?: number; }; type GlobalStateProps = Pick; +const NETWORK_REQUEST_TIMEOUT_S = 3; + const PaymentModal: FC = ({ isOpen, onClose, @@ -71,16 +81,16 @@ const PaymentModal: FC = ({ shippingOptions, savedInfo, canSaveCredentials, - nameRequested, - shippingAddressRequested, - phoneRequested, - emailRequested, - phoneToProvider, - emailToProvider, + isNameRequested, + isShippingAddressRequested, + isPhoneRequested, + isEmailRequested, + shouldSendPhoneToProvider, + shouldSendEmailToProvider, currency, passwordMissing, isProviderError, - invoiceContent, + invoice, nativeProvider, prices, needCardholderName, @@ -89,23 +99,47 @@ const PaymentModal: FC = ({ confirmPaymentUrl, error, countryList, + hasShippingOptions, + requestId, + smartGlocalToken, + stripeId, + savedCredentials, + passwordValidUntil, }) => { const { + loadPasswordInfo, validateRequestedInfo, sendPaymentForm, setPaymentStep, sendCredentialsInfo, clearPaymentError, + validatePaymentPassword, } = getActions(); + const lang = useLang(); + + const [isModalOpen, openModal, closeModal] = useFlag(); const [paymentState, paymentDispatch] = usePaymentReducer(); const [isLoading, setIsLoading] = useState(false); const [isTosAccepted, setIsTosAccepted] = useState(false); - const lang = useLang(); + const [twoFaPassword, setTwoFaPassword] = useState(''); + const canRenderFooter = step !== PaymentStep.ConfirmPayment; + const setStep = useCallback((nextStep) => { + setPaymentStep({ step: nextStep }); + }, [setPaymentStep]); + useEffect(() => { - if (step || error) { + if (isOpen) { + setTwoFaPassword(''); + loadPasswordInfo(); + openModal(); + } + }, [isOpen, loadPasswordInfo, openModal]); + + useEffect(() => { + if (step !== undefined || error) { setIsLoading(false); } }, [step, error]); @@ -148,6 +182,15 @@ const PaymentModal: FC = ({ } }, [savedInfo, paymentDispatch, countryList]); + useEffect(() => { + if (savedCredentials?.length) { + paymentDispatch({ + type: 'changeSavedCredentialId', + payload: savedCredentials[0].id, + }); + } + }, [paymentDispatch, savedCredentials]); + const handleErrorModalClose = useCallback(() => { clearPaymentError(); }, [clearPaymentError]); @@ -157,8 +200,8 @@ const PaymentModal: FC = ({ return 0; } - return getTotalPrice(prices, shippingOptions, paymentState.shipping); - }, [step, paymentState.shipping, prices, shippingOptions]); + return getTotalPrice(prices, shippingOptions, paymentState.shipping, paymentState.tipAmount); + }, [step, prices, shippingOptions, paymentState.shipping, paymentState.tipAmount]); const checkoutInfo = useMemo(() => { if (step !== PaymentStep.Checkout) { @@ -167,6 +210,10 @@ const PaymentModal: FC = ({ return getCheckoutInfo(paymentState, shippingOptions, nativeProvider || ''); }, [step, paymentState, shippingOptions, nativeProvider]); + const handleNewCardClick = useCallback(() => { + setStep(PaymentStep.PaymentInfo); + }, [setStep]); + function renderError() { if (!error) { return undefined; @@ -191,25 +238,43 @@ const PaymentModal: FC = ({ function renderModalContent(currentStep: PaymentStep) { switch (currentStep) { - case PaymentStep.ShippingInfo: + case PaymentStep.Checkout: return ( - ); - case PaymentStep.Shipping: + case PaymentStep.SavedPayments: return ( - + ); + case PaymentStep.ConfirmPassword: + return ( + ); case PaymentStep.PaymentInfo: @@ -224,20 +289,25 @@ const PaymentModal: FC = ({ countryList={countryList} /> ); - case PaymentStep.Checkout: + case PaymentStep.ShippingInfo: return ( - + ); + case PaymentStep.Shipping: + return ( + ); case PaymentStep.ConfirmPayment: @@ -268,48 +338,126 @@ const PaymentModal: FC = ({ sendPaymentForm({ shippingOptionId: paymentState.shipping, saveCredentials: paymentState.saveCredentials, + savedCredentialId: paymentState.savedCredentialId, + tipAmount: paymentState.tipAmount, }); }, [sendPaymentForm, paymentState]); - const setStep = useCallback((nextStep) => { - setPaymentStep({ step: nextStep }); - }, [setPaymentStep]); - const handleButtonClick = useCallback(() => { - setIsLoading(true); switch (step) { case PaymentStep.ShippingInfo: + setIsLoading(true); validateRequest(); break; + case PaymentStep.Shipping: - setStep(PaymentStep.PaymentInfo); + setStep(PaymentStep.Checkout); break; + + case PaymentStep.SavedPayments: + setStep(PaymentStep.ConfirmPassword); + break; + + case PaymentStep.ConfirmPassword: + if (twoFaPassword === '') { + return; + } + + setIsLoading(true); + validatePaymentPassword({ password: twoFaPassword }); + break; + case PaymentStep.PaymentInfo: + setIsLoading(true); sendCredentials(); + paymentDispatch({ type: 'changeSavedCredentialId', payload: '' }); break; - case PaymentStep.Checkout: + + case PaymentStep.Checkout: { + if (savedInfo && !requestId && !paymentState.shipping) { + setIsLoading(true); + validateRequest(); + return; + } + + if ( + paymentState.savedCredentialId + && (!passwordValidUntil || passwordValidUntil <= (Date.now() / 1000 - NETWORK_REQUEST_TIMEOUT_S)) + ) { + setStep(PaymentStep.ConfirmPassword); + return; + } + + if ( + !paymentState.savedCredentialId + && ( + (nativeProvider === DEFAULT_PROVIDER && !stripeId) + || (nativeProvider === DONATE_PROVIDER && !smartGlocalToken) + ) + ) { + setStep(PaymentStep.PaymentInfo); + return; + } + + const { phone, email, fullName } = paymentState; + const shouldFillRequestedData = (isEmailRequested && !email) + || (isPhoneRequested && !phone) + || (isNameRequested && !fullName); + + if ((isShippingAddressRequested && !requestId) || shouldFillRequestedData) { + setStep(PaymentStep.ShippingInfo); + return; + } + + if (isShippingAddressRequested && !paymentState.shipping) { + setStep(PaymentStep.Shipping); + return; + } + + setIsLoading(true); sendForm(); break; + } } - }, [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(() => { paymentDispatch({ type: 'resetState', }); setIsTosAccepted(false); - }, [paymentDispatch]); + onClose(); + }, [onClose, paymentDispatch]); + + const handleBackClick = useCallback(() => { + setStep(step === PaymentStep.ConfirmPassword ? PaymentStep.SavedPayments : PaymentStep.Checkout); + }, [setStep, step]); const modalHeader = useMemo(() => { switch (step) { + case PaymentStep.Checkout: + return lang('PaymentCheckout'); case PaymentStep.ShippingInfo: return lang('PaymentShippingInfo'); case PaymentStep.Shipping: return lang('PaymentShippingMethod'); + case PaymentStep.SavedPayments: + return lang('PaymentCheckoutMethod'); + case PaymentStep.ConfirmPassword: + return lang('Checkout.PasswordEntry.Title'); case PaymentStep.PaymentInfo: return lang('PaymentCardInfo'); - case PaymentStep.Checkout: - return lang('PaymentCheckout'); case PaymentStep.ConfirmPayment: return lang('Checkout.WebConfirmation.Title'); default: @@ -322,14 +470,15 @@ const PaymentModal: FC = ({ : lang('Next'); const isSubmitDisabled = isLoading - || Boolean(step === PaymentStep.Checkout && invoiceContent?.isRecurring && !isTosAccepted); + || Boolean(step === PaymentStep.Checkout && invoice?.isRecurring && !isTosAccepted); if (isProviderError) { return (

Sorry, Telegram WebZ doesn't support payments with this provider yet.
@@ -337,7 +486,7 @@ const PaymentModal: FC = ({

@@ -347,9 +496,9 @@ const PaymentModal: FC = ({ return (
@@ -358,10 +507,10 @@ const PaymentModal: FC = ({ color="translucent" round size="smaller" - onClick={onClose} + onClick={step === PaymentStep.Checkout ? closeModal : handleBackClick} ariaLabel="Close" > - +

{modalHeader}

@@ -401,29 +550,33 @@ export default memo(withGlobal( savedInfo, canSaveCredentials, invoice, - invoiceContent, + invoiceContainer, nativeProvider, nativeParams, passwordMissing, error, confirmPaymentUrl, inputInvoice, + requestId, + stripeCredentials, + smartGlocalCredentials, + savedCredentials, + temporaryPassword, } = global.payment; const chat = inputInvoice && 'chatId' in inputInvoice ? selectChat(global, inputInvoice.chatId) : undefined; const isProviderError = Boolean(invoice && (!nativeProvider || !SUPPORTED_PROVIDERS.has(nativeProvider))); const { needCardholderName, needCountry, needZip } = (nativeParams || {}); const { - nameRequested, - phoneRequested, - emailRequested, - shippingAddressRequested, - flexible, - phoneToProvider, - emailToProvider, + isNameRequested, + isShippingAddressRequested, + isPhoneRequested, + isEmailRequested, + shouldSendPhoneToProvider, + shouldSendEmailToProvider, currency, prices, - } = (invoice || {}); + } = (invoiceContainer || {}); return { step, @@ -433,23 +586,28 @@ export default memo(withGlobal( canSaveCredentials, nativeProvider, passwordMissing, - nameRequested, - shippingAddressRequested, - phoneRequested, - emailRequested, - flexible, - phoneToProvider, - emailToProvider, + isNameRequested, + isShippingAddressRequested, + isPhoneRequested, + isEmailRequested, + shouldSendPhoneToProvider, + shouldSendEmailToProvider, currency, prices, isProviderError, - invoiceContent, + invoice, needCardholderName, needCountry, needZip, error, confirmPaymentUrl, countryList: global.countryList.general, + requestId, + hasShippingOptions: Boolean(shippingOptions?.length), + smartGlocalToken: smartGlocalCredentials?.token, + stripeId: stripeCredentials?.id, + savedCredentials, + passwordValidUntil: temporaryPassword?.validUntil, }; }, )(PaymentModal)); @@ -463,11 +621,16 @@ function getShippingPrices(shippingOptions: ShippingOption[], shippingOption: st 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 ? getShippingPrices(shippingOptions, shippingOption) : []; - let total = 0; + let total = tipAmount; const totalPrices = prices.concat(shippingPrices || []); total = totalPrices.reduce((acc, cur) => { return acc + cur.amount; @@ -477,7 +640,7 @@ function getTotalPrice(prices: Price[] = [], shippingOptions: ShippingOption[] | function getCheckoutInfo(state: FormState, shippingOptions: ShippingOption[] | undefined, paymentProvider: string) { 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 ? `${state.streetLine1}, ${state.city}, ${state.countryIso2}` : undefined; diff --git a/src/components/payment/ReceiptModal.tsx b/src/components/payment/ReceiptModal.tsx index d05583314..bddae5898 100644 --- a/src/components/payment/ReceiptModal.tsx +++ b/src/components/payment/ReceiptModal.tsx @@ -1,11 +1,13 @@ -import type { FC } from '../../lib/teact/teact'; -import React, { memo, useMemo } from '../../lib/teact/teact'; +import React, { memo, useMemo, useEffect } from '../../lib/teact/teact'; import { withGlobal } from '../../global'; +import type { FC } from '../../lib/teact/teact'; + import type { Price } from '../../types'; import type { ApiShippingAddress, ApiWebDocument } from '../../api/types'; import useLang from '../../hooks/useLang'; +import useFlag from '../../hooks/useFlag'; import Checkout from './Checkout'; import Modal from '../ui/Modal'; @@ -21,6 +23,7 @@ export type OwnProps = { type StateProps = { prices?: Price[]; shippingPrices: any; + tipAmount?: number; totalAmount?: number; currency?: string; info?: { @@ -40,6 +43,7 @@ const ReceiptModal: FC = ({ onClose, prices, shippingPrices, + tipAmount, totalAmount, currency, info, @@ -50,15 +54,35 @@ const ReceiptModal: FC = ({ shippingMethod, }) => { const lang = useLang(); + + const [isModalOpen, openModal, closeModal] = useFlag(); + + useEffect(() => { + if (isOpen) { + openModal(); + } + }, [isOpen, openModal]); + const checkoutInfo = useMemo(() => { return getCheckoutInfo(credentialsTitle, info, shippingMethod); }, [info, shippingMethod, credentialsTitle]); + const invoice = useMemo(() => { + return { + photo, + text: text!, + title: title!, + amount: totalAmount!, + currency: currency!, + }; + }, [currency, photo, text, title, totalAmount]); + return (
@@ -67,7 +91,7 @@ const ReceiptModal: FC = ({ color="translucent" round size="smaller" - onClick={onClose} + onClick={closeModal} ariaLabel="Close" > @@ -79,11 +103,8 @@ const ReceiptModal: FC = ({ prices={prices} shippingPrices={shippingPrices} totalPrice={totalAmount} - invoiceContent={{ - photo, - text, - title, - }} + tipAmount={tipAmount} + invoice={invoice} checkoutInfo={checkoutInfo} currency={currency!} /> @@ -107,12 +128,14 @@ export default memo(withGlobal( photo, text, title, + tipAmount, } = (receipt || {}); return { currency, prices, info, + tipAmount, totalAmount, credentialsTitle, shippingPrices, diff --git a/src/components/payment/SavedPaymentCredentials.tsx b/src/components/payment/SavedPaymentCredentials.tsx new file mode 100644 index 000000000..a10707437 --- /dev/null +++ b/src/components/payment/SavedPaymentCredentials.tsx @@ -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 = ({ + 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 ( +
+
+
{lang('PaymentCardTitle')}
+ + + + + +
+ ); +}; + +export default memo(SavedPaymentCredentials); diff --git a/src/components/payment/Shipping.tsx b/src/components/payment/Shipping.tsx index 1839c6c4e..9e5fdf134 100644 --- a/src/components/payment/Shipping.tsx +++ b/src/components/payment/Shipping.tsx @@ -29,7 +29,7 @@ const Shipping: FC = ({ const lang = useLang(); useEffect(() => { - if (!shippingOptions || state.shipping) { + if (!shippingOptions || !shippingOptions.length || state.shipping) { return; } dispatch({ type: 'changeShipping', payload: shippingOptions[0].id }); diff --git a/src/components/payment/ShippingInfo.tsx b/src/components/payment/ShippingInfo.tsx index fffda193b..c404cdfb8 100644 --- a/src/components/payment/ShippingInfo.tsx +++ b/src/components/payment/ShippingInfo.tsx @@ -4,8 +4,8 @@ import React, { } from '../../lib/teact/teact'; import type { ApiCountry } from '../../api/types'; - import type { FormState, FormEditDispatch } from '../../hooks/reducers/usePaymentReducer'; + import useFocusAfterAnimation from '../../hooks/useFocusAfterAnimation'; import useLang from '../../hooks/useLang'; diff --git a/src/global/actions/api/payments.ts b/src/global/actions/api/payments.ts index a6911a4a4..bc50edc68 100644 --- a/src/global/actions/api/payments.ts +++ b/src/global/actions/api/payments.ts @@ -1,8 +1,10 @@ import { addActionHandler, getGlobal, setGlobal } from '../../index'; +import { callApi } from '../../../api/gramjs'; +import type { ApiChat, ApiInvoice, ApiRequestInputInvoice } from '../../../api/types'; import { PaymentStep } from '../../../types'; -import type { ApiChat, ApiRequestInputInvoice } from '../../../api/types'; +import { DEBUG_PAYMENT_SMART_GLOCAL } from '../../../config'; import { selectPaymentRequestId, selectProviderPublishableKey, @@ -14,11 +16,8 @@ import { selectSmartGlocalCredentials, selectPaymentInputInvoice, } from '../../selectors'; -import { callApi } from '../../../api/gramjs'; import { getStripeError } from '../../helpers'; import { buildQueryString } from '../../../util/requestQuery'; -import { DEBUG_PAYMENT_SMART_GLOCAL } from '../../../config'; - import { updateShippingOptions, setPaymentStep, @@ -28,19 +27,25 @@ import { setReceipt, clearPayment, closeInvoice, - setSmartGlocalCardInfo, addUsers, setInvoiceInfo, + setSmartGlocalCardInfo, addUsers, setInvoiceInfo, updatePayment, } from '../../reducers'; import { buildCollectionByKey } from '../../../util/iteratees'; addActionHandler('validateRequestedInfo', (global, actions, payload) => { - const { requestInfo, saveInfo } = payload; const inputInvoice = selectPaymentInputInvoice(global); - if (!inputInvoice) return; + if (!inputInvoice) { + return; + } + + const { requestInfo, saveInfo } = payload; if ('slug' in inputInvoice) { void validateRequestedInfo(inputInvoice, requestInfo, saveInfo); } else { const chat = selectChat(global, inputInvoice.chatId); - if (!chat) return; + if (!chat) { + return; + } + void validateRequestedInfo({ chat, 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) => { - let invoice; + let invoice: ApiInvoice | undefined; if ('slug' in payload) { invoice = await getPaymentForm({ slug: payload.slug }); } else { const chat = selectChat(global, payload.chatId); - if (!chat) return; + if (!chat) { + return; + } + invoice = await getPaymentForm({ chat, messageId: payload.messageId, }); } - if (!invoice) return; + + if (!invoice) { + return; + } global = getGlobal(); 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 { const result = await callApi('getPaymentForm', inputInvoice); if (!result) { return undefined; } + const { form, invoice } = result; + let global = setPaymentForm(getGlobal(), form); - let step = PaymentStep.PaymentInfo; - const { - shippingAddressRequested, nameRequested, phoneRequested, emailRequested, - } = global.payment.invoice || {}; - if (shippingAddressRequested || nameRequested || phoneRequested || emailRequested) { - step = PaymentStep.ShippingInfo; - } - global = setPaymentStep(global, step); + global = setPaymentStep(global, PaymentStep.Checkout); setGlobal(global); + return invoice; } @@ -179,17 +163,19 @@ addActionHandler('sendCredentialsInfo', (global, actions, payload) => { } }); -addActionHandler('sendPaymentForm', (global, actions, payload) => { - const { shippingOptionId, saveCredentials } = payload; +addActionHandler('sendPaymentForm', async (global, actions, payload) => { + const { + shippingOptionId, saveCredentials, savedCredentialId, tipAmount, + } = payload; const inputInvoice = selectPaymentInputInvoice(global); const formId = selectPaymentFormId(global); const requestInfoId = selectPaymentRequestId(global); - const { nativeProvider } = global.payment; + const { nativeProvider, temporaryPassword } = global.payment; const publishableKey = nativeProvider === 'stripe' ? selectProviderPublishableKey(global) : selectProviderPublicToken(global); if (!inputInvoice || !publishableKey || !formId || !nativeProvider) { - return undefined; + return; } let requestInputInvoice; @@ -200,7 +186,7 @@ addActionHandler('sendPaymentForm', (global, actions, payload) => { } else { const chat = selectChat(global, inputInvoice.chatId); if (!chat) { - return undefined; + return; } requestInputInvoice = { @@ -209,18 +195,32 @@ addActionHandler('sendPaymentForm', (global, actions, payload) => { }; } - void sendPaymentForm(requestInputInvoice, formId, { + setGlobal(updatePayment(global, { status: 'pending' })); + + const credentials = { save: saveCredentials, 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( @@ -288,10 +288,10 @@ async function sendSmartGlocalCredentials( ) { const params = { card: { - number: data.cardNumber.replace(/[^\d]+/g, ''), + number: data.cardNumber.replace(/\D+/g, ''), expiration_month: data.expiryMonth, expiration_year: data.expiryYear, - security_code: data.cvv.replace(/[^\d]+/g, ''), + security_code: data.cvv.replace(/\D+/g, ''), }, }; const url = DEBUG_PAYMENT_SMART_GLOCAL @@ -334,32 +334,8 @@ async function sendSmartGlocalCredentials( 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 = {}) => { - return setPaymentStep(global, payload.step || PaymentStep.ShippingInfo); + return setPaymentStep(global, payload.step ?? PaymentStep.Checkout); }); addActionHandler('closePremiumModal', (global, actions, payload) => { @@ -431,3 +407,39 @@ addActionHandler('closeGiftPremiumModal', (global) => { 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); +} diff --git a/src/global/actions/apiUpdaters/payments.ts b/src/global/actions/apiUpdaters/payments.ts index cf0e29c1c..76d563112 100644 --- a/src/global/actions/apiUpdaters/payments.ts +++ b/src/global/actions/apiUpdaters/payments.ts @@ -2,11 +2,30 @@ import { addActionHandler } from '../../index'; import { IS_PRODUCTION_HOST } from '../../../util/environment'; import { clearPayment } from '../../reducers'; +import * as langProvider from '../../../util/langProvider'; +import { formatCurrency } from '../../../util/formatCurrency'; +import { selectChatMessage } from '../../selectors'; addActionHandler('apiUpdate', (global, actions, update) => { switch (update['@type']) { case 'updatePaymentStateCompleted': { 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, // after which the payment form closes. In other cases, the payment form must be closed manually. if (!IS_PRODUCTION_HOST) { diff --git a/src/global/actions/ui/misc.ts b/src/global/actions/ui/misc.ts index 1ea252b4e..908a43e82 100644 --- a/src/global/actions/ui/misc.ts +++ b/src/global/actions/ui/misc.ts @@ -1,6 +1,6 @@ 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 { 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.push(notification); + newNotifications.push(notification as ApiNotification); return { ...global, diff --git a/src/global/reducers/payments.ts b/src/global/reducers/payments.ts index 08fea082a..bf0c54515 100644 --- a/src/global/reducers/payments.ts +++ b/src/global/reducers/payments.ts @@ -4,37 +4,29 @@ import type { ApiInvoice, ApiMessage, ApiPaymentForm, ApiReceipt, } from '../../api/types'; +export function updatePayment(global: GlobalState, update: Partial): GlobalState { + return { + ...global, + payment: { + ...global.payment, + ...update, + }, + }; +} + export function updateShippingOptions( global: GlobalState, shippingOptions: ShippingOption[], ): GlobalState { - return { - ...global, - payment: { - ...global.payment, - shippingOptions, - }, - }; + return updatePayment(global, { shippingOptions }); } export function setRequestInfoId(global: GlobalState, id: string): GlobalState { - return { - ...global, - payment: { - ...global.payment, - requestId: id, - }, - }; + return updatePayment(global, { requestId: id }); } export function setPaymentStep(global: GlobalState, step: PaymentStep): GlobalState { - return { - ...global, - payment: { - ...global.payment, - step, - }, - }; + return updatePayment(global, { step }); } export function setInvoiceInfo(global: GlobalState, invoice: ApiInvoice): GlobalState { @@ -47,71 +39,43 @@ export function setInvoiceInfo(global: GlobalState, invoice: ApiInvoice): Global photo, isRecurring, recurringTermsUrl, + maxTipAmount, + suggestedTipAmounts, } = invoice; - return { - ...global, - payment: { - ...global.payment, - invoiceContent: { - title, - text, - photo, - amount, - currency, - isTest, - isRecurring, - recurringTermsUrl, - }, + return updatePayment(global, { + invoice: { + title, + text, + photo, + amount, + currency, + isTest, + isRecurring, + recurringTermsUrl, + maxTipAmount, + suggestedTipAmounts, }, - }; + }); } export function setStripeCardInfo(global: GlobalState, cardInfo: { type: string; id: string }): GlobalState { - return { - ...global, - payment: { - ...global.payment, - stripeCredentials: { - ...cardInfo, - }, - }, - }; + return updatePayment(global, { stripeCredentials: { ...cardInfo } }); } export function setSmartGlocalCardInfo( global: GlobalState, cardInfo: { type: string; token: string }, ): GlobalState { - return { - ...global, - payment: { - ...global.payment, - smartGlocalCredentials: { - ...cardInfo, - }, - }, - }; + return updatePayment(global, { smartGlocalCredentials: { ...cardInfo } }); } export function setPaymentForm(global: GlobalState, form: ApiPaymentForm): GlobalState { - return { - ...global, - payment: { - ...global.payment, - ...form, - }, - }; + return updatePayment(global, { ...form }); } export function setConfirmPaymentUrl(global: GlobalState, url?: string): GlobalState { - return { - ...global, - payment: { - ...global.payment, - confirmPaymentUrl: url, - }, - }; + return updatePayment(global, { confirmPaymentUrl: url }); } export function setReceipt( @@ -120,13 +84,7 @@ export function setReceipt( message?: ApiMessage, ): GlobalState { if (!receipt || !message) { - return { - ...global, - payment: { - ...global.payment, - receipt: undefined, - }, - }; + return updatePayment(global, { receipt: undefined }); } const { invoice: messageInvoice } = message.content; @@ -134,18 +92,14 @@ export function setReceipt( photo, text, title, } = (messageInvoice || {}); - return { - ...global, - payment: { - ...global.payment, - receipt: { - ...receipt, - photo, - text, - title, - }, + return updatePayment(global, { + receipt: { + ...receipt, + photo, + text, + title, }, - }; + }); } export function clearPayment(global: GlobalState): GlobalState { @@ -156,11 +110,5 @@ export function clearPayment(global: GlobalState): GlobalState { } export function closeInvoice(global: GlobalState): GlobalState { - return { - ...global, - payment: { - ...global.payment, - isPaymentModalOpen: false, - }, - }; + return updatePayment(global, { isPaymentModalOpen: undefined }); } diff --git a/src/global/types.ts b/src/global/types.ts index 92bba8f46..794bcd862 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -44,6 +44,8 @@ import type { ApiInvoice, ApiStickerSetInfo, ApiChatType, + ApiReceipt, + ApiPaymentCredentials, } from '../api/types'; import type { FocusDirection, @@ -56,8 +58,7 @@ import type { ManagementProgress, PaymentStep, ShippingOption, - Invoice, - Receipt, + ApiInvoiceContainer, ApiPrivacyKey, ApiPrivacySettings, ThemeKey, @@ -481,8 +482,8 @@ export type GlobalState = { requestId?: string; savedInfo?: ApiPaymentSavedInfo; canSaveCredentials?: boolean; - invoice?: Invoice; - invoiceContent?: Omit; + invoice?: ApiInvoice; + invoiceContainer?: Omit; nativeProvider?: string; providerId?: string; nativeParams?: ApiPaymentFormNativeParams; @@ -495,18 +496,19 @@ export type GlobalState = { token: string; }; passwordMissing?: boolean; - savedCredentials?: { - id: string; - title: string; - }; - receipt?: Receipt; + savedCredentials?: ApiPaymentCredentials[]; + receipt?: ApiReceipt; error?: { field?: string; message?: string; - description: string; + description?: string; }; isPaymentModalOpen?: boolean; confirmPaymentUrl?: string; + temporaryPassword?: { + value: string; + validUntil: number; + }; }; chatCreation?: { @@ -1107,6 +1109,15 @@ export interface ActionPayloads { url?: string; }; closeUrlAuthModal: never; + showNotification: { + localId?: string; + title?: string; + message: string; + className?: string; + actionText?: string; + action?: NoneToVoidFunction; + }; + dismissNotification: { localId: string }; // Calls requestCall: { @@ -1180,12 +1191,17 @@ export interface ActionPayloads { // Invoice openInvoice: ApiInputInvoice; + + // Payment + validatePaymentPassword: { + password: string; + }; } export type NonTypedActionNames = ( // system 'init' | 'reset' | 'disconnect' | 'initApi' | 'sync' | 'saveSession' | - 'showNotification' | 'dismissNotification' | 'showDialog' | 'dismissDialog' | + 'showDialog' | 'dismissDialog' | // ui 'toggleChatInfo' | 'setIsUiReady' | 'toggleLeftColumn' | 'toggleSafeLinkModal' | 'openHistoryCalendar' | 'closeHistoryCalendar' | 'disableContextMenuHint' | diff --git a/src/hooks/reducers/usePaymentReducer.ts b/src/hooks/reducers/usePaymentReducer.ts index 589d3e047..e18c255a6 100644 --- a/src/hooks/reducers/usePaymentReducer.ts +++ b/src/hooks/reducers/usePaymentReducer.ts @@ -21,13 +21,16 @@ export type FormState = { saveInfo: boolean; saveCredentials: boolean; formErrors: Record; + tipAmount: number; + savedCredentialId: string; }; export type FormActions = ( 'changeAddress1' | 'changeAddress2' | 'changeCity' | 'changeState' | 'changeCountry' | 'changePostCode' | 'changeFullName' | 'changeEmail' | 'changePhone' | 'changeShipping' | 'updateUserInfo' | 'changeCardNumber' | 'changeCardholder' | 'changeExpiryDate' | 'changeCvvCode' | 'changeBillingCountry' | - 'changeBillingZip' | 'changeSaveInfo' | 'changeSaveCredentials' | 'setFormErrors' | 'resetState' + 'changeBillingZip' | 'changeSaveInfo' | 'changeSaveCredentials' | 'setFormErrors' | 'resetState' | 'setTipAmount' | + 'changeSavedCredentialId' ); export type FormEditDispatch = Dispatch; @@ -51,6 +54,8 @@ const INITIAL_STATE: FormState = { saveInfo: true, saveCredentials: false, formErrors: {}, + tipAmount: 0, + savedCredentialId: '', }; const reducer: StateReducer = (state, action) => { @@ -214,6 +219,16 @@ const reducer: StateReducer = (state, action) => { ...action.payload, }, }; + case 'setTipAmount': + return { + ...state, + tipAmount: action.payload, + }; + case 'changeSavedCredentialId': + return { + ...state, + savedCredentialId: action.payload, + }; case 'resetState': return { ...INITIAL_STATE, diff --git a/src/lib/gramjs/client/2fa.ts b/src/lib/gramjs/client/2fa.ts index a20a4747e..6238b3858 100644 --- a/src/lib/gramjs/client/2fa.ts +++ b/src/lib/gramjs/client/2fa.ts @@ -1,4 +1,4 @@ -import TelegramClient from './TelegramClient'; +import type TelegramClient from './TelegramClient'; // eslint-disable-next-line import/no-named-default import { default as Api } from '../tl/api'; import { generateRandomBytes } from '../Helpers'; @@ -15,6 +15,8 @@ export interface TwoFaParams { onEmailCodeError?: (err: Error) => void; } +export type TmpPasswordResult = Api.account.TmpPassword | { error: string } | undefined; + /** * Changes the 2FA settings of the logged in user. 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; + } +} diff --git a/src/lib/gramjs/client/TelegramClient.d.ts b/src/lib/gramjs/client/TelegramClient.d.ts index 0e4fc42c7..81c52682e 100644 --- a/src/lib/gramjs/client/TelegramClient.d.ts +++ b/src/lib/gramjs/client/TelegramClient.d.ts @@ -3,7 +3,7 @@ import type { Api } from '..'; import type { BotAuthParams, UserAuthParams } from './auth'; import type { uploadFile, UploadFileParams } from './uploadFile'; import type { downloadFile, DownloadFileParams } from './downloadFile'; -import type { TwoFaParams, updateTwoFaSettings } from './2fa'; +import type { TwoFaParams, updateTwoFaSettings, TmpPasswordResult } from './2fa'; declare class TelegramClient { constructor(...args: any); @@ -18,6 +18,8 @@ declare class TelegramClient { async updateTwoFaSettings(Params: TwoFaParams): ReturnType; + async getTmpPassword(currentPassword: string, ttl?: number): Promise; + // Untyped methods. [prop: string]: any; } diff --git a/src/lib/gramjs/client/TelegramClient.js b/src/lib/gramjs/client/TelegramClient.js index 05868fbdf..53b835616 100644 --- a/src/lib/gramjs/client/TelegramClient.js +++ b/src/lib/gramjs/client/TelegramClient.js @@ -22,7 +22,7 @@ const { } = require('./auth'); const { downloadFile } = require('./downloadFile'); const { uploadFile } = require('./uploadFile'); -const { updateTwoFaSettings } = require('./2fa'); +const { updateTwoFaSettings, getTmpPassword } = require('./2fa'); const DEFAULT_DC_ID = 2; const WEBDOCUMENT_DC_ID = 4; @@ -927,6 +927,10 @@ class TelegramClient { return updateTwoFaSettings(this, params); } + getTmpPassword(currentPassword, ttl) { + return getTmpPassword(this, currentPassword, ttl); + } + // event region addEventHandler(callback, event) { this._eventBuilders.push([event, callback]); diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index 866bfc663..3fc7e8ccf 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -102,7 +102,8 @@ $color-message-reaction-own-hover: #b5e0a4; --color-primary-shade: #{color.mix($color-primary, $color-black, 92%)}; --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-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-darker: #{color.mix($color-green, $color-black, 84%)}; diff --git a/src/styles/themes.json b/src/styles/themes.json index 423fc7ab3..eeb967b34 100644 --- a/src/styles/themes.json +++ b/src/styles/themes.json @@ -1,6 +1,7 @@ { "--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-background": ["#FFFFFF", "#212121"], "--color-background-compact-menu": ["#FFFFFFBB", "#212121DD"], diff --git a/src/types/index.ts b/src/types/index.ts index 571e8b28d..790f073a5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,7 +3,7 @@ import type { ApiBotInlineMediaResult, ApiBotInlineResult, ApiBotInlineSwitchPm, ApiChatInviteImporter, ApiExportedInvite, - ApiLanguage, ApiMessage, ApiShippingAddress, ApiStickerSet, ApiWebDocument, + ApiLanguage, ApiMessage, ApiStickerSet, } from '../api/types'; export type TextPart = TeactNode; @@ -129,34 +129,17 @@ export interface Price { 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; - emailRequested?: boolean; - emailToProvider?: boolean; - flexible?: boolean; - nameRequested?: boolean; - phoneRequested?: boolean; - phoneToProvider?: boolean; 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 { @@ -346,10 +329,12 @@ export enum ProfileState { } export enum PaymentStep { + Checkout, + SavedPayments, + ConfirmPassword, + PaymentInfo, ShippingInfo, Shipping, - PaymentInfo, - Checkout, ConfirmPayment, } diff --git a/src/util/formatCurrency.ts b/src/util/formatCurrency.ts index 17a902308..7bc07c478 100644 --- a/src/util/formatCurrency.ts +++ b/src/util/formatCurrency.ts @@ -1,10 +1,26 @@ 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, { style: 'currency', currency, - }).format(totalPrice / 10 ** getCurrencyExp(currency)); + }).format(price); } function getCurrencyExp(currency: string) { diff --git a/src/util/stopEvent.ts b/src/util/stopEvent.ts index 475c55c03..65e03f0fb 100644 --- a/src/util/stopEvent.ts +++ b/src/util/stopEvent.ts @@ -1,6 +1,6 @@ import type React from '../lib/teact/teact'; -const stopEvent = (e: React.UIEvent | Event) => { +const stopEvent = (e: React.UIEvent | Event | React.FormEvent) => { e.stopPropagation(); e.preventDefault(); };