DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our price is a component six times and a hardcoded string fifty-two times, and one sentence decides which

Notifio costs £20 once. It is a desktop app that watches rental search pages and emails you when a new listing appears, and the whole business model is one number on notifio.app/pricing.

That number reaches a visitor in two completely different ways:

$ grep -rno --include="*.tsx" "<Price" src | wc -l
8
$ grep -rno --include="*.tsx" --include="*.ts" "£20" src | wc -l
52
Enter fullscreen mode Exit fullscreen mode

Six of those eight component renders are <Price>, the other two are its footnote. Fifty-two places type the pounds out by hand. Both are correct, and the rule for which to use is one sentence: a figure the visitor is using to decide gets localised, and a figure that is indexed, structured, or legal stays in the currency we charge.

An earlier post covered how the converted figure is calculated and why it is allowed to be wrong in only one direction. This one is about the component API that sits on top of it, and the places that deliberately do not use it.

Four components, three of which render nothing for a UK visitor

export function RateNote({ className }: { className?: string }) {
  const currency = useDisplayCurrency();
  if (!isApproximate(currency)) return null;

  return <p className={className}>{rateNote(currency)}</p>;
}
Enter fullscreen mode Exit fullscreen mode

isApproximate is currency !== "gbp". For a UK visitor the displayed price is the charged price, so there is nothing to disclose, so the component returns null rather than rendering an empty paragraph with margin on it. ConversionNotice does the same. PriceFootnote is the one that always renders, because even for GBP there is a VAT line to say.

Null-rendering a disclosure is a pattern worth being deliberate about: the alternative is a conditional at every call site, and a conditional at a call site is a thing somebody forgets. Putting the condition inside the component means a page can always render the footnote and the footnote decides whether it has anything to say.

You can see both states on the same page without changing anything but your network route. A UK visitor to the pricing page gets the VAT line. A visitor in the Netherlands gets this:

Approximate EUR (£1 ≈ €1.22, incl. card fees, rounded up). Charged in GBP. VAT is added at checkout where it applies, based on your country.
Enter fullscreen mode Exit fullscreen mode

Which is three facts in one line, deliberately not three stacked notices. The comment in the source says why:

/**
 * One line rather than three stacked notices — a wall of grey caveats under a
 * price buries the single fact a customer needs, which is that the figure is
 * approximate and the real total appears at checkout.
 */
Enter fullscreen mode Exit fullscreen mode

A tooltip is not a disclosure

<Price> carries the caveat in a title attribute:

export function Price({ pence, className }: PriceProps) {
  const currency = useDisplayCurrency();

  return (
    <span className={className} title={conversionNotice(currency) || undefined}>
      {formatForDisplay(pence, currency)}
    </span>
  );
}
Enter fullscreen mode Exit fullscreen mode

|| undefined rather than || "", so a UK visitor's HTML has no title attribute at all instead of an empty one. Small thing, and it means a hover on a price that needs no explanation shows no empty tooltip box.

The part worth arguing about is the doc comment above it:

 * Carries the conversion caveat in `title` so the disclosure follows the number
 * even where there is no room for a footnote. Surfaces carrying a real purchase
 * decision should also render {@link PriceFootnote}, because a tooltip is not a
 * disclosure on a touch device.
Enter fullscreen mode Exit fullscreen mode

A title attribute requires hover. There is no hover on a phone, so on the device where a large share of visitors read the price, the disclosure attached to it does not exist. It is also not reliably reachable by keyboard and screen reader support for it is inconsistent. So title is treated as a bonus for the mouse user who is curious, and never as the place a required caveat lives.

That is why the pricing page renders a PriceFootnote under each of its two cards, as real text in the DOM, and the home page renders a ConversionNotice. The hierarchy: the disclosure goes in the document, the tooltip is a convenience, and a decision surface gets both.

The hazard the file documents about itself

This is my favourite comment in the repo, because it is a bug report filed against a future reader:

 * ## A note on the markup
 *
 * {@link Price} renders a bare `<span>` carrying whatever `className` you give
 * it — no wrapper element. That matters for any CSS that styles spans by
 * descent: a rule like `.plan-price span { font-size: 14px }`, written to shrink
 * a "/ month" suffix, will silently shrink the headline amount too once the
 * amount becomes a span.
