Skip to content

feat(plugin-ecommerce): add locale-aware currency formatting and symbol positioning#15139

Open
teastudiopl wants to merge 3 commits intopayloadcms:mainfrom
teastudiopl:feat/plugin-ecommerce-currency
Open

feat(plugin-ecommerce): add locale-aware currency formatting and symbol positioning#15139
teastudiopl wants to merge 3 commits intopayloadcms:mainfrom
teastudiopl:feat/plugin-ecommerce-currency

Conversation

@teastudiopl
Copy link
Contributor

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',
};
product-example

@paulpopus
Copy link
Contributor

I reviewed the code and it seems to me that the symbolPosition argument is essentially useless?
Same for the symbolSeparator

This would be because this switches formatting to the Intl API which will handle any nuances here, we can keep the symbol in the type as we can use that in the custom input component.

For the cell we can refactor a utility out so it's formatting the number using the Intl API as well.

What do you think? @teastudiopl

@teastudiopl
Copy link
Contributor Author

Good point. I can refactor the cell and extract a shared utility based on Intl.NumberFormat.
Should I handle this in this PR?

@paulpopus
Copy link
Contributor

Yes please, though for now don't do a shared utility, just copy it across and add a comment that it's similar to the other as I'm not sure we should have cross imports for bundling reasons. The code in the react folder should be independent until I can confirm it's safe (or we do an export from /shared in the future)

@teastudiopl
Copy link
Contributor Author

OK, you can verify it now.
I also added
symbolDisplay?: 'code' | 'symbol'
to the Currency type, which allows formatPrice to control whether the symbol or the code is shown.

@teastudiopl teastudiopl force-pushed the feat/plugin-ecommerce-currency branch from 4830a01 to f1542e9 Compare February 1, 2026 16:37
@teastudiopl
Copy link
Contributor Author

teastudiopl commented Feb 20, 2026

@paulpopus Any news about this PR?

usage in Price component:

'use client'
import { useCurrency } from '@payloadcms/plugin-ecommerce/client/react'
import { useLocale } from 'next-intl'
import React, { useMemo } from 'react'

type BaseProps = {
  className?: string
  currencyCodeClassName?: string
  as?: 'span' | 'p'
}

type PriceFixed = {
  amount: number
  currencyCode?: string
  highestAmount?: never
  lowestAmount?: never
}

type PriceRange = {
  amount?: never
  currencyCode?: string
  highestAmount: number
  lowestAmount: number
}

type Props = BaseProps & (PriceFixed | PriceRange)

export const Price = ({
  amount,
  className,
  highestAmount,
  lowestAmount,
  currencyCode: currencyCodeFromProps,
  as = 'p',
}: Props & React.ComponentProps<'p'>) => {
  const { formatCurrency, supportedCurrencies } = useCurrency()
  const locale = useLocale()

  const Element = as

  const currencyToUse = useMemo(() => {
    if (currencyCodeFromProps) {
      return supportedCurrencies.find((currency) => currency.code === currencyCodeFromProps)
    }
    return undefined
  }, [currencyCodeFromProps, supportedCurrencies])

  if (typeof amount === 'number') {
    return (
      <Element className={className} suppressHydrationWarning>
        {formatCurrency(amount, { currency: currencyToUse, locale: locale })}
      </Element>
    )
  }

  if (highestAmount && highestAmount !== lowestAmount) {
    return (
      <Element className={className} suppressHydrationWarning>
        {`${formatCurrency(lowestAmount, { currency: currencyToUse, locale: locale })} - ${formatCurrency(highestAmount, { currency: currencyToUse, locale: locale })}`}
      </Element>
    )
  }

  if (lowestAmount) {
    return (
      <Element className={className} suppressHydrationWarning>
        {`${formatCurrency(lowestAmount, { currency: currencyToUse, locale: locale })}`}
      </Element>
    )
  }

  return null
}

currencies config

import { EUR, USD } from '@payloadcms/plugin-ecommerce';

export const currenciesConfig = {
  supportedCurrencies: [
    {
      code: 'PLN',
      decimals: 2,
      label: 'Polski złoty',
      symbol: 'zł',
      symbolDisplay: 'symbol' as 'symbol'
    },
    USD,
    EUR,
  ],
  defaultCurrency: 'PLN',
}

@teastudiopl teastudiopl force-pushed the feat/plugin-ecommerce-currency branch from f1542e9 to 97ac0da Compare March 18, 2026 19:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants