Loyalty Patternsv1.00

Reward catalog

What the points are for. The spend surface — the missing half of the loop the points counter opens — and the component that finally makes "earn" mean something.

Install

Copy this folder alongside tokens.css.

Affordability is not arithmetic

The sharpest contract question in the kit lives here. The client holds the member's balance, and every item carries a cost — so balance >= cost is one expression away. Do not write it.

Per-member pricing, stock holds, tier gates, fraud rules and regional availability all live server-side. A component that computed affordability locally would sometimes offer what the backend will refuse, and that failure lands at the worst moment: after the member has already decided to want the thing. So each item's state arrives decided, and cost is a display value. The state is the verdict.

This is the missions list's rule — state beats arithmetic — applied to money, where the temptation is strongest.

Item states

Five, server truth, each rendering differently:

state Means Renders
redeemable The member can have this now Full strength, and the only state with an action
insufficient Costs more than they hold Not dimmed — it is reachable, and wanting it is the point of showing it. The server's note says how far away
locked Gated on tier, segment or date Dimmed, with the server's note saying which
sold-out Stock gone Dimmed, kept on the page — desire is data, and a catalog that hides what just sold looks broken to the member who saw it yesterday
redeemed This member already took it Half-faded, with a "redeemed" chip where the redeem button was

insufficient staying at full strength while locked and sold-out dim is deliberate: dimming means "nothing to do here", and there is something to do about a price — earn.

redeemed is not sold-out, and the difference is whose fact it is: one is about the world, the other about this member. Collapsing them tells someone their reward ran out when they are holding it. It is also the state a catalog needs most often: one-per-member rewards are ordinary — a welcome gift, a birthday drink, a tier perk.

It is the missions list's claimed at catalog scale, and it borrows that component's finding wholesale: a settled success and a loss must not look alike. So the redeem control's own position carries the answer — the eye is already going there to look for something to press — and the tile fades half as far as a sold-out one. The word is labels.state("redeemed"), the string the tile already speaks, so there is nothing new to write; the visible chip is hidden from the accessibility tree because that spoken line already carries it.

The note field carries the server's own explanation: "You need 120 more points", "Gold members only". The component never composes these sentences, because it cannot know why the server refused.

The empty state

The first in the kit, and the reason labels.empty is required rather than optional: a catalog can legitimately have nothing in it — season over, everything redeemed, stock exhausted — and an empty grid with no words reads as a loading failure. The difference between "nothing here yet" and "something broke" is one sentence, and it is yours. renderEmpty replaces the whole treatment.

No live region, on purpose

Every previous component with changing state announces it. This one has none: nothing changes inside the catalog that the member did not just do themselves, and the redemption flow that follows a press is yours — it owns its own announcements. A live region here would be the component narrating someone else's work.

Props

Prop Type Notes
items CatalogItem[] In merchandising order. Order, curation and pagination are the host's — this component renders the page it is given.
labels { catalog, redeem, cost(formatted, item), state(s), empty, free?, endsIn? } Required; all copy, screen-reader text included. free and endsIn are optional on the milestone-label rule — only reachable when an item costs nothing or carries a deadline.
layout "grid" | "list" Default grid. Grid packs as many columns as fit; list is one column with thumbnail media.
media boolean The media area. Default true; items without artwork get a neutral placeholder so a mixed catalog aligns.
unit ReactNode The mark beside each price, unless an item overrides it. Hidden from the accessibility tree — labels.cost says it in words.
locale string Default "en", pinned — the points counter's hydration rule.
renderMedia ({ item, index }) => ReactNode Replaces an item's artwork.
renderBadge ({ item }) => ReactNode Replaces the badge.
renderCost ({ item, formatted, free }) => ReactNode Replaces the visible price. The spoken price stays with labels.cost.
renderUrgency ({ item, remaining }) => ReactNode Replaces the countdown chip. remaining is null until the clock is known.
renderAction ({ item, redeem }) => ReactNode Replaces the redeem control.
renderItem ({ item, index }) => ReactNode Replaces a whole tile.
renderEmpty () => ReactNode Replaces the empty state.
tiles CatalogTileConfig color, border, radius, gap, itemMin, mediaAspect, costColor, badgeColor, badgeTextColor, urgencyColor, redeemedColor. Every value accepts any CSS value including var(--your-token).
status the kit's six Host-provided campaign state — a different axis from an item's own.
renderLocked (status) => ReactNode Renders alongside the catalog.
header / footer ReactNode The member's balance belongs in header — pair with the points counter rather than duplicating it.
terms ReactNode Compliance slot.
onRedeem (item) => void Fires from the default control. The component never transacts — see below.

