From 34a34483bfacac3fe8408f43e2fdfeb1aa9a0915 Mon Sep 17 00:00:00 2001 From: Djordje Vlaisavljevic Date: Wed, 18 Dec 2024 17:09:38 +0000 Subject: [PATCH 1/2] WIP --- .../src/global/form/CurrencyField.tsx | 33 +++++++++++++++---- .../settings/growth/offers/OfferSuccess.tsx | 5 ++- .../settings/growth/offers/OffersIndex.tsx | 11 +++---- .../membership/tiers/TierDetailPreview.tsx | 5 ++- .../settings/membership/tiers/TiersList.tsx | 5 ++- apps/admin-x-settings/src/utils/currency.ts | 18 ++++++++++ .../src/components/common/ProductsSection.js | 6 ++-- apps/portal/src/components/pages/OfferPage.js | 8 ++--- apps/portal/src/utils/helpers.js | 29 ++++++++++++++++ 9 files changed, 90 insertions(+), 30 deletions(-) diff --git a/apps/admin-x-design-system/src/global/form/CurrencyField.tsx b/apps/admin-x-design-system/src/global/form/CurrencyField.tsx index a146ae0876d..5abd1da08de 100644 --- a/apps/admin-x-design-system/src/global/form/CurrencyField.tsx +++ b/apps/admin-x-design-system/src/global/form/CurrencyField.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, {useState, useEffect} from 'react'; import TextField, {TextFieldProps} from './TextField'; export type CurrencyFieldProps = Omit & { @@ -15,13 +15,22 @@ export type CurrencyFieldProps = Omit = ({ - valueInCents, + valueInCents = '', onChange, ...props }) => { - const [localValue, setLocalValue] = useState(valueInCents === '' ? '' : ((valueInCents || 0) / 100).toString()); + // Format the initial value using the same logic as onBlur + const formatValue = (cents: number | '') => { + if (cents === '' || cents === undefined) { + return ''; + } + const value = cents / 100; + return value % 1 === 0 ? value.toString() : value.toFixed(2); + }; + + const [localValue, setLocalValue] = useState(formatValue(valueInCents)); - // While the user is editing we allow more lenient input, e.g. "1.32.566" to make it easier to type and change + // While the user is editing we allow more lenient input const stripNonNumeric = (input: string) => input.replace(/[^\d.]+/g, ''); // The saved value is strictly a number with 2 decimal places @@ -29,16 +38,26 @@ const CurrencyField: React.FC = ({ return Math.round(parseFloat(input.match(/[\d]+\.?[\d]{0,2}/)?.[0] || '0') * 100); }; + // Update localValue when valueInCents prop changes + useEffect(() => { + setLocalValue(formatValue(valueInCents)); + }, [valueInCents]); + return { - setLocalValue((forceCurrencyValue(e.target.value) / 100).toString()); + const value = forceCurrencyValue(e.target.value) / 100; + setLocalValue(value % 1 === 0 ? value.toString() : value.toFixed(2)); + // Only convert to cents on blur + onChange?.(forceCurrencyValue(e.target.value)); props.onBlur?.(e); }} onChange={(e) => { - setLocalValue(stripNonNumeric(e.target.value)); - onChange?.(forceCurrencyValue(e.target.value)); + const stripped = stripNonNumeric(e.target.value); + setLocalValue(stripped); + // Don't convert to cents while typing + // onChange?.(forceCurrencyValue(stripped)); }} />; }; diff --git a/apps/admin-x-settings/src/components/settings/growth/offers/OfferSuccess.tsx b/apps/admin-x-settings/src/components/settings/growth/offers/OfferSuccess.tsx index 507a96dd246..706d5ada4ac 100644 --- a/apps/admin-x-settings/src/components/settings/growth/offers/OfferSuccess.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/offers/OfferSuccess.tsx @@ -4,9 +4,8 @@ import {Icon} from '@tryghost/admin-x-design-system'; import {Modal} from '@tryghost/admin-x-design-system'; import {Offer, useBrowseOffersById} from '@tryghost/admin-x-framework/api/offers'; import {TextField} from '@tryghost/admin-x-design-system'; -import {currencyToDecimal} from '../../../../utils/currency'; +import {currencyToDecimal, formatMonetaryAmount} from '../../../../utils/currency'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; -import {numberWithCommas} from '../../../../utils/helpers'; import {useEffect, useState} from 'react'; import {useGlobalData} from '../../../providers/GlobalDataProvider'; import {useRouting} from '@tryghost/admin-x-framework/routing'; @@ -39,7 +38,7 @@ const OfferSuccess: React.FC<{id: string}> = ({id}) => { discount = offer?.amount + '% discount'; break; case 'fixed': - discount = numberWithCommas(currencyToDecimal(offer?.amount)) + ' ' + offer?.currency + ' discount'; + discount = formatMonetaryAmount(currencyToDecimal(offer?.amount)) + ' ' + offer?.currency + ' discount'; break; case 'trial': discount = offer?.amount + ' days free trial'; diff --git a/apps/admin-x-settings/src/components/settings/growth/offers/OffersIndex.tsx b/apps/admin-x-settings/src/components/settings/growth/offers/OffersIndex.tsx index 1fa015391eb..dc59321f11b 100644 --- a/apps/admin-x-settings/src/components/settings/growth/offers/OffersIndex.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/offers/OffersIndex.tsx @@ -5,9 +5,8 @@ import {Modal} from '@tryghost/admin-x-design-system'; import {SortMenu} from '@tryghost/admin-x-design-system'; import {Tier, getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; import {Tooltip} from '@tryghost/admin-x-design-system'; -import {currencyToDecimal, getSymbol} from '../../../../utils/currency'; +import {currencyToDecimal, formatMonetaryAmount, getSymbol} from '../../../../utils/currency'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; -import {numberWithCommas} from '../../../../utils/helpers'; import {useBrowseOffers} from '@tryghost/admin-x-framework/api/offers'; import {useGlobalData} from '../../../providers/GlobalDataProvider'; import {useModal} from '@ebay/nice-modal-react'; @@ -37,9 +36,7 @@ export const getOfferDiscount = (type: string, amount: number, cadence: string, const originalPrice = cadence === 'month' ? tier?.monthly_price ?? 0 : tier?.yearly_price ?? 0; let updatedPrice = originalPrice; - const formatToTwoDecimals = (num: number): number => parseFloat(num.toFixed(2)); - - let originalPriceWithCurrency = getSymbol(currency) + numberWithCommas(formatToTwoDecimals(currencyToDecimal(originalPrice))); + let originalPriceWithCurrency = getSymbol(currency) + formatMonetaryAmount(currencyToDecimal(originalPrice)); switch (type) { case 'percent': @@ -49,7 +46,7 @@ export const getOfferDiscount = (type: string, amount: number, cadence: string, break; case 'fixed': discountColor = 'text-blue'; - discountOffer = numberWithCommas(formatToTwoDecimals(currencyToDecimal(amount))) + ' ' + currency + ' off'; + discountOffer = formatMonetaryAmount(currencyToDecimal(amount)) + ' ' + currency + ' off'; updatedPrice = originalPrice - amount; break; case 'trial': @@ -65,7 +62,7 @@ export const getOfferDiscount = (type: string, amount: number, cadence: string, updatedPrice = 0; } - const updatedPriceWithCurrency = getSymbol(currency) + numberWithCommas(formatToTwoDecimals(currencyToDecimal(updatedPrice))); + const updatedPriceWithCurrency = getSymbol(currency) + formatMonetaryAmount(currencyToDecimal(updatedPrice)); return { discountColor, diff --git a/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailPreview.tsx b/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailPreview.tsx index ced9678a6d0..8728aec1706 100644 --- a/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailPreview.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailPreview.tsx @@ -2,8 +2,7 @@ import React, {useState} from 'react'; import clsx from 'clsx'; import {Button, Heading, Icon} from '@tryghost/admin-x-design-system'; import {TierFormState} from './TierDetailModal'; -import {currencyToDecimal, getSymbol} from '../../../../utils/currency'; -import {numberWithCommas} from '../../../../utils/helpers'; +import {currencyToDecimal, formatMonetaryAmount, getSymbol} from '../../../../utils/currency'; interface TierDetailPreviewProps { tier: TierFormState; @@ -100,7 +99,7 @@ const TierDetailPreview: React.FC = ({tier, isFreeTier})
{currencySymbol} - {showingYearly ? numberWithCommas(yearlyPrice) : numberWithCommas(monthlyPrice)} + {showingYearly ? formatMonetaryAmount(yearlyPrice) : formatMonetaryAmount(monthlyPrice)} {!isFreeTier && /{showingYearly ? 'year' : 'month'}}
diff --git a/apps/admin-x-settings/src/components/settings/membership/tiers/TiersList.tsx b/apps/admin-x-settings/src/components/settings/membership/tiers/TiersList.tsx index 3aa1b36a08c..8d45b3a1ffe 100644 --- a/apps/admin-x-settings/src/components/settings/membership/tiers/TiersList.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/tiers/TiersList.tsx @@ -3,8 +3,7 @@ import clsx from 'clsx'; import {Icon, NoValueLabel} from '@tryghost/admin-x-design-system'; import {Tier} from '@tryghost/admin-x-framework/api/tiers'; import {TrialDaysLabel} from './TierDetailPreview'; -import {currencyToDecimal, getSymbol} from '../../../../utils/currency'; -import {numberWithCommas} from '../../../../utils/helpers'; +import {currencyToDecimal, formatMonetaryAmount, getSymbol} from '../../../../utils/currency'; import {useRouting} from '@tryghost/admin-x-framework/routing'; interface TiersListProps { @@ -33,7 +32,7 @@ const TierCard: React.FC = ({tier}) => {
{tier.name}
{currencySymbol} - {numberWithCommas(currencyToDecimal(tier.monthly_price || 0))} + {formatMonetaryAmount(currencyToDecimal(tier.monthly_price || 0))} {(tier.monthly_price && tier.monthly_price > 0) && /month}
{tier.trial_days ? diff --git a/apps/admin-x-settings/src/utils/currency.ts b/apps/admin-x-settings/src/utils/currency.ts index be1d5da5669..f0083795b3a 100644 --- a/apps/admin-x-settings/src/utils/currency.ts +++ b/apps/admin-x-settings/src/utils/currency.ts @@ -227,3 +227,21 @@ export function validateCurrencyAmount( return `Suggested amount cannot be more than ${symbol}${maxAmount}.`; } } + +/** + * Formats a decimal amount for monetary display: + * - Maintains 2 decimal places only when needed (1.3 -> "1.30", 1 -> "1") + * - Adds thousand separators (1000.5 -> "1,000.50") + * @param amount The decimal amount to format + * @returns Formatted string (e.g., "1,000.30", "1,000", "1.55") + */ +export function formatMonetaryAmount(amount: number): string { + const formatted = amount % 1 === 0 ? + amount.toString() : + amount.toFixed(2); + + const [whole, decimal] = formatted.split('.'); + const withCommas = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + + return decimal ? `${withCommas}.${decimal}` : withCommas; +} diff --git a/apps/portal/src/components/common/ProductsSection.js b/apps/portal/src/components/common/ProductsSection.js index 9bc2db6fba9..ed91e3f266e 100644 --- a/apps/portal/src/components/common/ProductsSection.js +++ b/apps/portal/src/components/common/ProductsSection.js @@ -1,7 +1,7 @@ import React, {useContext, useEffect, useState} from 'react'; import {ReactComponent as LoaderIcon} from '../../images/icons/loader.svg'; import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark.svg'; -import {getCurrencySymbol, getPriceString, getStripeAmount, getMemberActivePrice, getProductFromPrice, getFreeTierTitle, getFreeTierDescription, getFreeProduct, getFreeProductBenefits, getSupportAddress, formatNumber, isCookiesDisabled, hasOnlyFreeProduct, isMemberActivePrice, hasFreeTrialTier, isComplimentaryMember} from '../../utils/helpers'; +import {getCurrencySymbol, getPriceString, getStripeAmount, getMemberActivePrice, getProductFromPrice, getFreeTierTitle, getFreeTierDescription, getFreeProduct, formatMonetaryAmount, getFreeProductBenefits, getSupportAddress, formatNumber, isCookiesDisabled, hasOnlyFreeProduct, isMemberActivePrice, hasFreeTrialTier, isComplimentaryMember} from '../../utils/helpers'; import AppContext from '../../AppContext'; import calculateDiscount from '../../utils/discount'; import Interpolate from '@doist/react-interpolate'; @@ -625,7 +625,7 @@ function ProductCardPrice({product}) {
1 ? ' long' : '')}>{currencySymbol} - {formatNumber(getStripeAmount(activePrice.amount))} + {formatMonetaryAmount(getStripeAmount(activePrice.amount))} /{interval}
@@ -643,7 +643,7 @@ function ProductCardPrice({product}) {
1 ? ' long' : '')}>{currencySymbol} - {formatNumber(getStripeAmount(activePrice.amount))} + {formatMonetaryAmount(getStripeAmount(activePrice.amount))} /{interval}
{(selectedInterval === 'year' ? : '')} diff --git a/apps/portal/src/components/pages/OfferPage.js b/apps/portal/src/components/pages/OfferPage.js index 622e318ea39..4783bf1271d 100644 --- a/apps/portal/src/components/pages/OfferPage.js +++ b/apps/portal/src/components/pages/OfferPage.js @@ -4,7 +4,7 @@ import AppContext from '../../AppContext'; import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark.svg'; import CloseButton from '../common/CloseButton'; import InputForm from '../common/InputForm'; -import {getCurrencySymbol, getProductFromId, hasMultipleProductsFeature, isSameCurrency, formatNumber, hasMultipleNewsletters} from '../../utils/helpers'; +import {getCurrencySymbol, getProductFromId, hasMultipleProductsFeature, isSameCurrency, formatNumber, formatMonetaryAmount, hasMultipleNewsletters} from '../../utils/helpers'; import {ValidateInputForm} from '../../utils/form'; import {interceptAnchorClicks} from '../../utils/links'; import NewsletterSelectionPage from './NewsletterSelectionPage'; @@ -586,7 +586,7 @@ export default class OfferPage extends React.Component {
{getCurrencySymbol(price.currency)} - {formatNumber(this.renderRoundedPrice(updatedPrice))} + {formatMonetaryAmount(this.updatedPrice)}
); @@ -595,7 +595,7 @@ export default class OfferPage extends React.Component {
{getCurrencySymbol(price.currency)} - {formatNumber(this.renderRoundedPrice(updatedPrice))} + {formatMonetaryAmount(this.updatedPrice)}
); @@ -606,7 +606,7 @@ export default class OfferPage extends React.Component { return null; } return ( -
{getCurrencySymbol(price.currency)} {formatNumber(price.amount / 100)}
+
{getCurrencySymbol(price.currency)} {formatMonetaryAmount(price.amount / 100)}
); } diff --git a/apps/portal/src/utils/helpers.js b/apps/portal/src/utils/helpers.js index c178c4c11aa..cd87fc73ad6 100644 --- a/apps/portal/src/utils/helpers.js +++ b/apps/portal/src/utils/helpers.js @@ -942,3 +942,32 @@ export function isRecentMember({member}) { return diffHours < 24; } + +/** + * Formats a decimal amount for monetary display: + * - Maintains 2 decimal places only when needed (1.3 -> "1.30", 1 -> "1") + * - Adds thousand separators (1000.5 -> "1,000.50") + * @param {number} amount - The decimal amount to format + * @returns {string} Formatted string (e.g., "1,000.30", "1,000", "1.55") + */ +export function formatMonetaryAmount(amount) { + // Handle invalid inputs + if (amount === undefined || amount === null) { + return ''; + } + + // Ensure we're working with a number + const numericAmount = parseFloat(amount); + if (isNaN(numericAmount)) { + return ''; + } + + const formatted = numericAmount % 1 === 0 ? + numericAmount.toString() : + numericAmount.toFixed(2); + + const [whole, decimal] = formatted.split('.'); + const withCommas = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + + return decimal ? `${withCommas}.${decimal}` : withCommas; +} From 01f1ec68f12db70854b1edd36318362309e824b4 Mon Sep 17 00:00:00 2001 From: Djordje Vlaisavljevic Date: Thu, 19 Dec 2024 16:55:49 +0000 Subject: [PATCH 2/2] WIP --- apps/portal/src/components/common/ProductsSection.js | 2 +- apps/portal/src/components/pages/OfferPage.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/portal/src/components/common/ProductsSection.js b/apps/portal/src/components/common/ProductsSection.js index ed91e3f266e..a658408ae62 100644 --- a/apps/portal/src/components/common/ProductsSection.js +++ b/apps/portal/src/components/common/ProductsSection.js @@ -1,7 +1,7 @@ import React, {useContext, useEffect, useState} from 'react'; import {ReactComponent as LoaderIcon} from '../../images/icons/loader.svg'; import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark.svg'; -import {getCurrencySymbol, getPriceString, getStripeAmount, getMemberActivePrice, getProductFromPrice, getFreeTierTitle, getFreeTierDescription, getFreeProduct, formatMonetaryAmount, getFreeProductBenefits, getSupportAddress, formatNumber, isCookiesDisabled, hasOnlyFreeProduct, isMemberActivePrice, hasFreeTrialTier, isComplimentaryMember} from '../../utils/helpers'; +import {getCurrencySymbol, getPriceString, getStripeAmount, getMemberActivePrice, getProductFromPrice, getFreeTierTitle, getFreeTierDescription, getFreeProduct, formatMonetaryAmount, getFreeProductBenefits, getSupportAddress, isCookiesDisabled, hasOnlyFreeProduct, isMemberActivePrice, hasFreeTrialTier, isComplimentaryMember} from '../../utils/helpers'; import AppContext from '../../AppContext'; import calculateDiscount from '../../utils/discount'; import Interpolate from '@doist/react-interpolate'; diff --git a/apps/portal/src/components/pages/OfferPage.js b/apps/portal/src/components/pages/OfferPage.js index 4783bf1271d..f649eb4d1fc 100644 --- a/apps/portal/src/components/pages/OfferPage.js +++ b/apps/portal/src/components/pages/OfferPage.js @@ -4,7 +4,7 @@ import AppContext from '../../AppContext'; import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark.svg'; import CloseButton from '../common/CloseButton'; import InputForm from '../common/InputForm'; -import {getCurrencySymbol, getProductFromId, hasMultipleProductsFeature, isSameCurrency, formatNumber, formatMonetaryAmount, hasMultipleNewsletters} from '../../utils/helpers'; +import {getCurrencySymbol, getProductFromId, hasMultipleProductsFeature, isSameCurrency, formatMonetaryAmount, hasMultipleNewsletters} from '../../utils/helpers'; import {ValidateInputForm} from '../../utils/form'; import {interceptAnchorClicks} from '../../utils/links'; import NewsletterSelectionPage from './NewsletterSelectionPage'; @@ -595,7 +595,7 @@ export default class OfferPage extends React.Component {
{getCurrencySymbol(price.currency)} - {formatMonetaryAmount(this.updatedPrice)} + {formatMonetaryAmount(this.renderRoundedPrice(updatedPrice))}
);