Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed currency formatting WIP #21924

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions apps/admin-x-design-system/src/global/form/CurrencyField.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {useState} from 'react';
import React, {useState, useEffect} from 'react';
import TextField, {TextFieldProps} from './TextField';

export type CurrencyFieldProps = Omit<TextFieldProps, 'type' | 'onChange' | 'value'> & {
Expand All @@ -15,30 +15,49 @@ export type CurrencyFieldProps = Omit<TextFieldProps, 'type' | 'onChange' | 'val
* Available options are generally the same as TextField.
*/
const CurrencyField: React.FC<CurrencyFieldProps> = ({
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
const forceCurrencyValue = (input: string) => {
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 <TextField
{...props}
value={localValue}
onBlur={(e) => {
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));
}}
/>;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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':
Expand All @@ -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':
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -100,7 +99,7 @@ const TierDetailPreview: React.FC<TierDetailPreviewProps> = ({tier, isFreeTier})
<div className="mt-4 flex w-full flex-row flex-wrap items-end justify-between gap-x-1 gap-y-[10px]">
<div className={`flex flex-wrap text-black ${((showingYearly && tier?.yearly_price === undefined) || (!showingYearly && tier?.monthly_price === undefined)) && !isFreeTier ? 'opacity-30' : ''}`}>
<span className="self-start text-[2.7rem] font-bold uppercase leading-[1.115]">{currencySymbol}</span>
<span className="break-all text-[3.4rem] font-bold leading-none tracking-tight">{showingYearly ? numberWithCommas(yearlyPrice) : numberWithCommas(monthlyPrice)}</span>
<span className="break-all text-[3.4rem] font-bold leading-none tracking-tight">{showingYearly ? formatMonetaryAmount(yearlyPrice) : formatMonetaryAmount(monthlyPrice)}</span>
{!isFreeTier && <span className="ml-1 self-end text-[1.5rem] leading-snug text-grey-800">/{showingYearly ? 'year' : 'month'}</span>}
</div>
<TrialDaysLabel trialDays={trialDays} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -33,7 +32,7 @@ const TierCard: React.FC<TierCardProps> = ({tier}) => {
<div className='text-[1.65rem] font-bold leading-tight tracking-tight text-black dark:text-white'>{tier.name}</div>
<div className='mt-2 flex items-baseline'>
<span className="ml-1 translate-y-[-3px] text-md font-bold uppercase">{currencySymbol}</span>
<span className='text-xl font-bold tracking-tighter'>{numberWithCommas(currencyToDecimal(tier.monthly_price || 0))}</span>
<span className='text-xl font-bold tracking-tighter'>{formatMonetaryAmount(currencyToDecimal(tier.monthly_price || 0))}</span>
{(tier.monthly_price && tier.monthly_price > 0) && <span className='text-sm text-grey-700'>/month</span>}
</div>
{tier.trial_days ?
Expand Down
18 changes: 18 additions & 0 deletions apps/admin-x-settings/src/utils/currency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
6 changes: 3 additions & 3 deletions apps/portal/src/components/common/ProductsSection.js
Original file line number Diff line number Diff line change
@@ -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, isCookiesDisabled, hasOnlyFreeProduct, isMemberActivePrice, hasFreeTrialTier, isComplimentaryMember} from '../../utils/helpers';
import AppContext from '../../AppContext';
import calculateDiscount from '../../utils/discount';
import Interpolate from '@doist/react-interpolate';
Expand Down Expand Up @@ -625,7 +625,7 @@ function ProductCardPrice({product}) {
<div className="gh-portal-product-card-price-trial">
<div className="gh-portal-product-price">
<span className={'currency-sign' + (currencySymbol.length > 1 ? ' long' : '')}>{currencySymbol}</span>
<span className="amount" data-testid="product-amount">{formatNumber(getStripeAmount(activePrice.amount))}</span>
<span className="amount" data-testid="product-amount">{formatMonetaryAmount(getStripeAmount(activePrice.amount))}</span>
<span className="billing-period">/{interval}</span>
</div>
<ProductCardTrialDays trialDays={trialDays} discount={yearlyDiscount} selectedInterval={selectedInterval} />
Expand All @@ -643,7 +643,7 @@ function ProductCardPrice({product}) {
<div className="gh-portal-product-card-price-trial">
<div className="gh-portal-product-price">
<span className={'currency-sign' + (currencySymbol.length > 1 ? ' long' : '')}>{currencySymbol}</span>
<span className="amount" data-testid="product-amount">{formatNumber(getStripeAmount(activePrice.amount))}</span>
<span className="amount" data-testid="product-amount">{formatMonetaryAmount(getStripeAmount(activePrice.amount))}</span>
<span className="billing-period">/{interval}</span>
</div>
{(selectedInterval === 'year' ? <YearlyDiscount discount={yearlyDiscount} /> : '')}
Expand Down
8 changes: 4 additions & 4 deletions apps/portal/src/components/pages/OfferPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, formatMonetaryAmount, hasMultipleNewsletters} from '../../utils/helpers';
import {ValidateInputForm} from '../../utils/form';
import {interceptAnchorClicks} from '../../utils/links';
import NewsletterSelectionPage from './NewsletterSelectionPage';
Expand Down Expand Up @@ -586,7 +586,7 @@ export default class OfferPage extends React.Component {
<div className="gh-portal-product-card-pricecontainer offer-type-trial">
<div className="gh-portal-product-price">
<span className={'currency-sign ' + currencyClass}>{getCurrencySymbol(price.currency)}</span>
<span className="amount">{formatNumber(this.renderRoundedPrice(updatedPrice))}</span>
<span className="amount">{formatMonetaryAmount(this.updatedPrice)}</span>
</div>
</div>
);
Expand All @@ -595,7 +595,7 @@ export default class OfferPage extends React.Component {
<div className="gh-portal-product-card-pricecontainer">
<div className="gh-portal-product-price">
<span className={'currency-sign ' + currencyClass}>{getCurrencySymbol(price.currency)}</span>
<span className="amount">{formatNumber(this.renderRoundedPrice(updatedPrice))}</span>
<span className="amount">{formatMonetaryAmount(this.renderRoundedPrice(updatedPrice))}</span>
</div>
</div>
);
Expand All @@ -606,7 +606,7 @@ export default class OfferPage extends React.Component {
return null;
}
return (
<div className="gh-portal-offer-oldprice">{getCurrencySymbol(price.currency)} {formatNumber(price.amount / 100)}</div>
<div className="gh-portal-offer-oldprice">{getCurrencySymbol(price.currency)} {formatMonetaryAmount(price.amount / 100)}</div>
);
}

Expand Down
29 changes: 29 additions & 0 deletions apps/portal/src/utils/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading