La Élume Beauté · Chapter 5 of 7

Loyalty without made-up numbers

Jovanus Hartono2026

On this page

The loyalty page shows a member where they stand in the store's programme: their tier, how close they are to the next one, and the vouchers they can use.

Most of it could be built on data the backend already served, but parts of the design had no data behind them yet, and those gaps shaped the page more than anything else.

No made-up numbers

The plan for the page had three rules for those gaps:

  • A number with no source got an honest placeholder instead of a guess: "no fabricated metrics".
  • The design wanted a history of everything that had moved a member's progress, and the only history the backend had came in the wrong shape. The plan was blunt about that too: "Do not invent historical rows from that shape."
  • Even empty lists got a rule: "Empty voucher sections: short empty copy, not placeholder fake cards."

So every part without data either was taken out or shipped as an What a screen shows when there's nothing to list yet, usually a short line of text., and every number on the page came from the backend.

The banner

The banner at the top comes from a single request, which returns the member's tier and their progress. The tier's name picks the banner's artwork, and whether the text on top of it is light or dark. The progress bar's width is clamped between empty and full, and a CSS transition eases it into place when it changes:

const progress = Math.min(1, Math.max(0, rawProgress)); // never below 0%, never past 100%

<div
  className="h-1 transition-[width] duration-300"
  style={{ width: `${progress * 100}%` }}
/>

At the top tier there's nothing left to aim for, so the target disappears.

The first design had a second progress bar that no endpoint could fill yet. It shipped as an empty bar with a note saying it was coming soon, and the banner now has one bar.

Vouchers

Vouchers come from one more request, and the store splits them in two on its own side: the ones meant for your tier, and everything else. A voucher counts as yours when its target matches your tier's name, ignoring case and stray spaces:

const normalise = (name?: string) => name?.trim().toLowerCase() ?? "";

const isForYou = (voucher: Voucher, tierName: string) =>
  normalise(voucher.target) === normalise(tierName); // " Gold " matches "gold"
Two lists of vouchers. Under Exclusive vouchers, two offers for gold members, each with a minimum spend, an expiry date, a code with a copy button and a Use now link. Under General vouchers, two more offers laid out the same way.
The two voucher lists, with made-up vouchers.

Each voucher gets a title from its description. If it has none, the title is built from its type and value, like "10% off", and failing that it's just "Voucher offer":

const voucherTitle = (voucher: Voucher) => {
  const description = voucher.description?.trim();
  if (description) {
    return description;
  }
  if (voucher.type === "percentage") {
    return `${voucher.value}% off`;
  }
  if (voucher.type === "fixed") {
    return `${formatRupiah(voucher.value)} off`;
  }
  return "Voucher offer";
};

Every row has a button that copies the code with navigator.clipboard.writeText, with a "Copied!" tooltip for two seconds, and a link that takes you straight to checkout. An empty list is one line of text.