Enter fullscreen mode Exit fullscreen mode

Before <Price> existed, a price in a card was a text node. After, it is a <span>. Any descendant selector written in the text-node era now matches the headline amount, and the symptom is a £20 rendered at the size of its own small print, with nothing in the diff of the component that changed to explain it.

The general shape is worth naming: replacing text with an element changes what descendant selectors match, and that is a visual change with no visual code in it. The two defences are scoping the suffix rule to its own class, and knowing the component renders no wrapper. The comment exists because the second one is invisible from the call site.

The context defaults to the currency we charge

const DisplayCurrencyContext = createContext<DisplayCurrency>(BASE_CURRENCY);
Enter fullscreen mode Exit fullscreen mode

A component rendered outside a provider reads GBP. The comment is explicit that this is a choice rather than a convenience:

 * Defaults to {@link BASE_CURRENCY} when no provider is present, so a surface
 * that forgets to wrap itself shows GBP — the currency we actually charge — and
 * never a blank or wrong-looking price.
Enter fullscreen mode Exit fullscreen mode

Three routes wrap themselves in the provider: home, download and pricing. If a fourth route rendered a <Price> without one, the failure mode is a correct price in the wrong currency for that visitor, which is a cosmetic miss. The alternative defaults are a throw, which turns a forgotten wrapper into a 500 on a marketing page, or a nullish currency, which turns it into a blank where the price should be. On a page whose job is to state a price, showing the real one is the best of the three.

The provider also carries a server-resolved value as a prop rather than fetching anything:

 * This deliberately has no effect, no fetch and no storage — every one of those
 * is a way for the price to visibly change after load.
Enter fullscreen mode Exit fullscreen mode

Which is the actual reason these are client components at all. They are client components so they can read context, and they render byte-identically on the server, so the price in the HTML on arrival is the final price and there is no hydration correction to watch happen.

The fifty-two literals, and the three kinds of place they live

Not localising is a decision made as explicitly as localising. From the top of the home page:

/**
 * Only the price *figures* are localised. The FAQ copy, the crawler-facing
 * summary block and the FAQPage structured data all keep their literal £20,
 * because those strings are indexed and the structured data has to match the
 * text beside it — and £20 is the amount actually charged in every country.
 */
Enter fullscreen mode Exit fullscreen mode

Three groups.

Indexed copy and structured data. An FAQ answer saying "a one-time £20 payment" is in the page's FAQPage JSON-LD and in the visible text, and those two have to agree. A converted figure in one and not the other is a structured data mismatch; converted in both means the indexed answer differs per visitor, which is not what a page in a search index should be.

Comparison pages. Every price on notifio.app/compare is quoted, including ours. The type those pages are built from says why:

export type ComparePlan = {
  /** e.g. "1 month" */
  label: string;
  /** Exactly as published, currency symbol included. */
  price: string;
};
Enter fullscreen mode Exit fullscreen mode

A competitor's €29 a month is a string copied from their own pricing page with the date we read it. Putting our own price through a converter on the same table while theirs is a literal would be comparing a quote to a conversion, and the whole point of that table is that both halves can be checked.

The terms of service. notifio.app/terms mentions £20 three times, and the third one is this:

£20 or the total amount you paid us in the twelve months
Enter fullscreen mode Exit fullscreen mode

A limitation of liability. That is the one number on the entire site that must never be converted, buffered or rounded up to look retail, because it is not a price being advertised. It is the amount of a possible refund, denominated in the currency the money actually moved in. A localised liability cap would be a different promise in every country, with the buffered conversion quietly making the promise larger than the sum we hold.

The rule, restated

The split is not "marketing pages versus legal pages". It is what the number is for. A figure answering "can I afford this" should arrive in the reader's own units, with the caveat in the document and not only in a tooltip. A figure that is a record of a transaction, an indexed claim, or a quotation of someone else, stays exactly as it was written.

Six component renders, fifty-two literals, and both numbers going up over time is fine as long as each new one was a choice. The app itself, which never sees any of this because it is charged in GBP pence, is at notifio.app/download.

Top comments (0)