CatalogItem

Field Type Notes
id string Stable across renders.
title string
description string One supporting line.
cost number The price. Display only — the state is the verdict. 0 renders labels.free.
state CatalogItemState See above. Server truth.
unit ReactNode This item's own unit mark, overriding the catalog's.
endsAt string | number When the offer ends, from the server. Drives the countdown; decides nothing.
badge string "New", "Ends Sunday". Text, not an icon.
note string The server's explanation for a refusal.
media ReactNode Artwork. The kit ships none.

The component never transacts. Deducting points is a purchase, with a confirm step, idempotency and a receipt among its failure modes — and it belongs to your flow, exactly as claiming does on the missions list. onRedeem hands you the item; what happens next is yours.

The price is yours, all of it

The component owns one thing about the price — how the digits are grouped — and hands you everything else.

What it keeps, and why: grouped formatting only, with no compact option. 1.2K rounds, and a price that rounds is a price that lies — the member either can or cannot afford 1,240, and "about 1.2K" answers a question nobody asked. Tabular figures throughout, because a catalog is a column of numbers and proportional digits make them unscannable. The locale is a pinned prop, never resolved at run time, or the same price renders two strings and hydration mismatches.

Everything else is a decision you make:

Zero is a real price. A welcome gift, a birthday reward, a tier perk that costs nothing. cost: 0 renders labels.free — your word, whether that is "Free", "On us" or "Included" — and drops the unit mark, because "Free pts" is nonsense. Without labels.free the digit 0 stands, which is why you should supply it: "0 pts" reads as a broken tile, not as a gift.

The unit is per item as well as per catalog. unit on the catalog is the default; unit on an item wins. One programme legitimately spends points for most rewards and stamps for the coffee ones, and a component that forced a single currency would send those programmes away to build their own. labels.cost receives the item for the same reason — a spoken price typed to the digits alone can only name one unit.

The whole treatment is replaceable. renderCost gets the item, the grouped digits and whether it is free, and returns whatever the price should look like: a strikethrough sale price, "£5 + 200 pts", a tier-member discount, a unit that is an icon and a word.

tsx
renderCost={({ item, formatted, free }) =>
  free ? <Gift /> : <Split cash={item.cashPart} points={formatted} />
}

The spoken price stays outside that seam and keeps coming from labels.cost. A host reaching for a custom price treatment is not asking to go silent, and putting both behind one prop is how that happens anyway.

Deadlines are display

An item may carry endsAt from the server. It drives a countdown chip and an outline that escalates in two steps — under a day, then under an hour — and it decides nothing:

An item whose countdown has run out while the server still calls it redeemable is redeemable, and keeps its button.

This is the coupon card's clock rule, second instance, and it generalises for the same reason: a component that read the device clock to withdraw an offer would take a reward away from a member whose phone clock is wrong, and hand one to a member who set theirs back. The server changes state; the next payload says so.

Three details worth keeping if you port this:

  • The copy is yours, and it must handle null. labels.endsIn receives the remaining time and the raw endsAt, and the remaining time is null before the clock is known — during server rendering and on the first client paint. Return an absolute phrasing there ("Ends 31 March") and a relative one after. The component cannot invent either: dates need a locale and a calendar.
  • It is not announced. The countdown changes on its own, and a tile interrupting a member every hour to repeat itself is worse than silence. It renders as visible words rather than as a colour, so "ends soon" survives a member who cannot see the tint.
  • One timer for the whole grid, ticking no faster than the displayed unit can change: an hourly repaint for a three-day offer, per-minute in the last hour. Twelve tiles with twelve per-second timers is a battery cost for numbers that change once a day.

renderUrgency replaces the chip and receives the same computed remaining time, so a ring or a bar needs no clock of its own.

Layout, and what itemMin actually is

