Treueprogramm
Ein Treueprogramm in Sections. Die Stufen und ihre Vorteile, Wege zum Punktesammeln, Prämien zum Einlösen, die Mitgliedskarte mit Fortschritt und Aktivität und eine Empfehlung mit Link zum Teilen.
Eine von 4 Components in Konto und Treue
Variants
Details
Was Treueprogramm mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Konto und Treue
- Bereich
- E-Commerce
- Variants
- 5
- Version
- 1.0.1 · seit 1.1.0
- Datei
- components/ui/loyalty.tsx
- Bringt mit
- utilskitbuttoniconrevealmotionsectionnumber-tickerproduct-image
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 29 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle LoyaltyProps aus components/ui/loyalty.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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
Eingebaute Texte
Diese Texte schreibt Treueprogramm selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 29 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| points | Englisch points | Deutsch Punkte |
| pts | Englisch pts | Deutsch P. |
| from | Englisch from | Deutsch ab |
| youAreHere | Englisch You are here | Deutsch Hier stehst du |
| yourLevel | Englisch Your level | Deutsch Deine Stufe |
| toGo | Englisch points to | Deutsch Punkte bis |
| topLevel | Englisch You reached the top level | Deutsch Du hast die höchste Stufe erreicht |
| earnNow | Englisch Earn now | Deutsch Jetzt sammeln |
| earned | Englisch Earned | Deutsch Gesammelt |
| balance | Englisch Your balance | Deutsch Dein Guthaben |
| redeem | Englisch Redeem | Deutsch Einlösen |
| redeemed | Englisch Redeemed | Deutsch Eingelöst |
| notEnough | Englisch more needed | Deutsch fehlen noch |
| memberNo | Englisch Member no. | Deutsch Mitgliedsnr. |
| since | Englisch Member since | Deutsch Mitglied seit |
| recent | Englisch Recent activity | Deutsch Letzte Aktivität |
| seeAll | Englisch See all activity | Deutsch Alle Aktivitäten |
| copy | Englisch Copy link | Deutsch Link kopieren |
| yourLink | Englisch Your link | Deutsch Dein Link |
| copied | Englisch Link copied | Deutsch Link kopiert |
| share | Englisch Share by email | Deutsch Per E-Mail teilen |
| youGet | Englisch You get | Deutsch Du bekommst |
| theyGet | Englisch Your friend gets | Deutsch Deine Freundin oder dein Freund bekommt |
| friends | Englisch Friends you invited | Deutsch Deine Einladungen |
| perks | Englisch Benefits | Deutsch Vorteile |
| howItWorks | Englisch How it works | Deutsch So geht es |
| step1 | Englisch Share your link | Deutsch Link teilen |
| step2 | Englisch Your friend orders | Deutsch Die Person bestellt |
| step3 | Englisch You both get your reward | Deutsch Ihr bekommt beide euer Geschenk |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-live, damit Screenreader Zweck und Zustand ansagen.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 641 Zeilen
'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} />;
}Häufige Fragen
Wie installiere ich Treueprogramm?
Mit aiw add @ai-website/loyalty. Der Befehl legt die Datei components/ui/loyalty.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, motion, section, number-ticker und product-image. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Treueprogramm?
5 Varianten, Stufen, Punkte sammeln, Prämien, Mitgliedskarte und Freunde einladen. Du wählst sie mit der Prop variant, zum Beispiel variant="earn".
Kann ich die Texte von Treueprogramm übersetzen?
Ja. Treueprogramm bringt 29 eigene Texte auf Deutsch und Englisch mit, etwa „Punkte“, „P.“ und „ab“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Treueprogramm einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Treueprogramm mit aiw_components und installieren sie mit aiw_add @ai-website/loyalty. Nenn im Prompt die Variante, etwa variant="tiers", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Treueprogramm ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Loyalty-Code zum Kopieren.