Loyalty program
A loyalty program as sections. The levels and their benefits, ways to earn points, rewards to redeem, the member card with progress and activity, and a referral with a link to share.
One of 4 components in Account and loyalty
Variants
Details
What Loyalty program brings along, read from the registry and the source.
- Category
- Account and loyalty
- Area
- E-Commerce
- Variants
- 5
- Version
- 1.0.1 · since 1.1.0
- File
- components/ui/loyalty.tsx
- Brings along
- utilskitbuttoniconrevealmotionsectionnumber-tickerproduct-image
- npm packages
- None
- Languages
- English and German, 29 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The LoyaltyProps interface from components/ui/loyalty.tsx, with the comments from the code.
variantoptionalLoyaltyVarianttiersearnrewardsstatusreferral
eyebrowoptionalstringtitleoptionalstringdescriptionoptionalstringprogramoptionalstringThe program name, e.g. "Nordhaus Circle"
tiersoptionalLoyaltyTier[]waysoptionalEarnWay[]rewardsoptionalLoyaltyReward[]memberoptional{ name: string; points: number; since?: string; number?: string }activityoptionalLoyaltyActivity[]referraloptional{ link: string; youGet: string; theyGet: string; friends?: { name: string; status: string; points?: number }[] }onRedeemoptional(reward: LoyaltyReward) => void
Built-in texts
Loyalty program writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 29 texts
| Key | English | German |
|---|---|---|
| points | English points | German Punkte |
| pts | English pts | German P. |
| from | English from | German ab |
| youAreHere | English You are here | German Hier stehst du |
| yourLevel | English Your level | German Deine Stufe |
| toGo | English points to | German Punkte bis |
| topLevel | English You reached the top level | German Du hast die höchste Stufe erreicht |
| earnNow | English Earn now | German Jetzt sammeln |
| earned | English Earned | German Gesammelt |
| balance | English Your balance | German Dein Guthaben |
| redeem | English Redeem | German Einlösen |
| redeemed | English Redeemed | German Eingelöst |
| notEnough | English more needed | German fehlen noch |
| memberNo | English Member no. | German Mitgliedsnr. |
| since | English Member since | German Mitglied seit |
| recent | English Recent activity | German Letzte Aktivität |
| seeAll | English See all activity | German Alle Aktivitäten |
| copy | English Copy link | German Link kopieren |
| yourLink | English Your link | German Dein Link |
| copied | English Link copied | German Link kopiert |
| share | English Share by email | German Per E-Mail teilen |
| youGet | English You get | German Du bekommst |
| theyGet | English Your friend gets | German Deine Freundin oder dein Freund bekommt |
| friends | English Friends you invited | German Deine Einladungen |
| perks | English Benefits | German Vorteile |
| howItWorks | English How it works | German So geht es |
| step1 | English Share your link | German Link teilen |
| step2 | English Your friend orders | German Die Person bestellt |
| step3 | English You both get your reward | German Ihr bekommt beide euer Geschenk |
Accessibility
Read from the source of the component.
- Sets aria-live, so screen readers announce purpose and state.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 4 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 641 lines
'use client';
import { useEffect, useId, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger, useScrollProgress } from '@/components/ui/motion';
import { Eyebrow, Section } from '@/components/ui/section';
import { NumberTicker } from '@/components/ui/number-ticker';
import { ShopMotion, Swap, formatNumber } from '@/components/ui/product-image';
/*
* A loyalty program: what the tiers give, how to earn points, what points
* buy, where a member stands and what a friend gets for a referral.
*
* tiers the levels side by side, a line shows where you stand, the next level glows
* earn ways to earn points as tiles, each with its points and a one tap action
* rewards rewards to redeem, with a points balance that counts down on redeem
* status the member card: points, level, progress to the next, recent activity
* referral invite a friend: a link to copy, what both get, friends who joined
*/
export type LoyaltyVariant = 'tiers' | 'earn' | 'rewards' | 'status' | 'referral';
export interface LoyaltyTier {
id: string;
name: string;
/** Points needed to reach it */
from: number;
perks: string[];
/** e.g. "1 point per €1" */
rate?: string;
}
export interface EarnWay {
id: string;
icon?: IconName;
title: string;
text?: string;
points: number;
action?: string;
done?: boolean;
}
export interface LoyaltyReward {
id: string;
title: string;
text?: string;
cost: number;
icon?: IconName;
image?: React.ReactNode;
}
export interface LoyaltyActivity {
date: string;
text: string;
points: number;
}
export interface LoyaltyProps {
variant?: LoyaltyVariant;
eyebrow?: string;
title?: string;
description?: string;
/** The program name, e.g. "Nordhaus Circle" */
program?: string;
tiers?: LoyaltyTier[];
ways?: EarnWay[];
rewards?: LoyaltyReward[];
member?: { name: string; points: number; since?: string; number?: string };
activity?: LoyaltyActivity[];
referral?: { link: string; youGet: string; theyGet: string; friends?: { name: string; status: string; points?: number }[] };
onRedeem?: (reward: LoyaltyReward) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
points: 'points',
pts: 'pts',
from: 'from',
youAreHere: 'You are here',
yourLevel: 'Your level',
toGo: 'points to',
topLevel: 'You reached the top level',
earnNow: 'Earn now',
earned: 'Earned',
balance: 'Your balance',
redeem: 'Redeem',
redeemed: 'Redeemed',
notEnough: 'more needed',
memberNo: 'Member no.',
since: 'Member since',
recent: 'Recent activity',
seeAll: 'See all activity',
copy: 'Copy link',
yourLink: 'Your link',
copied: 'Link copied',
share: 'Share by email',
youGet: 'You get',
theyGet: 'Your friend gets',
friends: 'Friends you invited',
perks: 'Benefits',
howItWorks: 'How it works',
step1: 'Share your link',
step2: 'Your friend orders',
step3: 'You both get your reward',
},
de: {
points: 'Punkte',
pts: 'P.',
from: 'ab',
youAreHere: 'Hier stehst du',
yourLevel: 'Deine Stufe',
toGo: 'Punkte bis',
topLevel: 'Du hast die höchste Stufe erreicht',
earnNow: 'Jetzt sammeln',
earned: 'Gesammelt',
balance: 'Dein Guthaben',
redeem: 'Einlösen',
redeemed: 'Eingelöst',
notEnough: 'fehlen noch',
memberNo: 'Mitgliedsnr.',
since: 'Mitglied seit',
recent: 'Letzte Aktivität',
seeAll: 'Alle Aktivitäten',
copy: 'Link kopieren',
yourLink: 'Dein Link',
copied: 'Link kopiert',
share: 'Per E-Mail teilen',
youGet: 'Du bekommst',
theyGet: 'Deine Freundin oder dein Freund bekommt',
friends: 'Deine Einladungen',
perks: 'Vorteile',
howItWorks: 'So geht es',
step1: 'Link teilen',
step2: 'Die Person bestellt',
step3: 'Ihr bekommt beide euer Geschenk',
},
};
type Texts = (typeof TEXTS)['en'];
const FOCUS = 'outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]';
const CARD = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const ACCENT_TEXT = 'text-[color-mix(in_oklab,var(--primary)_78%,var(--foreground))]';
const ACCENT_SOFT = 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)]';
function Head({ eyebrow, title, description, center, aside }: { eyebrow?: string; title?: string; description?: string; center?: boolean; aside?: React.ReactNode }) {
if (!title) return null;
return (
<div className={cn('mb-10 flex flex-col gap-6 sm:mb-12', center ? 'items-center text-center' : 'sm:flex-row sm:items-end sm:justify-between')}>
<div className={cn('max-w-[40rem]', center && 'mx-auto')}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && (
<p className={cn('kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')} style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
{description}
</p>
)}
</div>
{aside}
</div>
);
}
const tierIndex = (tiers: LoyaltyTier[], points: number) => {
let i = 0;
tiers.forEach((x, k) => {
if (points >= x.from) i = k;
});
return i;
};
/* ------------------------------------------------------------------ */
/* tiers */
/* ------------------------------------------------------------------ */
function Tiers(p: LoyaltyProps & { t: Texts }) {
const { t } = p;
const locale = useLocale();
const tiers = p.tiers ?? [];
const pts = p.member?.points ?? 0;
const cur = tierIndex(tiers, pts);
const line = useScrollProgress<HTMLDivElement>({ start: 0.1, end: 0.45 });
// Where the member stands on the line: between the start of the current level and the next one
const next = tiers[cur + 1];
const within = next ? (pts - tiers[cur].from) / Math.max(1, next.from - tiers[cur].from) : 0;
const at = tiers.length > 1 ? (cur + within) / (tiers.length - 1) : 0;
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} center />
{p.member && (
<div ref={line} className="relative mb-8 mt-14 hidden lg:block" style={{ marginInline: `${round(50 / Math.max(1, tiers.length), 3)}%` }}>
<div className="relative h-1.5 rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]">
{/* A mark above the middle of every level card */}
{tiers.map((x, i) => (
<span key={x.id} aria-hidden className={cn('absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-background', i <= cur ? 'bg-primary' : 'bg-border-strong')} style={{ left: `${round((i / Math.max(1, tiers.length - 1)) * 100, 2)}%` }} />
))}
<span aria-hidden className="absolute inset-y-0 left-0 origin-left rounded-full bg-primary" style={{ width: `${round(at * 100, 2)}%`, transform: 'scaleX(var(--kit-progress, 1))' }} />
<span className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2" style={{ left: `${round(at * 100, 2)}%` }}>
<span className="relative block size-4 rounded-full border-[3px] border-background bg-primary shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_25%,transparent)]" />
<span className="absolute bottom-full left-1/2 mb-3 -translate-x-1/2 whitespace-nowrap rounded-lg bg-foreground px-2.5 py-1 text-[12px] font-medium text-background shadow-[var(--shadow-md)]">
{t.youAreHere} · {formatNumber(locale, pts)}
</span>
</span>
</div>
</div>
)}
<Stagger step={90} as="ol" className={cn('grid grid-cols-1 gap-4 sm:grid-cols-2 lg:gap-5', tiers.length >= 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3')}>
{tiers.map((x, i) => {
const on = i === cur && !!p.member;
const reached = !!p.member && i <= cur;
return (
<li key={x.id}>
<article
className={cn(
CARD,
'relative flex h-full flex-col overflow-hidden p-6 transition-[transform,box-shadow] duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 motion-reduce:transition-none',
on && 'shadow-[var(--highlight),0_0_0_1.5px_var(--primary),var(--shadow-md)]',
)}
>
{on && <div aria-hidden className="pointer-events-none absolute -right-12 -top-12 size-40 rounded-full bg-[radial-gradient(circle,color-mix(in_oklab,var(--primary)_28%,transparent),transparent_70%)]" data-clip-ok />}
<div className="relative flex items-start justify-between gap-3">
<span className={cn('grid size-11 place-items-center rounded-xl text-[15px] font-semibold', reached ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground')} aria-hidden>
{i + 1}
</span>
{on && <span className={cn('rounded-full px-2.5 py-1 text-[12px] font-medium', ACCENT_SOFT, ACCENT_TEXT)}>{t.yourLevel}</span>}
</div>
<h3 className="relative mt-5 text-[20px] font-semibold tracking-[-0.015em] text-foreground">{x.name}</h3>
<p className="relative text-[13.5px] text-muted-foreground">
{i === 0 ? x.rate : `${t.from} ${formatNumber(locale, x.from)} ${t.points}`}
{i > 0 && x.rate && ` · ${x.rate}`}
</p>
<ul className="relative mt-5 space-y-2.5 border-t border-border pt-5">
{x.perks.map((perk) => (
<li key={perk} className="flex items-start gap-2.5 text-[14px] leading-snug text-foreground">
<Icon name="check" size={16} className={cn('mt-px shrink-0', reached ? ACCENT_TEXT : 'text-subtle-foreground')} />
{perk}
</li>
))}
</ul>
</article>
</li>
);
})}
</Stagger>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* earn */
/* ------------------------------------------------------------------ */
function Earn(p: LoyaltyProps & { t: Texts }) {
const { t } = p;
const locale = useLocale();
const [done, setDone] = useState<Record<string, boolean>>(() => Object.fromEntries((p.ways ?? []).map((w) => [w.id, !!w.done])));
const total = (p.ways ?? []).filter((w) => done[w.id]).reduce((s, w) => s + w.points, 0);
return (
<Section className={p.className}>
<ShopMotion />
<Head
eyebrow={p.eyebrow}
title={p.title}
description={p.description}
aside={
<div className={cn(CARD, 'flex items-center gap-3 px-4 py-3')} aria-live="polite">
<span className={cn('grid size-10 place-items-center rounded-full', ACCENT_SOFT, ACCENT_TEXT)}>
<Icon name="sparkle" size={18} />
</span>
<span>
<span className="block text-[12.5px] text-muted-foreground">{t.earned}</span>
<Swap value={total}>
<span className="text-[20px] font-semibold tabular-nums text-foreground">
{formatNumber(locale, total)} <span className="text-[13px] font-normal text-muted-foreground">{t.points}</span>
</span>
</Swap>
</span>
</div>
}
/>
<Stagger step={70} as="ul" className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-5">
{(p.ways ?? []).map((w) => {
const on = !!done[w.id];
return (
<li key={w.id}>
<article className={cn(CARD, 'group/w flex h-full flex-col p-5 transition-[box-shadow] duration-300 sm:p-6', on && 'bg-[color-mix(in_oklab,var(--success)_5%,var(--card))]')}>
<div className="flex items-start justify-between gap-3">
<span className={cn('grid size-11 place-items-center rounded-xl transition-[background-color,color,transform] duration-500 ease-[var(--ease-kit)] group-hover/w:-rotate-6 group-hover/w:scale-105', on ? 'bg-success text-[oklch(0.99_0_0)]' : 'bg-muted text-foreground')}>
<Icon name={on ? 'check' : w.icon ?? 'sparkle'} size={19} />
</span>
<span className={cn('rounded-full px-2.5 py-1 text-[13px] font-semibold tabular-nums', on ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground' : cn(ACCENT_SOFT, ACCENT_TEXT))}>
+{formatNumber(locale, w.points)}
</span>
</div>
<h3 className="mt-5 text-[16px] font-semibold text-foreground">{w.title}</h3>
{w.text && <p className="mt-1 text-pretty text-[14px] leading-relaxed text-muted-foreground">{w.text}</p>}
<div className="mt-auto pt-5">
<Button
size="sm"
variant={on ? 'secondary' : 'outline'}
className="h-11 w-full sm:h-10"
disabled={on}
iconLeft={on ? 'check' : undefined}
onClick={() => setDone((d) => ({ ...d, [w.id]: true }))}
>
<Swap value={on}>{on ? `${t.earned} · +${formatNumber(locale, w.points)}` : w.action ?? t.earnNow}</Swap>
</Button>
</div>
</article>
</li>
);
})}
</Stagger>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* rewards */
/* ------------------------------------------------------------------ */
function Rewards(p: LoyaltyProps & { t: Texts }) {
const { t } = p;
const locale = useLocale();
const [balance, setBalance] = useState(p.member?.points ?? 0);
const [taken, setTaken] = useState<Record<string, boolean>>({});
const [last, setLast] = useState<{ id: string; n: number } | null>(null);
return (
<Section className={p.className}>
<ShopMotion />
<Head
eyebrow={p.eyebrow}
title={p.title}
description={p.description}
aside={
<div className={cn(CARD, 'relative px-5 py-3.5')} aria-live="polite">
<span className="block text-[12.5px] text-muted-foreground">{t.balance}</span>
<span className="relative flex items-baseline gap-1.5">
<Swap value={balance}>
<span className="text-[26px] font-semibold leading-tight tracking-[-0.02em] tabular-nums text-foreground">{formatNumber(locale, balance)}</span>
</Swap>
<span className="text-[13px] text-muted-foreground">{t.points}</span>
{last && (
<span key={last.n} aria-hidden className="pointer-events-none absolute -top-2 right-0 animate-[kitshop-fly_1s_var(--ease-kit)_both] rounded-full bg-foreground px-2 py-0.5 text-[11px] font-semibold tabular-nums text-background motion-reduce:hidden">
-{formatNumber(locale, (p.rewards ?? []).find((r) => r.id === last.id)?.cost ?? 0)}
</span>
)}
</span>
</div>
}
/>
<Stagger step={70} as="ul" className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 lg:gap-5">
{(p.rewards ?? []).map((r) => {
const on = !!taken[r.id];
const short = r.cost - balance;
const can = short <= 0;
const share = Math.min(1, balance / Math.max(1, r.cost));
return (
<li key={r.id}>
<article className={cn(CARD, 'group/r flex h-full flex-col overflow-hidden transition-[transform,box-shadow] duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-lg)] motion-reduce:transition-none')}>
<div className="relative grid aspect-[16/10] place-items-center overflow-hidden bg-surface-2">
{r.image ? (
<span className="absolute inset-0 transition-transform duration-700 ease-[var(--ease-kit)] group-hover/r:scale-105 [&>*]:size-full [&>*]:object-cover">{r.image}</span>
) : (
<span className={cn('grid size-16 place-items-center rounded-2xl transition-transform duration-500 ease-[var(--ease-kit)] group-hover/r:-rotate-6 group-hover/r:scale-110', ACCENT_SOFT, ACCENT_TEXT)}>
<Icon name={r.icon ?? 'gift'} size={28} />
</span>
)}
<span className="absolute left-3 top-3 rounded-full bg-background/90 px-2.5 py-1 text-[12.5px] font-semibold tabular-nums text-foreground backdrop-blur">
{formatNumber(locale, r.cost)} {t.pts}
</span>
</div>
<div className="flex flex-1 flex-col p-5">
<h3 className="text-[16px] font-semibold text-foreground">{r.title}</h3>
{r.text && <p className="mt-1 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{r.text}</p>}
<div className="mt-auto pt-5">
{!can && !on && (
<div className="mb-3">
<span className="relative block h-1 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" aria-hidden>
<span className="absolute inset-0 origin-left rounded-full bg-foreground transition-transform duration-700 ease-[var(--ease-kit)]" style={{ transform: `scaleX(${round(share, 3)})` }} />
</span>
<p className="mt-1.5 text-[12.5px] text-muted-foreground">
<span className="tabular-nums text-foreground">{formatNumber(locale, short)}</span> {t.notEnough}
</p>
</div>
)}
<Button
size="sm"
variant={on ? 'secondary' : can ? 'primary' : 'outline'}
className="h-11 w-full sm:h-10"
disabled={on || !can}
iconLeft={on ? 'check' : 'gift'}
onClick={() => {
setTaken((x) => ({ ...x, [r.id]: true }));
setBalance((b) => b - r.cost);
setLast((l) => ({ id: r.id, n: (l?.n ?? 0) + 1 }));
p.onRedeem?.(r);
}}
>
<Swap value={on}>{on ? t.redeemed : t.redeem}</Swap>
</Button>
</div>
</div>
</article>
</li>
);
})}
</Stagger>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* status */
/* ------------------------------------------------------------------ */
function Status(p: LoyaltyProps & { t: Texts }) {
const { t, member } = p;
const locale = useLocale();
const tiers = p.tiers ?? [];
const pts = member?.points ?? 0;
const cur = tierIndex(tiers, pts);
const next = tiers[cur + 1];
const share = next ? Math.min(1, (pts - tiers[cur].from) / Math.max(1, next.from - tiers[cur].from)) : 1;
const [ref, inView] = useInView<HTMLDivElement>();
const ring = round(2 * Math.PI * 70);
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-6">
{/* The member card */}
<Reveal effect="scale" className="relative overflow-hidden rounded-3xl bg-bezel p-6 text-white shadow-[var(--shadow-lg)] sm:p-8">
<div aria-hidden data-clip-ok className="pointer-events-none absolute -right-24 -top-24 size-72 rounded-full bg-[radial-gradient(circle,color-mix(in_oklab,var(--primary)_55%,transparent),transparent_68%)] opacity-80" />
<div aria-hidden className="pointer-events-none absolute inset-0 bg-[radial-gradient(rgb(255_255_255/0.07)_1px,transparent_1px)] bg-[size:18px_18px]" />
<div ref={ref} className="relative flex h-full flex-col gap-8 sm:flex-row sm:items-center">
<span className="relative grid size-40 shrink-0 place-items-center self-center sm:self-auto">
<svg viewBox="0 0 160 160" className="absolute inset-0 -rotate-90" aria-hidden>
<circle cx="80" cy="80" r="70" fill="none" strokeWidth="10" stroke="rgb(255 255 255 / 0.12)" />
<circle
cx="80"
cy="80"
r="70"
fill="none"
strokeWidth="10"
strokeLinecap="round"
className="stroke-primary transition-[stroke-dashoffset] duration-[1600ms] ease-[var(--ease-kit)] motion-reduce:transition-none"
strokeDasharray={ring}
strokeDashoffset={round(ring * (1 - (inView ? share : 0)))}
/>
</svg>
<span className="relative text-center">
<NumberTicker value={pts} duration={1600} className="block text-[32px] font-semibold leading-none tracking-[-0.03em] tabular-nums" />
<span className="mt-1 block text-[12.5px] text-white/70">{t.points}</span>
</span>
</span>
<div className="min-w-0">
<p className="text-[13px] font-medium uppercase tracking-[0.12em] text-white/70">{p.program}</p>
<p className="mt-1 text-[28px] font-semibold tracking-[-0.02em]">{tiers[cur]?.name}</p>
<p className="mt-1 text-[14px] text-white/75">{member?.name}</p>
<p className="mt-4 text-pretty text-[14px] text-white/85">
{next ? (
<>
<span className="font-semibold tabular-nums text-white">{formatNumber(locale, next.from - pts)}</span> {t.toGo} {next.name}
</>
) : (
t.topLevel
)}
</p>
<div className="mt-5 flex flex-wrap gap-x-6 gap-y-1 text-[12.5px] text-white/65">
{member?.number && (
<span>
{t.memberNo} <span className="tabular-nums text-white/90">{member.number}</span>
</span>
)}
{member?.since && (
<span>
{t.since} <span className="text-white/90">{member.since}</span>
</span>
)}
</div>
</div>
</div>
</Reveal>
<Reveal effect="rise" delay={120} className={cn(CARD, 'flex flex-col p-5 sm:p-6')}>
<h3 className="text-[15px] font-semibold text-foreground">{t.recent}</h3>
<ul className="mt-3 divide-y divide-border">
{(p.activity ?? []).map((a, i) => (
<li key={i} className="flex items-center gap-3 py-3.5">
<span className={cn('grid size-9 shrink-0 place-items-center rounded-full', a.points >= 0 ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground' : 'bg-muted text-muted-foreground')}>
<Icon name={a.points >= 0 ? 'plus' : 'gift'} size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] text-foreground">{a.text}</span>
<span className="block text-[12.5px] text-muted-foreground">{a.date}</span>
</span>
<span className={cn('shrink-0 text-[14px] font-semibold tabular-nums', a.points >= 0 ? 'text-success-foreground' : 'text-foreground')}>
{a.points >= 0 ? '+' : '-'}
{formatNumber(locale, Math.abs(a.points))}
</span>
</li>
))}
</ul>
<a href="#" className={cn('group/all relative mt-auto after:absolute after:inset-x-0 after:-inset-y-1 after:content-[""] inline-flex items-center gap-1.5 pt-4 text-[14px] font-medium text-foreground', FOCUS)}>
{t.seeAll}
<Icon name="arrow-right" size={14} className="transition-transform duration-300 group-hover/all:translate-x-1" />
</a>
</Reveal>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* referral */
/* ------------------------------------------------------------------ */
function Referral(p: LoyaltyProps & { t: Texts }) {
const { t } = p;
const locale = useLocale();
const ref = p.referral ?? { link: '', youGet: '', theyGet: '' };
const [copied, setCopied] = useState(false);
const id = useId();
useEffect(() => {
if (!copied) return;
const x = window.setTimeout(() => setCopied(false), 1800);
return () => window.clearTimeout(x);
}, [copied]);
const steps = [
{ icon: 'link' as IconName, text: t.step1 },
{ icon: 'bag' as IconName, text: t.step2 },
{ icon: 'gift' as IconName, text: t.step3 },
];
return (
<Section className={p.className}>
<ShopMotion />
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-center lg:gap-16">
<div>
{p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{p.title}</h2>
{p.description && (
<p className="kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
{p.description}
</p>
)}
<div className="mt-8 grid grid-cols-1 gap-3 sm:grid-cols-2">
{[
[t.youGet, ref.youGet],
[t.theyGet, ref.theyGet],
].map(([k, v], i) => (
<Reveal key={k} effect="rise" delay={i * 90} className={cn(CARD, 'p-5')}>
<p className="text-[12.5px] text-muted-foreground">{k}</p>
<p className="mt-1 text-[20px] font-semibold tracking-[-0.015em] text-foreground">{v}</p>
</Reveal>
))}
</div>
<label htmlFor={id} className="mb-2 mt-8 block text-[13.5px] font-medium text-foreground">
{t.yourLink}
</label>
<div className="flex gap-2">
<input id={id} readOnly value={ref.link} onFocus={(e) => e.currentTarget.select()} className="h-12 w-0 min-w-0 flex-1 rounded-lg border border-input bg-surface px-3.5 text-[14.5px] text-foreground outline-none transition-[border-color,box-shadow] focus:border-border-strong focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_22%,transparent)]" />
<Button
size="lg"
className="shrink-0"
iconLeft={copied ? 'check' : 'copy'}
onClick={() => {
navigator.clipboard?.writeText(ref.link).catch(() => {});
setCopied(true);
}}
>
<Swap value={copied}>{copied ? t.copied : t.copy}</Swap>
</Button>
</div>
<Button href={`mailto:?body=${encodeURIComponent(ref.link)}`} variant="ghost" className="mt-2 -ms-3" iconLeft="mail">
{t.share}
</Button>
</div>
<div className="space-y-5">
<div className={cn(CARD, 'p-5 sm:p-6')}>
<h3 className="text-[15px] font-semibold text-foreground">{t.howItWorks}</h3>
<Stagger step={110} as="ol" className="mt-5 grid grid-cols-3 gap-3">
{steps.map((s, i) => (
<li key={s.text} className="relative flex flex-col items-center text-center">
{i < steps.length - 1 && <span aria-hidden className="absolute left-[calc(50%+1.75rem)] right-[calc(-50%+1.75rem)] top-6 h-px border-t border-dashed border-border-strong" />}
<span className={cn('relative grid size-12 place-items-center rounded-full', i === 2 ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground')}>
<Icon name={s.icon} size={19} />
</span>
<span className="mt-3 text-pretty text-[13px] leading-snug text-foreground">{s.text}</span>
</li>
))}
</Stagger>
</div>
{ref.friends?.length ? (
<div className={cn(CARD, 'p-5 sm:p-6')}>
<h3 className="text-[15px] font-semibold text-foreground">{t.friends}</h3>
<ul className="mt-3 divide-y divide-border">
{ref.friends.map((f, i) => (
<li key={f.name} className="flex items-center gap-3 py-3 animate-[kitshop-in_0.45s_var(--ease-kit)_both]" style={{ animationDelay: `${i * 80}ms` }}>
<span className="grid size-9 shrink-0 place-items-center rounded-full bg-muted text-[13px] font-semibold text-foreground">{f.name.charAt(0)}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-medium text-foreground">{f.name}</span>
<span className="block text-[12.5px] text-muted-foreground">{f.status}</span>
</span>
{f.points ? <span className="shrink-0 text-[14px] font-semibold tabular-nums text-success-foreground">+{formatNumber(locale, f.points)}</span> : null}
</li>
))}
</ul>
</div>
) : null}
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* The block */
/* ------------------------------------------------------------------ */
/**
* A loyalty program in five layouts. tiers: the levels and their benefits.
* earn: ways to collect points. rewards: what points buy. status: the
* member card. referral: invite friends.
*/
export function Loyalty(props: LoyaltyProps) {
const { variant = 'tiers' } = props;
const t = useTexts(TEXTS, props.labels);
if (variant === 'earn') return <Earn {...props} t={t} />;
if (variant === 'rewards') return <Rewards {...props} t={t} />;
if (variant === 'status') return <Status {...props} t={t} />;
if (variant === 'referral') return <Referral {...props} t={t} />;
return <Tiers {...props} t={t} />;
}Frequent questions
How do I install Loyalty program?
With aiw add @ai-website/loyalty. The command puts components/ui/loyalty.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, motion, section, number-ticker and product-image. It needs no further npm packages.
Which variants does Loyalty program have?
5 variants, Levels, Ways to earn, Rewards, Member card and Refer a friend. You pick one with the variant prop, for example variant="earn".
Can I translate the texts of Loyalty program?
Yes. Loyalty program carries 29 texts of its own in English and German, such as “points”, “pts” and “from”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Loyalty program?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Loyalty program with aiw_components and install it with aiw_add @ai-website/loyalty. Name the variant in your prompt, for example variant="tiers", and the assistant writes your content straight into the props.
Can I adjust Loyalty program without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching Loyalty code to copy.