Behive Tech Registry

Price Tag

v0.0.0

A formatted price with an optional "was" price and the percentage saved. Formatting goes through `Intl.NumberFormat`, so symbols, decimals and grouping follow the locale and currency instead of being hand-rolled.

pnpm add @acme/atoms.price-tag

registry/acme/atoms/price-tag · depends on @behivetech/get-class-name

price-tag.tsx
1import { type HTMLAttributes } from "react";
2import { getClassName } from "@behivetech/get-class-name";
3import styles from "./price-tag.module.scss";
4
5export type PriceTagSize = "sm" | "md" | "lg";
6
7export interface PriceTagProps extends HTMLAttributes<HTMLSpanElement> {
8 /** The price to show, in major units (e.g. 19.99) */
9 amount: number;
10 /** ISO 4217 currency code; defaults to USD */
11 currency?: string;
12 /** BCP 47 locale used for formatting; defaults to the browser's, then en-US */
13 locale?: string;
14 /** The previous price, when `amount` is a discount — rendered struck through with the saving */
15 compareAt?: number;
16 /** Controls the type size; defaults to md */
17 size?: PriceTagSize;
18 /** Additional class names to merge with the component root element */
19 className?: string;
20}
21
22/**
23 * A formatted price, with an optional "was" price and the percentage saved.
24 *
25 * Formatting goes through `Intl.NumberFormat`, so currency symbols, decimal
26 * rules and grouping follow the locale rather than being hand-rolled — 1 999 €
27 * in French, $1,999.00 in US English, ¥1,999 with no decimals in Japanese.
28 */
29export const PriceTag = ({
30 amount,
31 currency = "USD",
32 locale,
33 compareAt,
34 size = "md",
35 className,
36 ...rest
37}: PriceTagProps) => {
38 const [rootClass, getChildClass] = getClassName({
39 className,
40 rootClass: "ACME__PriceTag",
41 modifiers: { [size]: true, discounted: compareAt !== undefined && compareAt > amount },
42 styles,
43 });
44
45 const format = new Intl.NumberFormat(locale, { style: "currency", currency });
46 const discounted = compareAt !== undefined && compareAt > amount;
47 const saving = discounted ? Math.round(((compareAt - amount) / compareAt) * 100) : 0;
48
49 return (
50 <span {...rest} className={rootClass}>
51 <span className={getChildClass("amount")}>{format.format(amount)}</span>
52 {discounted && (
53 <>
54 <s className={getChildClass("compare-at")}>{format.format(compareAt)}</s>
55 <span className={getChildClass("saving")}>−{saving}%</span>
56 </>
57 )}
58 </span>
59 );
60};
price-tag.module.scss
1.ACME__PriceTag {
2 display: inline-flex;
3 align-items: baseline;
4 gap: 0.5em;
5 font-family: var(--md-sys-typescale-body-font);
6 font-variant-numeric: tabular-nums;
7 color: var(--md-sys-color-on-surface);
8
9 &--sm {
10 font-size: var(--md-sys-typescale-body-small-size);
11 }
12
13 &--md {
14 font-size: var(--md-sys-typescale-title-medium-size);
15 }
16
17 &--lg {
18 font-size: var(--md-sys-typescale-headline-small-size);
19 }
20
21 &__amount {
22 font-weight: 600;
23 }
24
25 &--discounted &__amount {
26 color: var(--md-sys-color-primary);
27 }
28
29 &__compare-at {
30 font-size: 0.8em;
31 color: var(--md-sys-color-on-surface-variant);
32 }
33
34 &__saving {
35 padding: 0.1em 0.5em;
36 border-radius: var(--md-sys-shape-corner-full);
37 font-size: 0.7em;
38 font-weight: 600;
39 background: var(--md-sys-color-tertiary-container);
40 color: var(--md-sys-color-on-tertiary-container);
41 }
42}
index.ts
1export { PriceTag } from './price-tag.js';
2export type { PriceTagProps } from './price-tag.js';
price-tag.composition.tsx
1import { PriceTag } from "./price-tag.js";
2
3export const BasicPriceTag = () => <PriceTag amount={19.99} />;
4
5export const DiscountedPriceTag = () => <PriceTag amount={14.99} compareAt={19.99} />;
6
7export const EuroPriceTag = () => <PriceTag amount={1999} currency="EUR" locale="fr-FR" />;
8
9export const YenPriceTag = () => <PriceTag amount={1999} currency="JPY" locale="ja-JP" />;
10
11/** All three sizes side by side, so the scale can be judged at once. */
12export const PriceTagSizes = () => (
13 <div style={{ display: "flex", gap: "1.5rem", alignItems: "baseline" }}>
14 <PriceTag size="sm" amount={9.5} compareAt={12} />
15 <PriceTag size="md" amount={9.5} compareAt={12} />
16 <PriceTag size="lg" amount={9.5} compareAt={12} />
17 </div>
18);
price-tag.spec.tsx
1import { render, screen } from "@testing-library/react";
2import { PriceTag } from "./price-tag.js";
3
4describe("PriceTag", () => {
5 it("formats the amount as currency for the locale", () => {
6 render(<PriceTag amount={1999.5} locale="en-US" />);
7 expect(screen.getByText("$1,999.50")).toBeTruthy();
8 });
9
10 it("respects the currency's own decimal rules", () => {
11 render(<PriceTag amount={1999} currency="JPY" locale="ja-JP" />);
12 expect(screen.getByText("¥1,999")).toBeTruthy();
13 });
14
15 it("shows the previous price and the saving when discounted", () => {
16 render(<PriceTag amount={15} compareAt={20} locale="en-US" />);
17 expect(screen.getByText("$20.00").tagName).toBe("S");
18 expect(screen.getByText("−25%")).toBeTruthy();
19 });
20
21 it("ignores a compareAt that is not actually higher", () => {
22 render(<PriceTag amount={20} compareAt={20} locale="en-US" />);
23 expect(screen.queryByText(/%/)).toBeNull();
24 });
25
26 it("applies additional className", () => {
27 const { container } = render(<PriceTag amount={1} className="custom" />);
28 expect(container.firstElementChild?.className).toContain("custom");
29 });
30});