Skip to content

Price

Price is one style of the number run, the numeric variant of Text Foundation's content item union. One run type, one formatter, one set of role names, covering every number a retail template renders:

{ "number": { "value": 200,  "style": "currency", "currency": "AUD", "locale": "en-AU" } }
{ "number": { "value": 50,   "style": "percent" } }
{ "number": { "value": 4.5,  "style": "unit", "unit": "stars" } }
{ "number": { "value": 1299, "style": "decimal" } }

The reason it's one variant rather than a price run plus a percent run plus a number run: all four are the same call. Intl.NumberFormat with different options, formatToParts to split the result, role typography over the parts. Giving them separate schema shapes would be three mechanisms computing one class of fact, which is the thing this brief keeps refusing to do. The feature is still called Price because that's the retail concept it exists for; style: "currency" is what the rest of this page describes.

A number is always a number, never a typed string. That is the whole point: 50 is bindable, formattable, and styleable part by part, where "50%" typed into a text field is none of those and lets a campaign editor write "50 %" or "fifty".

percent takes 50, not 0.5

Intl's own percent style expects a fraction, so 0.5 renders as "50%". A retail author types 50, a campaign operator filling in "Discount %" types 50, and a product feed carries 50. So the authored value is the percentage itself and the formatter divides by 100 before handing it to Intl. One documented conversion at one boundary, in exchange for never having a field whose value means something different from what it's labelled. Intl still decides where the sign sits and whether a space precedes it, which is the part that genuinely varies by locale.

The formatter

One Intl.NumberFormat(locale, options) instance, cached per (locale, style, currency, unit, grouping). For style: "currency" the options are { style: 'currency', currency, trailingZeroDisplay: 'stripIfInteger', useGrouping }grouping: false on the run turns off the thousands separator, since a retail price is as often $1088 as $1,088 and that's a brand choice, not a locale one.

formatToParts(amount) maps through a fixed six-case switch (currency→currencySymbol, integer→dollars, group→thousandsSeparator, decimal→decimalSeparator, fraction→cents, everything else→literal) into NumberPart[] carrying only {role, value} — zero currency-specific and zero style-specific branches. The roles are formatToParts' own types, so widening to a new style adds no role vocabulary: currencySymbol, integer, group, decimal, fraction, unit, percentSign, literal.

trailingZeroDisplay:'stripIfInteger' natively handles "decimals only when present" (verified: integer amounts omit the decimal/fraction parts entirely); non-standard fraction-digit currencies (JPY = 0, BHD = 3) fall out for free. style:'unit' cannot express an arbitrary retail unit string (fixed ~50–60 CLDR vocabulary, throws on 'each') — a free-text unit is appended as a plain literal part outside Intl's control, with placement/separator from an explicit locale → { position, separator } config table. Styling is a separate renderer-side typography[role] lookup (Text Foundation's typography resolver), not threaded through the formatter. "Decimals: always/never" needs explicit override logic on top of the native auto default. Price parts sit inline with literal runs — the discount lockup's suffix is an ordinary text run in the same content array (Text Foundation), not a pinned layer.

Migration target is the four bespoke components that actually call the shared currency formatter: betta-price-box, freshmart-price-box, freshmart-save-box, jb-price-box (and their test files) — retire their hand-rolled currency logic in favour of this formatter. betta-discount-box and jb-price-frenzy share the naming pattern but carry no currency logic at all — out of scope (the former is entirely Text Foundation's padding work, the latter renders a freeform headline string).

formatToParts returns parts in the locale's own logical (RTL-correct) order — preserve that array order when re-concatenating by role, so RTL isn't a rework later even though it's out of scope this round (non-goal).

Done when

The price-with-levers and inline-price hard cases render, and a bound value change restyles correctly across currencies.