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;
return {
text,
title,
text,
photo: buildApiWebDocument(photo),
receiptMsgId,
amount: Number(totalAmount),

View File

@ -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 }));
}

View File

@ -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(),

View File

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

View File

@ -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,
};
}

View File

@ -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 });

View File

@ -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 {

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 {
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[];

View File

@ -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<OwnProps> = ({
hint,
placeholder = 'Password',
submitLabel = 'Next',
description,
shouldShowSubmit,
shouldResetValue,
shouldDisablePasswordManager = false,
@ -100,7 +103,7 @@ const PasswordForm: FC<OwnProps> = ({
}
if (canSubmit) {
onSubmit(password);
onSubmit!(password);
}
}
@ -117,7 +120,7 @@ const PasswordForm: FC<OwnProps> = ({
}
return (
<form action="" onSubmit={handleSubmit} autoComplete="off">
<form action="" onSubmit={onSubmit ? handleSubmit : stopEvent} autoComplete="off">
<div
className={buildClassName('input-group password-input', password && 'touched', error && 'error')}
dir={lang.isRtl ? 'rtl' : undefined}
@ -145,7 +148,8 @@ const PasswordForm: FC<OwnProps> = ({
<i className={isPasswordVisible ? 'icon-eye' : 'icon-eye-closed'} />
</div>
</div>
{(canSubmit || shouldShowSubmit) && (
{description && <p className="description">{description}</p>}
{onSubmit && (canSubmit || shouldShowSubmit) && (
<Button type="submit" ripple={!noRipple} isLoading={isLoading} disabled={!canSubmit}>
{submitLabel}
</Button>

View File

@ -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 {

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 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<OwnProps> = ({
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<OwnProps> = ({
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) {
const langString = lang('PaymentCheckoutAcceptRecurrent', chat?.title);
const langStringSplit = langString.split('*');
@ -125,27 +178,53 @@ const Checkout: FC<OwnProps> = ({
{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)
)}
</div>
<div className={styles.invoiceInfo}>
{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!)}
</div>
</div>
);
};
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 (
<div className={styles.checkoutInfoItem}>
<i className={buildClassName(icon, styles.checkoutInfoItemIcon)}> </i>
<div className={styles.checkoutInfoItemInfo}>
<div className={styles.checkoutInfoItemInfoTitle}>
{title}
</div>
<p className={styles.checkoutInfoItemInfoData}>
{data}
</p>
<ListItem
multiline={Boolean(title && label !== title)}
icon={icon}
inactive={!onClick}
onClick={onClick}
>
{customIcon && <i className={customIcon} />}
<div className={styles.checkoutInfoItemInfoTitle}>
{title || label}
</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;
}
}
.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}
/>
)}
{ canSaveCredentials && (
<Checkbox
label={lang('PaymentCardSavePaymentInformation')}
checked={state.saveCredentials}
tabIndex={0}
onChange={handleChangeSaveCredentials}
/>
) }
<Checkbox
label={lang('PaymentCardSavePaymentInformation')}
checked={canSaveCredentials ? state.saveCredentials : false}
tabIndex={0}
onChange={handleChangeSaveCredentials}
disabled={!canSaveCredentials}
/>
<p className="description">
{lang(canSaveCredentials ? 'Checkout.NewCard.SaveInfoHelp' : 'Checkout.2FA.Text')}
</p>
</form>
</div>
);

View File

@ -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;

View File

@ -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<GlobalState['payment'], (
'step' | 'shippingOptions' |
'savedInfo' | 'canSaveCredentials' | 'nativeProvider' | 'passwordMissing' | 'invoiceContent' |
'error'
'savedInfo' | 'canSaveCredentials' | 'nativeProvider' | 'passwordMissing' | 'invoice' | 'error'
)>;
const NETWORK_REQUEST_TIMEOUT_S = 3;
const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
isOpen,
onClose,
@ -71,16 +81,16 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
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<OwnProps & StateProps & GlobalStateProps> = ({
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<OwnProps & StateProps & GlobalStateProps> = ({
}
}, [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<OwnProps & StateProps & GlobalStateProps> = ({
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<OwnProps & StateProps & GlobalStateProps> = ({
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<OwnProps & StateProps & GlobalStateProps> = ({
function renderModalContent(currentStep: PaymentStep) {
switch (currentStep) {
case PaymentStep.ShippingInfo:
case PaymentStep.Checkout:
return (
<ShippingInfo
state={paymentState}
<Checkout
chat={chat}
prices={prices}
dispatch={paymentDispatch}
needAddress={Boolean(shippingAddressRequested)}
needEmail={Boolean(emailRequested || emailToProvider)}
needPhone={Boolean(phoneRequested || phoneToProvider)}
needName={Boolean(nameRequested)}
countryList={countryList}
shippingPrices={paymentState.shipping && shippingOptions
? getShippingPrices(shippingOptions, paymentState.shipping)
: undefined}
totalPrice={totalPrice}
invoice={invoice}
checkoutInfo={checkoutInfo}
currency={currency!}
hasShippingOptions={hasShippingOptions}
tipAmount={paymentState.tipAmount}
needAddress={Boolean(isShippingAddressRequested)}
savedCredentials={savedCredentials}
isTosAccepted={isTosAccepted}
onAcceptTos={setIsTosAccepted}
/>
);
case PaymentStep.Shipping:
case PaymentStep.SavedPayments:
return (
<Shipping
<SavedPaymentCredentials
state={paymentState}
savedCredentials={savedCredentials}
dispatch={paymentDispatch}
shippingOptions={shippingOptions || []}
currency={currency!}
onNewCardClick={handleNewCardClick}
/>
);
case PaymentStep.ConfirmPassword:
return (
<PasswordConfirm
state={paymentState}
savedCredentials={savedCredentials}
onPasswordChange={setTwoFaPassword}
isActive={currentStep === step}
/>
);
case PaymentStep.PaymentInfo:
@ -224,20 +289,25 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
countryList={countryList}
/>
);
case PaymentStep.Checkout:
case PaymentStep.ShippingInfo:
return (
<Checkout
chat={chat}
prices={prices}
shippingPrices={paymentState.shipping && shippingOptions
? getShippingPrices(shippingOptions, paymentState.shipping)
: undefined}
totalPrice={totalPrice}
invoiceContent={invoiceContent}
checkoutInfo={checkoutInfo}
<ShippingInfo
state={paymentState}
dispatch={paymentDispatch}
needAddress={Boolean(isShippingAddressRequested)}
needEmail={Boolean(isEmailRequested || shouldSendEmailToProvider)}
needPhone={Boolean(isPhoneRequested || shouldSendPhoneToProvider)}
needName={Boolean(isNameRequested)}
countryList={countryList}
/>
);
case PaymentStep.Shipping:
return (
<Shipping
state={paymentState}
dispatch={paymentDispatch}
shippingOptions={shippingOptions || []}
currency={currency!}
isTosAccepted={isTosAccepted}
onAcceptTos={setIsTosAccepted}
/>
);
case PaymentStep.ConfirmPayment:
@ -268,48 +338,126 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
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<OwnProps & StateProps & GlobalStateProps> = ({
: lang('Next');
const isSubmitDisabled = isLoading
|| Boolean(step === PaymentStep.Checkout && invoiceContent?.isRecurring && !isTosAccepted);
|| Boolean(step === PaymentStep.Checkout && invoice?.isRecurring && !isTosAccepted);
if (isProviderError) {
return (
<Modal
className="error"
isOpen={isOpen}
onClose={onClose}
isOpen={isModalOpen}
onClose={closeModal}
onCloseAnimationEnd={handleModalClose}
>
<p>
Sorry, Telegram WebZ doesn&apos;t support payments with this provider yet. <br />
@ -337,7 +486,7 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
</p>
<Button
isText
onClick={onClose}
onClick={closeModal}
>
{lang('OK')}
</Button>
@ -347,9 +496,9 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
return (
<Modal
className={buildClassName('PaymentModal', invoiceContent?.isRecurring && 'recurring')}
isOpen={isOpen}
onClose={onClose}
className={buildClassName('PaymentModal', invoice?.isRecurring && 'recurring')}
isOpen={isModalOpen}
onClose={closeModal}
onCloseAnimationEnd={handleModalClose}
>
<div className="header" dir={lang.isRtl ? 'rtl' : undefined}>
@ -358,10 +507,10 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
color="translucent"
round
size="smaller"
onClick={onClose}
onClick={step === PaymentStep.Checkout ? closeModal : handleBackClick}
ariaLabel="Close"
>
<i className="icon-close" />
<i className={step === PaymentStep.Checkout ? 'icon-close' : 'icon-arrow-left'} />
</Button>
<h3>{modalHeader}</h3>
</div>
@ -401,29 +550,33 @@ export default memo(withGlobal<OwnProps>(
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<OwnProps>(
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;

View File

@ -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<OwnProps & StateProps> = ({
onClose,
prices,
shippingPrices,
tipAmount,
totalAmount,
currency,
info,
@ -50,15 +54,35 @@ const ReceiptModal: FC<OwnProps & StateProps> = ({
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 (
<Modal
className="PaymentModal PaymentModal-receipt"
isOpen={isOpen}
onClose={onClose}
isOpen={isModalOpen}
onClose={closeModal}
onCloseAnimationEnd={onClose}
>
<div>
<div className="header" dir={lang.isRtl ? 'rtl' : undefined}>
@ -67,7 +91,7 @@ const ReceiptModal: FC<OwnProps & StateProps> = ({
color="translucent"
round
size="smaller"
onClick={onClose}
onClick={closeModal}
ariaLabel="Close"
>
<i className="icon-close" />
@ -79,11 +103,8 @@ const ReceiptModal: FC<OwnProps & StateProps> = ({
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<OwnProps>(
photo,
text,
title,
tipAmount,
} = (receipt || {});
return {
currency,
prices,
info,
tipAmount,
totalAmount,
credentialsTitle,
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();
useEffect(() => {
if (!shippingOptions || state.shipping) {
if (!shippingOptions || !shippingOptions.length || state.shipping) {
return;
}
dispatch({ type: 'changeShipping', payload: shippingOptions[0].id });

View File

@ -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';

View File

@ -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<ApiInvoice | undefined> {
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);
}

View File

@ -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) {

View File

@ -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,

View File

@ -4,37 +4,29 @@ import type {
ApiInvoice, ApiMessage, ApiPaymentForm, ApiReceipt,
} from '../../api/types';
export function updatePayment(global: GlobalState, update: Partial<GlobalState['payment']>): 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 });
}

View File

@ -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<ApiInvoice, 'receiptMsgId'>;
invoice?: ApiInvoice;
invoiceContainer?: Omit<ApiInvoiceContainer, 'receiptMsgId'>;
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' |

View File

@ -21,13 +21,16 @@ export type FormState = {
saveInfo: boolean;
saveCredentials: boolean;
formErrors: Record<string, string>;
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<FormActions>;
@ -51,6 +54,8 @@ const INITIAL_STATE: FormState = {
saveInfo: true,
saveCredentials: false,
formErrors: {},
tipAmount: 0,
savedCredentialId: '',
};
const reducer: StateReducer<FormState, FormActions> = (state, action) => {
@ -214,6 +219,16 @@ const reducer: StateReducer<FormState, FormActions> = (state, action) => {
...action.payload,
},
};
case 'setTipAmount':
return {
...state,
tipAmount: action.payload,
};
case 'changeSavedCredentialId':
return {
...state,
savedCredentialId: action.payload,
};
case 'resetState':
return {
...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
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;
}
}

View File

@ -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<typeof updateTwoFaSettings>;
async getTmpPassword(currentPassword: string, ttl?: number): Promise<TmpPasswordResult>;
// Untyped methods.
[prop: string]: any;
}

View File

@ -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]);

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-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%)};

View File

@ -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"],

View File

@ -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,
}

View File

@ -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) {

View File

@ -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();
};