Stars: Format currency with icon (#4678)

This commit is contained in:
zubiden 2024-06-18 16:30:47 +02:00 committed by Alexander Zinchuk
parent 41cc393a3c
commit 6f79159ec3
22 changed files with 132 additions and 46 deletions

View File

@ -698,7 +698,6 @@ function buildReplyButtons(message: UniversalMessage, shouldSkipBuyButton?: bool
if (media instanceof GramJs.MessageMediaInvoice && media.receiptMsgId) {
return {
type: 'receipt',
text: 'PaymentReceipt',
receiptMessageId: media.receiptMsgId,
};
}

View File

@ -726,7 +726,6 @@ interface ApiKeyboardButtonSimple {
interface ApiKeyboardButtonReceipt {
type: 'receipt';
text: string;
receiptMessageId: number;
}

View File

@ -14,7 +14,7 @@ import {
isExpiredMessage,
} from '../../../global/helpers';
import { getMessageSummaryText } from '../../../global/helpers/messageSummary';
import { formatCurrency } from '../../../util/formatCurrency';
import { formatCurrencyAsString } from '../../../util/formatCurrency';
import trimText from '../../../util/trimText';
import renderText from './renderText';
@ -85,7 +85,7 @@ export function renderActionMessageText(
processed = processPlaceholder(
unprocessed,
'%payment_amount%',
formatCurrency(amount!, currency!, lang.code),
formatCurrencyAsString(amount!, currency!, lang.code),
);
unprocessed = processed.pop() as string;
content.push(...processed);
@ -134,11 +134,11 @@ export function renderActionMessageText(
}
if (unprocessed.includes('%gift_payment_amount%')) {
const price = formatCurrency(amount!, currency!, lang.code);
const price = formatCurrencyAsString(amount!, currency!, lang.code);
let priceText = price;
if (giftCryptoInfo) {
const cryptoPrice = formatCurrency(giftCryptoInfo.amount, giftCryptoInfo.currency, lang.code);
const cryptoPrice = formatCurrencyAsString(giftCryptoInfo.amount, giftCryptoInfo.currency, lang.code);
priceText = `${cryptoPrice} (${price})`;
}

View File

@ -17,6 +17,14 @@
height: 1.5rem;
}
.adaptive {
display: inline-block;
width: 1em;
height: 1em;
line-height: 1;
vertical-align: text-top;
}
.svg {
width: 100%;
height: 100%;

View File

@ -9,7 +9,7 @@ import styles from './StarIcon.module.scss';
type OwnProps = {
type?: 'gold' | 'premium' | 'regular';
size?: 'small' | 'middle' | 'big';
size?: 'small' | 'middle' | 'big' | 'adaptive';
className?: string;
onClick?: VoidFunction;
};

View File

@ -12,6 +12,7 @@ import buildClassName from '../../util/buildClassName';
import { IS_TOUCH_ENV } from '../../util/windowEnvironment';
import { getPictogramDimensions, REM } from '../common/helpers/mediaDimensions';
import renderText from '../common/helpers/renderText';
import renderKeyboardButtonText from './composer/helpers/renderKeyboardButtonText';
import { useFastClick } from '../../hooks/useFastClick';
import useFlag from '../../hooks/useFlag';
@ -198,7 +199,7 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
onMouseEnter={!IS_TOUCH_ENV ? markNoHoverColor : undefined}
onMouseLeave={!IS_TOUCH_ENV ? unmarkNoHoverColor : undefined}
>
{renderText(inlineButton.text)}
{renderKeyboardButtonText(lang, inlineButton)}
</Button>
)}
</div>

View File

@ -1,13 +1,15 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo } from '../../../lib/teact/teact';
import type { FC, TeactNode } from '../../../lib/teact/teact';
import React, { memo, useMemo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiMessage } from '../../../api/types';
import { selectChatMessage, selectCurrentMessageList } from '../../../global/selectors';
import { IS_TOUCH_ENV } from '../../../util/windowEnvironment';
import renderKeyboardButtonText from './helpers/renderKeyboardButtonText';
import useMouseInside from '../../../hooks/useMouseInside';
import useOldLang from '../../../hooks/useOldLang';
import Button from '../../ui/Button';
import Menu from '../../ui/Menu';
@ -29,9 +31,20 @@ const BotKeyboardMenu: FC<OwnProps & StateProps> = ({
}) => {
const { clickBotInlineButton } = getActions();
const lang = useOldLang();
const [handleMouseEnter, handleMouseLeave] = useMouseInside(isOpen, onClose);
const { isKeyboardSingleUse } = message || {};
const buttonTexts = useMemo(() => {
const texts: TeactNode[][] = [];
message?.keyboardButtons!.forEach((row) => {
texts.push(row.map((button) => renderKeyboardButtonText(lang, button)));
});
return texts;
}, [lang, message?.keyboardButtons]);
if (!message || !message.keyboardButtons) {
return undefined;
}
@ -50,16 +63,16 @@ const BotKeyboardMenu: FC<OwnProps & StateProps> = ({
noCompact
>
<div className="content custom-scroll">
{message.keyboardButtons.map((row) => (
{message.keyboardButtons.map((row, i) => (
<div className="row">
{row.map((button) => (
{row.map((button, j) => (
<Button
ripple
disabled={button.type === 'unsupported'}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => clickBotInlineButton({ messageId: message.id, button })}
>
{button.text}
{buttonTexts?.[i][j]}
</Button>
))}
</div>

View File

@ -0,0 +1,22 @@
import React, { type TeactNode } from '../../../../lib/teact/teact';
import type { ApiKeyboardButton } from '../../../../api/types';
import { replaceWithTeact } from '../../../../util/replaceWithTeact';
import renderText from '../../../common/helpers/renderText';
import { type LangFn } from '../../../../hooks/useOldLang';
import Icon from '../../../common/icons/Icon';
export default function renderKeyboardButtonText(lang: LangFn, button: ApiKeyboardButton): TeactNode {
if (button.type === 'receipt') {
return lang('PaymentReceipt');
}
if (button.type === 'buy') {
return replaceWithTeact(button.text, '⭐', <Icon className="star-currency-icon" name="star" />);
}
return renderText(button.text);
}

View File

@ -37,7 +37,7 @@
margin-right: 0;
}
.icon {
.corner-icon {
font-size: 0.875rem;
position: absolute;
right: 0.1875rem;

View File

@ -1,13 +1,14 @@
import type { FC } from '../../../lib/teact/teact';
import React from '../../../lib/teact/teact';
import type { FC, TeactNode } from '../../../lib/teact/teact';
import React, { memo, useMemo } from '../../../lib/teact/teact';
import type { ApiKeyboardButton, ApiMessage } from '../../../api/types';
import { RE_TME_LINK } from '../../../config';
import renderText from '../../common/helpers/renderText';
import renderKeyboardButtonText from '../composer/helpers/renderKeyboardButtonText';
import useOldLang from '../../../hooks/useOldLang';
import Icon from '../../common/icons/Icon';
import Button from '../../ui/Button';
import './InlineButtons.scss';
@ -25,29 +26,37 @@ const InlineButtons: FC<OwnProps> = ({ message, onClick }) => {
switch (type) {
case 'url': {
if (!RE_TME_LINK.test(button.url)) {
return <i className="icon icon-arrow-right" />;
return <Icon className="corner-icon" name="arrow-right" />;
}
break;
}
case 'urlAuth':
return <i className="icon icon-arrow-right" />;
return <Icon className="corner-icon" name="arrow-right" />;
case 'buy':
case 'receipt':
return <i className="icon icon-cart" />;
return <Icon className="corner-icon" name="card" />;
case 'switchBotInline':
return <i className="icon icon-share-filled" />;
return <Icon className="corner-icon" name="share-filled" />;
case 'webView':
case 'simpleWebView':
return <i className="icon icon-webapp" />;
return <Icon className="corner-icon" name="webapp" />;
}
return undefined;
};
const buttonTexts = useMemo(() => {
const texts: TeactNode[][] = [];
message.inlineButtons!.forEach((row) => {
texts.push(row.map((button) => renderKeyboardButtonText(lang, button)));
});
return texts;
}, [lang, message.inlineButtons]);
return (
<div className="InlineButtons">
{message.inlineButtons!.map((row) => (
{message.inlineButtons!.map((row, i) => (
<div className="row">
{row.map((button) => (
{row.map((button, j) => (
<Button
size="tiny"
ripple
@ -55,7 +64,9 @@ const InlineButtons: FC<OwnProps> = ({ message, onClick }) => {
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onClick({ messageId: message.id, button })}
>
<span className="inline-button-text">{renderText(lang(button.text))}</span>
<span className="inline-button-text">
{buttonTexts[i][j]}
</span>
{renderIcon(button)}
</Button>
))}
@ -65,4 +76,4 @@ const InlineButtons: FC<OwnProps> = ({ message, onClick }) => {
);
};
export default InlineButtons;
export default memo(InlineButtons);

View File

@ -48,6 +48,11 @@
.test-invoice {
margin-left: 0.5rem;
}
.invoice-currency-icon {
margin-inline-end: 0.125rem;
line-height: 1.5;
}
}
.invoice-image-container {

View File

@ -119,7 +119,7 @@ const Invoice: FC<OwnProps> = ({
</div>
)}
<p className="description-text">
{formatCurrency(amount, currency, lang.code)}
{formatCurrency(amount, currency, lang.code, { iconClassName: 'invoice-currency-icon' })}
{isTest && <span className="test-invoice">{lang('PaymentTestInvoice')}</span>}
</p>
</div>

View File

@ -7,7 +7,7 @@ import type { ApiMessage } from '../../../api/types';
import { getMessageInvoice } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import { formatMediaDuration } from '../../../util/dates/dateFormat';
import { formatCurrency } from '../../../util/formatCurrency';
import { formatCurrencyAsString } from '../../../util/formatCurrency';
import useInterval from '../../../hooks/schedulers/useInterval';
import useLastCallback from '../../../hooks/useLastCallback';
@ -74,7 +74,7 @@ const InvoiceMediaPreview: FC<OwnProps> = ({
{Boolean(duration) && <div className={styles.duration}>{formatMediaDuration(duration)}</div>}
<div className={styles.buy}>
<i className={buildClassName('icon', 'icon-lock', styles.lock)} />
{lang('Checkout.PayPrice', formatCurrency(amount, currency))}
{lang('Checkout.PayPrice', formatCurrencyAsString(amount, currency))}
</div>
</div>
);

View File

@ -10,7 +10,7 @@ import type { TabState } from '../../../global/types';
import { copyTextToClipboard } from '../../../util/clipboard';
import { formatDateAtTime } from '../../../util/dates/dateFormat';
import { formatCurrency } from '../../../util/formatCurrency';
import { formatCurrencyAsString } from '../../../util/formatCurrency';
import { formatPhoneNumberWithCode } from '../../../util/phoneNumber';
import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets';
import formatUsername from '../../common/helpers/formatUsername';
@ -91,8 +91,8 @@ const CollectibleInfoModal: FC<OwnProps & StateProps> = ({
if (!modal) return undefined;
const key = isUsername ? 'FragmentUsernameMessage' : 'FragmentPhoneMessage';
const date = formatDateAtTime(lang, modal.purchaseDate * 1000);
const currency = formatCurrency(modal.amount, modal.currency, lang.code);
const cryptoCurrency = formatCurrency(modal.cryptoAmount, modal.cryptoCurrency, lang.code);
const currency = formatCurrencyAsString(modal.amount, modal.currency, lang.code);
const cryptoCurrency = formatCurrencyAsString(modal.cryptoAmount, modal.cryptoCurrency, lang.code);
const paid = `${cryptoCurrency} (${currency})`;
return lang(key, [date, paid]);
}, [modal, isUsername, lang]);

View File

@ -121,7 +121,7 @@ const Checkout: FC<OwnProps> = ({
className={buildClassName(styles.tipsItem, tip === tipAmount && styles.tipsItem_active)}
onClick={dispatch ? () => handleTipsClick(tip === tipAmount ? 0 : tip) : undefined}
>
{formatCurrency(tip, currency, lang.code, true)}
{formatCurrency(tip, currency, lang.code, { shouldOmitFractions: true })}
</div>
))}
</div>
@ -199,7 +199,7 @@ const Checkout: FC<OwnProps> = ({
label: lang('PaymentCheckoutProvider'),
customIcon: buildClassName(styles.provider, styles[paymentProvider.toLowerCase()]),
})}
{(needAddress || !isInteractive) && renderCheckoutItem({
{(needAddress || (!isInteractive && shippingAddress)) && renderCheckoutItem({
title: shippingAddress,
label: lang('PaymentShippingAddress'),
icon: 'location',
@ -215,7 +215,7 @@ const Checkout: FC<OwnProps> = ({
label: lang('PaymentCheckoutPhoneNumber'),
icon: 'phone',
})}
{(hasShippingOptions || !isInteractive) && renderCheckoutItem({
{(hasShippingOptions || (!isInteractive && shippingMethod)) && renderCheckoutItem({
title: shippingMethod,
label: lang('PaymentCheckoutShippingMethod'),
icon: 'truck',

View File

@ -15,7 +15,7 @@ import { getUserFullName } from '../../global/helpers';
import { selectChat, selectTabState, selectUser } from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import captureKeyboardListeners from '../../util/captureKeyboardListeners';
import { formatCurrency } from '../../util/formatCurrency';
import { formatCurrencyAsString } from '../../util/formatCurrency';
import { detectCardTypeText } from '../common/helpers/detectCardType';
import usePaymentReducer from '../../hooks/reducers/usePaymentReducer';
@ -517,7 +517,7 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
}, [step, lang]);
const buttonText = step === PaymentStep.Checkout
? lang('Checkout.PayPrice', formatCurrency(totalPrice, currency!, lang.code))
? lang('Checkout.PayPrice', formatCurrencyAsString(totalPrice, currency!, lang.code))
: lang('Next');
function getIsSubmitDisabled() {

View File

@ -14,7 +14,7 @@ type OwnProps = {
id?: string;
name: string;
label: TeactNode;
subLabel?: string;
subLabel?: TeactNode;
value: string;
checked: boolean;
disabled?: boolean;

View File

@ -8,7 +8,7 @@ import Radio from './Radio';
export type IRadioOption<T = string> = {
label: TeactNode;
subLabel?: string;
subLabel?: TeactNode;
value: T;
hidden?: boolean;
className?: string;
@ -25,7 +25,7 @@ type OwnProps = {
onClickAction?: (value: string) => void;
isLink?: boolean;
subLabelClassName?: string;
subLabel?: string | undefined;
subLabel?: TeactNode;
};
const RadioGroup: FC<OwnProps> = ({

View File

@ -1,7 +1,7 @@
import type { ActionReturnType } from '../../types';
import { areDeepEqual } from '../../../util/areDeepEqual';
import { formatCurrency } from '../../../util/formatCurrency';
import { formatCurrencyAsString } from '../../../util/formatCurrency';
import * as langProvider from '../../../util/oldLangProvider';
import { addActionHandler, setGlobal } from '../../index';
import { closeInvoice, updateStarsBalance } from '../../reducers';
@ -22,7 +22,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
actions.showNotification({
tabId,
message: langProvider.oldTranslate('PaymentInfoHint', [
formatCurrency(amount, currency, langProvider.getTranslationFn().code),
formatCurrencyAsString(amount, currency, langProvider.getTranslationFn().code),
title,
]),
});

View File

@ -1,6 +1,9 @@
import * as langProvider from '../util/oldLangProvider';
import useAsync from './useAsync';
/**
* @deprecated Migrate to `useLang`, while using needed key inside initial fallback
*/
const useOldLangString = (
langCode: string | undefined,
key: string,

View File

@ -284,6 +284,11 @@ body:not(.is-ios) {
left: 0;
}
.star-currency-icon {
font-size: 1rem !important;
vertical-align: text-top;
}
.shared-canvas-container {
position: relative;
}

View File

@ -1,20 +1,40 @@
import React, { type TeactNode } from '../lib/teact/teact';
import type { LangCode } from '../types';
import StarIcon from '../components/common/icons/StarIcon';
const STARS_CODE = 'XTR';
export function formatCurrency(
totalPrice: number,
currency: string,
locale: LangCode = 'en',
shouldOmitFractions = false,
) {
options?: {
shouldOmitFractions?: boolean;
iconClassName?: string;
},
): TeactNode {
const price = totalPrice / 10 ** getCurrencyExp(currency);
if (currency === STARS_CODE) {
return `⭐️${price}`;
return [<StarIcon className={options?.iconClassName} type="gold" size="adaptive" />, price];
}
if (shouldOmitFractions && price % 1 === 0) {
return formatCurrencyAsString(totalPrice, currency, locale, options);
}
export function formatCurrencyAsString(
totalPrice: number,
currency: string,
locale: LangCode = 'en',
options?: {
shouldOmitFractions?: boolean;
},
) {
const price = totalPrice / 10 ** getCurrencyExp(currency);
if (options?.shouldOmitFractions && price % 1 === 0) {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,