The grid packs as many columns as fit the container, each at least tiles.itemMin wide (default 180px). A 320px sheet gets one column, a full page gets four, and there is no breakpoint anywhere — the container-driven acceptance criterion expressed as one declaration.

itemMin is presentation, and it is a minimum, not a width. It is worth being precise about this, because it is the one layout number the component asks for and it looks at first like configuration:

  • It is token sugar. It writes --lk-catalog-item-min and nothing else, so tiles={{ itemMin: "220px" }} and --lk-catalog-item-min: 220px in your own CSS are the same act. Set it wherever you keep your other tokens and never pass the prop.
  • It configures the grid, not the component. No behaviour changes, no state changes, no item renders differently. It is the number CSS auto-fill requires in order to decide a column count: repeat(auto-fill, minmax(<this>, 1fr)). Without a minimum there is no such thing as "as many columns as fit".
  • It is a floor, not a size. Tiles stretch past it to share the row — at 700px with a 180px minimum you get three columns of ~233px, not three of 180px. It answers "how narrow may a tile get before the grid drops a column", which is a question only you can answer, because it depends on your titles, your artwork and how much of a phone you are given.

So: pure presentation, one token deep, and the playground exposes it because changing it visibly changes the layout you are evaluating — which is the panel's whole test. Nothing about the component's contract or behaviour is reachable through it.

Campaign status

Your campaign status Member sees
Open available The live catalog
Season closed ended The catalog, dimmed, with your copy
Not enrolled not-eligible The catalog, dimmed, with your copy
Redemptions used up already-played The catalog, dimmed, with your copy
Redemption cap this period attempts-exhausted The catalog, with your come-back-later copy
Opens with the season not-started The catalog, with an opens-on line

Tokens

Token Default Purpose
--lk-catalog-surface --lk-surface Tile fill.
--lk-catalog-border --lk-border Tile outline.
--lk-catalog-radius --lk-radius Tile corners.
--lk-catalog-gap calc(--lk-space * 3) Space between tiles.
--lk-catalog-item-min 180px The narrowest a grid tile may be.
--lk-catalog-media-aspect 4 / 3 Media shape in the grid.
--lk-catalog-thumb 72px Media size in the list layout.
--lk-catalog-cost-color --lk-text The price. Not accent-driven by default.
--lk-catalog-badge color-mix(--lk-action 14%) Badge background.
--lk-catalog-badge-text --lk-action Badge text.
--lk-catalog-urgency --lk-warning The countdown chip and the urgent tile's outline.
--lk-catalog-redeemed --lk-success The redeemed chip. Coloured by meaning, like the missions list's claimed row.

The countdown is coloured by meaning, not by brand — the same call the tier ladder's risk notice makes. "This is going away" has to read at a glance, and the action colour already means "press things".

The catalog adds nothing to tokens.css.

Accessibility

A real <ul>, so a reader is told how many rewards there are before hearing them. Each item speaks its price in words and its state by name — a tile distinguished by opacity alone says nothing to a screen reader. The redeem control is the kit's quiet button, on the action colour, with a visible focus ring.

The spoken price survives every customisation: renderCost replaces the picture and the words keep coming from labels.cost. A free item speaks its free word, and an item with its own unit speaks that unit.

Countdowns are visible words, never a tint alone, and they are not announced — see above.

There is no gesture, so WCAG 2.5.1 does not apply — and no live region, for the reason above.

When to use it / when not to

Use it wherever points can buy something: the catalog closes the loop the points counter opens, and its redeem handoff pairs with the coupon card (a code-bearing reward lands there) or your own fulfilment.

Prefer the missions list when the items are things to do rather than things to have. Don't reach for it as a product-grid substitute in a commerce app — it renders rewards, not SKUs, and it deliberately has no cart.

Common mistakes

Computing affordability from balance >= cost; hiding sold-out items; dimming insufficient items; composing refusal sentences client-side; compact-formatting a price; printing "0" where a reward is free; using sold-out for a reward the member has already taken; dimming a redeemed item as far as a sold-out one; putting a unit mark after the free word; assuming one catalog spends one currency; letting a finished countdown withdraw an offer the server still offers; announcing the countdown; transacting inside the component; and shipping an empty catalog with no labels.empty worth reading.