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.
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
redeemableis 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.endsInreceives the remaining time and the rawendsAt, and the remaining time isnullbefore 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-minand nothing else, sotiles={{ itemMin: "220px" }}and--lk-catalog-item-min: 220pxin 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-fillrequires 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.