Skip to content

Commit 6731036

Browse files
feat(plugin-ecommerce): add locale-aware currency formatting and symbol positioning (#15139)
This PR introduces automatic, locale-aware currency formatting using the native Intl.NumberFormat API. It also standardizes currency symbol positions and separators across the frontend, ensuring consistent price display for different currencies in Payload e-commerce. **Changes** 1. useCurrency hook - Added locale support in formatCurrency. - Replaced manual string formatting with Intl.NumberFormat: ``` return new Intl.NumberFormat(locale, { style: 'currency', currency: code, minimumFractionDigits: decimals, maximumFractionDigits: decimals, }).format(value / Math.pow(10, decimals)) ``` Automatically handles: - Decimal separators (. vs ,) depending on locale - Currency placement (before or after the value) - Correct number of fraction digits - Optional locale parameter allows overriding per call (default: 'en') **2. Currency display settings** Standardized symbol placement and separator: ``` <div className="priceCell"> {currency.symbolPosition === 'before' ? `<span className="currencySymbol">${currency.symbol}</span>` : ''} {currency.symbolPosition === 'before' && currency.symbolSeparator} <span className="priceValue">{convertFromBaseValue({ baseValue: cellData, currency })}</span> {currency.symbolPosition === 'after' && currency.symbolSeparator} {currency.symbolPosition === 'after' ? `<span className="currencySymbol">${currency.symbol}</span>` : ''} </div> ``` Ensures correct rendering for currencies that place the symbol before (EUR, USD, GBP) or after (PLN). **3. Predefined currencies** Updated standard currency definitions: ``` export const EUR: Currency = { code: 'EUR', decimals: 2, label: 'Euro', symbol: '€', symbolPosition: 'before', symbolSeparator: '' } export const USD: Currency = { code: 'USD', decimals: 2, label: 'US Dollar', symbol: '$', symbolPosition: 'before', symbolSeparator: '' } export const GBP: Currency = { code: 'GBP', decimals: 2, label: 'British Pound', symbol: '£', symbolPosition: 'before', symbolSeparator: '' } ``` **4. Example: currenciesConfig in Payload** ``` import { EUR as BaseEUR, USD } from '@payloadcms/plugin-ecommerce'; import type { Currency } from '@payloadcms/plugin-ecommerce/types'; const PLN = { code: 'PLN', decimals: 2, label: 'Polski złoty', symbol: 'zł', symbolPosition: 'after', symbolSeparator: ' ', } satisfies Currency; export const currenciesConfig = { supportedCurrencies: [ PLN, USD, BaseEUR, ], defaultCurrency: 'PLN', }; ``` <img width="576" height="641" alt="product-example" src="https://github.com/user-attachments/assets/4965ac38-a8cc-44ab-b8d8-0f3d9082399c" /> --------- Co-authored-by: Paul Popus <paul@payloadcms.com>
1 parent 48db8c1 commit 6731036

6 files changed

Lines changed: 216 additions & 48 deletions

File tree

packages/plugin-ecommerce/src/react/provider/index.tsx

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,26 +1116,27 @@ export const useCurrency = () => {
11161116
const { currenciesConfig, currency, setCurrency } = useEcommerce()
11171117

11181118
const formatCurrency = useCallback(
1119-
(value?: null | number, options?: { currency?: Currency }): string => {
1119+
(value?: null | number, options?: { currency?: Currency; locale?: string }): string => {
11201120
if (value === undefined || value === null) {
11211121
return ''
11221122
}
11231123

11241124
const currencyToUse = options?.currency || currency
1125-
11261125
if (!currencyToUse) {
11271126
return value.toString()
11281127
}
11291128

1130-
if (value === 0) {
1131-
return `${currencyToUse.symbol}0.${'0'.repeat(currencyToUse.decimals)}`
1132-
}
1129+
const { code, decimals, symbolDisplay } = currencyToUse
11331130

1134-
// Convert from base value (e.g., cents) to decimal value (e.g., dollars)
1135-
const decimalValue = value / Math.pow(10, currencyToUse.decimals)
1131+
const locale = options?.locale || 'en'
11361132

1137-
// Format with the correct number of decimal places
1138-
return `${currencyToUse.symbol}${decimalValue.toFixed(currencyToUse.decimals)}`
1133+
return new Intl.NumberFormat(locale, {
1134+
currency: code,
1135+
currencyDisplay: symbolDisplay || 'symbol',
1136+
maximumFractionDigits: decimals,
1137+
minimumFractionDigits: decimals,
1138+
style: 'currency',
1139+
}).format(value / Math.pow(10, decimals))
11391140
},
11401141
[currency],
11411142
)

packages/plugin-ecommerce/src/types/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ export type PaymentAdapterClient = {
233233
export type Currency = {
234234
/**
235235
* The ISO 4217 currency code
236-
* @example 'usd'
236+
* @example 'USD'
237237
*/
238238
code: string
239239
/**
@@ -252,6 +252,11 @@ export type Currency = {
252252
* @example '$'
253253
*/
254254
symbol: string
255+
/**
256+
* The display format for the currency symbol in formatted output.
257+
* @example 'symbol'
258+
*/
259+
symbolDisplay?: 'code' | 'symbol'
255260
}
256261

257262
/**

packages/plugin-ecommerce/src/ui/PriceCell/index.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
'use client'
2+
23
import type { DefaultCellComponentProps, TypedCollection } from 'payload'
34

45
import { useTranslation } from '@payloadcms/ui'
56

67
import type { CurrenciesConfig, Currency } from '../../types/index.js'
78

8-
import { convertFromBaseValue } from '../utilities.js'
9+
import { formatPrice } from '../utilities.js'
910

1011
type Props = {
1112
cellData?: number
@@ -16,7 +17,7 @@ type Props = {
1617
} & DefaultCellComponentProps
1718

1819
export const PriceCell: React.FC<Props> = (args) => {
19-
const { t } = useTranslation()
20+
const { i18n, t } = useTranslation()
2021
const { cellData, currenciesConfig, currency: currencyFromProps, rowData } = args
2122

2223
const currency = currencyFromProps || currenciesConfig.supportedCurrencies[0]
@@ -40,10 +41,5 @@ export const PriceCell: React.FC<Props> = (args) => {
4041
return <span>{t('plugin-ecommerce:priceNotSet')}</span>
4142
}
4243

43-
return (
44-
<span>
45-
{currency.symbol}
46-
{convertFromBaseValue({ baseValue: cellData, currency })}
47-
</span>
48-
)
44+
return <span>{formatPrice({ baseValue: cellData, currency, locale: i18n.language })}</span>
4945
}

packages/plugin-ecommerce/src/ui/PriceRowLabel/index.tsx

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
'use client'
22

3-
import { useRowLabel } from '@payloadcms/ui'
3+
import { useRowLabel, useTranslation } from '@payloadcms/ui'
44
import { useMemo } from 'react'
55

66
import type { CurrenciesConfig } from '../../types/index.js'
77

88
import './index.css'
9-
import { convertFromBaseValue } from '../utilities.js'
9+
import { formatPrice } from '../utilities.js'
1010

1111
type Props = {
1212
currenciesConfig: CurrenciesConfig
@@ -16,6 +16,7 @@ export const PriceRowLabel: React.FC<Props> = (props) => {
1616
const { currenciesConfig } = props
1717
const { defaultCurrency, supportedCurrencies } = currenciesConfig
1818

19+
const { i18n } = useTranslation()
1920
const { data } = useRowLabel<{ amount: number; currency: string }>()
2021

2122
const currency = useMemo(() => {
@@ -32,23 +33,20 @@ export const PriceRowLabel: React.FC<Props> = (props) => {
3233
return supportedCurrencies[0]
3334
}, [data.currency, supportedCurrencies, defaultCurrency])
3435

35-
const amount = useMemo(() => {
36-
if (data.amount) {
37-
return convertFromBaseValue({ baseValue: data.amount, currency: currency! })
36+
const formattedPrice = useMemo(() => {
37+
if (!currency) {
38+
return '0'
3839
}
3940

40-
return '0'
41-
}, [currency, data.amount])
41+
return formatPrice({ baseValue: data.amount ?? 0, currency, locale: i18n.language })
42+
}, [currency, data.amount, i18n.language])
4243

4344
return (
4445
<div className="priceRowLabel">
4546
<div className="priceLabel">Price:</div>
4647

4748
<div className="priceValue">
48-
<span>
49-
{currency?.symbol}
50-
{amount}
51-
</span>
49+
<span>{formattedPrice}</span>
5250
<span>({data.currency})</span>
5351
</div>
5452
</div>

packages/plugin-ecommerce/src/ui/utilities.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,27 @@ export const convertFromBaseValue = ({
4444
// Format with the correct number of decimal places
4545
return decimalValue.toFixed(currency.decimals)
4646
}
47+
48+
/**
49+
* Format a base value as a locale-aware currency string using the Intl API.
50+
*
51+
* @example formatPrice({ baseValue: 2500, currency: USD }) // "$25.00"
52+
* @example formatPrice({ baseValue: 2500, currency: EUR, locale: 'de' }) // "25,00 €"
53+
*/
54+
export const formatPrice = ({
55+
baseValue,
56+
currency,
57+
locale = 'en',
58+
}: {
59+
baseValue: number
60+
currency: Currency
61+
locale?: string
62+
}): string => {
63+
return new Intl.NumberFormat(locale, {
64+
currency: currency.code,
65+
currencyDisplay: currency.symbolDisplay ?? 'symbol',
66+
maximumFractionDigits: currency.decimals,
67+
minimumFractionDigits: currency.decimals,
68+
style: 'currency',
69+
}).format(baseValue / Math.pow(10, currency.decimals))
70+
}

0 commit comments

Comments
 (0)