Munchable's paywall has to print a price the moment it opens. The website has it easy: a page render already has the request in its hands, so the visitor's country comes off an IP header. The app has no request. It has a phone, and a phone knows where its owner lives.
So the app reads a country out of the device locale, which means parsing a BCP 47 language tag, which is a smaller job than it looks and has exactly one trap in it.
The whole function
export function regionFromLocale(locale: string | null | undefined): string | null {
if (!locale) return null;
// The first subtag is the language, so the region is always one of the rest.
const [, ...subtags] = locale.trim().split(/[-_]/);
for (const subtag of subtags) {
// A single-character subtag opens an extension (`-u-ca-gregory`), and
// everything after it is extension data: `ca` there is a calendar, not
// Canada. Stop rather than read one as a country.
if (subtag.length <= 1) break;
if (/^[A-Za-z]{2}$/.test(subtag)) return subtag.toUpperCase();
}
return null;
}
Three decisions in nine lines.
The language subtag is dropped before the loop starts. Language codes and country codes are both two letters and they overlap heavily, so the first subtag has to be skipped positionally rather than tested. ca-ES is Catalan as spoken in Spain. Scan it from the left looking for two letters and you get Canada; start after the language and you get Spain, which is the correct answer and also a euro.
A subtag of one character ends the search. This is the trap. A single-letter subtag is a singleton that opens an extension sequence, and everything after it is extension payload. Android and the web both hand out locales like de-DE-u-ca-gregory, where u opens the Unicode extension and ca is the calendar key, and gregory is its value. A parser that keeps looking for a two-letter subtag past that point finds ca and decides a German phone is Canadian. So the loop breaks instead of continuing, which is the one line in the function I would not have written without having seen the string.
So in one tag, ca can be a language and ca can be a calendar, and in neither position is it the country.
Script subtags take care of themselves. zh-Hant-TW has a four-letter script subtag in the middle, which fails the two-letter test and is skipped without needing a rule of its own.
Running it over the shapes these APIs really return:
| Locale | Region | Currency shown |
|---|---|---|
en-GB |
GB |
gbp |
en_GB (Android's underscored form) |
GB |
gbp |
ca-ES |
ES |
eur |
de-DE-u-ca-gregory |
DE |
eur |
zh-Hant-TW |
TW |
gbp |
en-US-POSIX |
US |
usd |
es-419 |
null |
gbp |
en |
null |
gbp |
es-419 is not a country and is not treated as one
419 is a UN M49 numeric region: Latin America, as a single locale. It is a real and correct thing for a device to report, and it names a continent. There is no single currency for it, so the parser's two-letter test rejects it and the price falls back to the currency the price list is authored in.
That is the general rule in the last column too. TW resolves cleanly as a region and still shows pounds, because Taiwan is not in the country-to-currency table. Twenty four currencies and fifty eight country entries is not the world, and the honest behaviour for everything outside it is to show the list's own currency rather than to guess at one. You can see the result of that fallback on munchable.app/#pricing, which is also where a crawler always lands: search engines are quoted pounds deliberately, so the indexed price is the one actually charged rather than the currency of whichever datacentre fetched the page.
Where the region comes from, with a fallback for the browser build
const fromIntl = regionFromLocale(Intl.DateTimeFormat().resolvedOptions().locale);
if (fromIntl) return fromIntl;
if (typeof navigator !== 'undefined') return regionFromLocale(navigator.language);
Intl is the one source present on iOS, Android and the web, and Hermes ships it. The navigator.language fallback is there for the same code running in a browser, where Intl can report a locale with no region at all. The app ships as a browser build too, at app.munchable.app, so that second line is not hypothetical.
The result is resolved once per launch and cached. A phone's region does not change while the app is open, and re-deriving it for every render of every price is work for a value that cannot have moved.
There is one more guard underneath all of it. Every price in the app goes through Intl.NumberFormat, in exactly one function, wrapped:
function money(format: () => string, pence: number): string {
try {
return format();
} catch {
return `£${(pence / 100).toFixed(2).replace(/\.00$/, '')}`;
}
}
A runtime without Intl renders plain pounds, which is what we charge anyway, instead of taking the paywall down with it. One place makes the assumption, so one place catches it being wrong.
The device and the IP disagree, and the device wins
A British phone in Spain is the interesting case. The website would resolve Spain from the request and quote euros. The app resolves GB from the device and quotes pounds.
The device is the better answer, and not just because it is cheaper to read. A phone's region is set by its owner, it survives being offline, and it is available synchronously on the first render, so the paywall paints the right figure immediately with no swap a moment later. A British phone in Spain belongs to someone who still pays in pounds.
Nothing above decides what anybody is charged. It decides which of Munchable's prices gets printed. Premium is bought on the web, and the Checkout Session is created in the currency of the country the buyer's request comes from, against a fixed price per currency rather than a conversion at the till. The two signals agree for almost everyone, and where they do not, the figure on the paywall is a display of a price list rather than a quote. The earlier post on those two signals covers the website half, which is a header and a cookie rather than a parser. The list itself is the thing to get right, and it is one table in a shared package rather than a rate lookup.
Top comments (0)