CogniPrep prices are in GBP pence internally and rendered in the visitor's own currency. A visitor can also arrive on a link that carries a code, from a creator, a school or a friend, and that code gets banked in the browser and applied when they reach something they can buy.
Those two facts collided in a place I did not expect: the surfaces that mention a price without selling anything. The upgrade prompt on the dashboard home is one. It says what the thing costs and sends you somewhere else to buy it. It has no code field of its own, because there is nothing to check out from it.
Without any extra work, someone who arrived on a creator link saw the full price there, and the discounted one only after clicking through. That reads like a bait and switch in the wrong direction: the number you were shown first was the worse one.
Two components, because there are two situations
The first is a price with a known discount:
export function DiscountedPrice({ pence, chargedPence, className }: DiscountedPriceProps) {
const currency = useDisplayCurrency();
const full = formatForDisplay(pence, currency);
const charged = chargedPence == null ? full : formatForDisplay(chargedPence, currency);
if (chargedPence == null || chargedPence >= pence || charged === full) {
return <Price pence={pence} className={className} />;
}
// Kept on one line so a wrap never strands the struck figure from its replacement.
return (
<span className={cn('whitespace-nowrap', className)} title={conversionNotice(currency) || undefined}>
<s className="opacity-60" aria-hidden>{full}</s>{' '}
{charged}
</span>
);
}
The second wraps it for the surfaces that have no code field, so they can still reflect a code the visitor arrived with:
export function PendingCodePrice({ pence, productType, productId, bundle, className }: PendingCodePriceProps) {
const [applied, setApplied] = useState<AppliedCode | null>(null);
usePendingCode({ productType, productId, bundle }, setApplied);
return <DiscountedPrice pence={pence} chargedPence={applied?.chargedAmount} className={className} />;
}
It renders the plain list price first and strikes it through once the code resolves. A visitor with no banked code makes no request at all, because the hook returns before fetching anything when there is nothing in storage.
Three decisions inside twenty lines
The guard compares formatted strings, not pence. charged === full looks redundant next to chargedPence >= pence, and it is not, because the two numbers go through a currency conversion before anyone sees them. Prices are converted with a fee buffer and then rounded up to the next retail-looking figure for the currency, which is the next .99 where the currency has decimals and the next whole unit where it does not. Two different pence amounts can land on the same displayed figure. The thing that must never appear on screen is a struck price sitting next to an identical price, and the only value that can tell you that is about to happen is the formatted string.
The struck figure is hidden from assistive technology. These prices sit inside buttons. Read inline, the markup turns a button into "Unlock for fifteen pounds thirteen pounds fifty", which is at best confusing and at worst sounds like two prices. The figure that matters to someone listening is the one they will be charged, so the old one gets aria-hidden and the visible strike-through carries the "this used to cost more" meaning for people who can see it.
whitespace-nowrap, deliberately. A struck figure at the end of one line and its replacement at the start of the next is not a price, it is two prices. Keeping the pair unbreakable is cheaper than any amount of clever layout.
And the rule that makes all of it safe to be wrong
None of this is load-bearing. The checkout route re-validates the code and re-prices server side, so the figure on screen is a quote, not a contract. That is what lets the client-side path be optimistic in the first place: it can show a discount it is not completely sure about, because the authority sits somewhere else.
The same principle shapes two smaller behaviours in the shared hook. A code that is refused for one product is only forgotten if the refusal is terminal, because "this code does not apply to interview credits" says nothing about whether it applies to everything else. And when a visitor explicitly removes an auto-applied code, the banked code is cleared too, otherwise a code they just rejected reappears on the next pricing surface they open.
See it
The conversion that all of this rests on is public. Open cogniprep.app/pricing and read the figures. The GBP list prices behind them are £15, £10 and £5, and the rule is: multiply by the currency rate, add a 4 per cent conversion fee buffer, then round up to the next .99.
From Ireland those three come out as €18.99, €12.99 and €6.99. Check it: 15 x 1.1696 x 1.04 = 18.246, and the next .99 above that is 18.99. Then 10 x 1.1696 x 1.04 = 12.163 and you get 12.99, and 5 gives 6.08 and you get 6.99. Your own currency will show different numbers, and the arithmetic is the same one. The "VAT included" line under the price is the only small print, and it is one line on purpose.
The code field itself lives on the purchase surfaces rather than the public pricing page, so the struck-through pair is something you see once you are signed in and looking at something you could buy. The part you can check without an account is the conversion above, which is the half that decides whether a discount is even visible.
Top comments (1)
what is the purpose of using a tag and then hide it?