DEV Community

Daniel Pertu
Daniel Pertu

Posted on

One regex reads a brand's cheapest price off its own pricing page, and 30 characters decide if it is monthly

Every creator Nakodo suggests comes with a sentence explaining the fee. The last line of it is the one brands actually read:

About 13K views on a sponsored post, so £100 is £7.50 per 1,000 views. One new customer, about £470 in their first year, covers it.

That last clause needs a number the app has no way of knowing: what one new customer is worth to this brand in their first year. The fee itself comes from the creator's views and a rate card, so this number changes no amount. It only decides whether the brand is being told "one new customer covers it" or "nine new customers cover it", which is the difference between a fee that feels obvious and a fee that feels like a risk.

The obvious way to get it is to ask. We had that version. It is a field on a form with a currency symbol in front of it, and the honest description of what it produces is an empty box, because nobody wants to do that arithmetic for a tool they are still evaluating.

We have a rule about this now: anything readable off the brand's own site gets read, shown as found, and given a way to change it. Never an empty box. The app already reads the site to write the campaign brief, which is the first thing it does and is described on how it works. The pricing text is already in hand, so the question is only how to get a number out of it.

No model for this one

This is the sort of job an AI call would do fine, and we did not use one. The brand looks at this number the moment the page loads and either accepts it or corrects it, so the cost of being wrong is one glance. A regex is free, instant, deterministic and testable, and its failure mode is the field falling back to empty, which is where the whole feature started.

const MONEY = /(£|€|\$|\b(?:GBP|EUR|USD)\b)\s?(\d[\d,]*(?:\.\d+)?)|(\d[\d,]*(?:\.\d+)?)\s?(£|€|\b(?:GBP|EUR|USD)\b)/gi;
const SYMBOL_CURRENCY: Record<string, Currency> = { "£": "gbp", GBP: "gbp", "€": "eur", EUR: "eur", $: "usd", USD: "usd" };
Enter fullscreen mode Exit fullscreen mode

Two alternatives, because both orders happen in the wild: £12 and 12 EUR. That is why the capture groups come in pairs and the body reads m[2] ?? m[3] for the amount and m[1] ?? m[4] for the currency. Four groups rather than two named ones is the price of one pass over the text.

The 30 characters after the number

The interesting decision is not finding the money. It is working out what period the money is for.

const after = pricing.slice(m.index + m[0].length, m.index + m[0].length + 30).toLowerCase();
const month = after.search(/\/\s*mo?\b|\bmo\b|month|pcm/);
const year = after.search(/\/\s*y(?:r|ear)?\b|year|annual|\byr\b/);
const monthly = month >= 0 && (year < 0 || month < year);
values.push(((monthly ? 12 : 1) * amount * CURRENCIES[currency].rate) / CURRENCIES[from].rate);
Enter fullscreen mode Exit fullscreen mode

A 30 character window after the match, both patterns searched, and whichever appears first wins. That last clause does more work than it looks like. Real pricing pages say things like:

9.99 / month, billed annually at 99 / yr

A naive "does it mention a year" check reads the first price as yearly and undercounts the brand by a factor of twelve. Position breaks the tie, and both prices on that line are then evaluated on their own terms: 9.99 monthly becomes 119.88 a year, 99 stays 99, and the cheaper of the two wins.

The window is small on purpose. Widen it to a hundred characters and the word "year" from the next sentence starts voting on this price.

Cheapest wins, and the result is a guess

const lowest = Math.min(...values);
return Number.isFinite(lowest) && lowest >= 1 ? niceAmount(lowest) : null;
Enter fullscreen mode Exit fullscreen mode

The cheapest paid price, not the average and not the headline plan, because the marginal customer a creator sends is far likelier to buy the entry tier. Math.min over an empty spread is Infinity, and Number.isFinite catches that, which is why there is no separate length check. The >= 1 floor throws away free tiers and stray pence. niceAmount is the same log-scale rounding every other amount in the app goes through, so the field reads 250 rather than 262.17.

Five cases, with the campaign in pounds:

Pricing text Result
Plans start at £12/month, or £120 a year. 120
Free. Pro $29 per month. Teams $99/mo. 250
Our chairs cost from £249. 250
£9.99/mo billed annually at £99/yr 100
No prices here, call us. null

Row two shows the currency crossing: 29 dollars a month is 348 dollars a year, which at our fixed rate is about 263 pounds, rounded to 250. Row three is the right answer for a furniture shop, where one sale is the whole first-year value, and it is right by accident: no period word inside 30 characters, so it is treated as one-off. Row four is the tie-break earning its keep.

The found value is not stored, and that is the useful part

The field in the editor is an ordinary amount input, prefilled, with one line under it that changes depending on where the number came from:

{terms.customerValue
  ? "In their first year, as you set it. Each creator's page says how many new customers their fee needs."
  : found
    ? "In their first year, from the prices on your site. Each creator's page says how many new customers their fee needs."
    : "In their first year. Your site gives no prices to work it out from; with it, each creator's page says how many new customers their fee needs."}
Enter fullscreen mode Exit fullscreen mode

Three states, three sentences, because "where did this number come from" is the first thing anyone asks about a prefilled field. The third one explains what they lose by leaving it blank rather than scolding them for it.

The onChange is where the design decision lives:

onChange={(n) => onCustomerValue(n > 0 && n !== found ? n : null)}
Enter fullscreen mode Exit fullscreen mode

Typing the found value back in stores null, not that value. So a brand that agrees with the guess stays subscribed to the guess: change the prices on your pricing page and the number follows. Only a brand that disagrees pins a number of their own. The read side does the same thing in one line:

return terms.customerValue || customerValueFrom(s.pricing, terms.currency);
Enter fullscreen mode Exit fullscreen mode

A stored override, or a fresh read. There is no third branch, no "source" column, and no stale copy of somebody's pricing page in our database.

Being wrong in public is the feature

If the guess is wrong the brand fixes it in two seconds. If we had asked instead, the field would have stayed empty and the fee explanation would have lost its last line.

Our own guide on working back from what a customer is worth is the manual version of this calculation, and the TikTok and Instagram pricing guide does it with plays instead of views. Both of them ask the reader for the number this regex tries to save them typing.

Top comments (0)