Zum Inhalt springen
UI Kit
Accent

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

5 Variants

Variants

Stufen

Vier Stufen nebeneinander mit Punkterate und Vorteilen. Eine Linie darüber füllt sich beim Scrollen bis dorthin, wo das Mitglied steht, die aktuelle Stufe leuchtet im Akzent und erreichte Stufen haken ihre Vorteile ab.

Im Studio öffnen

Punkte sammeln

Wege zum Punktesammeln als Kacheln mit ihren Punkten. Das Icon kippt beim Hover, ein Tippen schreibt die Punkte gut, färbt die Kachel grün mit Haken und lässt die Summe in der Ecke hochrollen.

Im Studio öffnen

Prämien

Prämien mit ihrem Punktepreis. Unerreichbare zeigen einen Balken, wie weit es noch ist, Einlösen rollt das Guthaben herunter, ein „-500“ steigt daraus auf und die Karte wechselt zu „Eingelöst“.

Im Studio öffnen

Mitgliedskarte

Die Mitgliedskarte auf dunkler Fläche mit einem Ring, der sich bis zur nächsten Stufe zeichnet, während die Punkte hochzählen, daneben die letzte Aktivität mit gesammelten und eingelösten Punkten.

Im Studio öffnen

Freunde einladen

Was beide bekommen als zwei Karten, ein Link, der sich selbst markiert und mit Haken kopiert, Teilen per E-Mail, drei Schritte mit gestrichelter Linie und die Eingeladenen, jeweils mit ihrem Stand.

Im Studio öffnen

Details

Was Treueprogramm mitbringt, aus Registry und Quellcode gelesen.

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

Props

Die Schnittstelle LoyaltyProps aus components/ui/loyalty.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    LoyaltyVariant

    tiersearnrewardsstatusreferral

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • programoptional
    string

    The program name, e.g. "Nordhaus Circle"

  • tiersoptional
    LoyaltyTier[]
  • waysoptional
    EarnWay[]
  • rewardsoptional
    LoyaltyReward[]
  • memberoptional
    { name: string; points: number; since?: string; number?: string }
  • activityoptional
    LoyaltyActivity[]
  • 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
pointsEnglisch pointsDeutsch Punkte
ptsEnglisch ptsDeutsch P.
fromEnglisch fromDeutsch ab
youAreHereEnglisch You are hereDeutsch Hier stehst du
yourLevelEnglisch Your levelDeutsch Deine Stufe
toGoEnglisch points toDeutsch Punkte bis
topLevelEnglisch You reached the top levelDeutsch Du hast die höchste Stufe erreicht
earnNowEnglisch Earn nowDeutsch Jetzt sammeln
earnedEnglisch EarnedDeutsch Gesammelt
balanceEnglisch Your balanceDeutsch Dein Guthaben
redeemEnglisch RedeemDeutsch Einlösen
redeemedEnglisch RedeemedDeutsch Eingelöst
notEnoughEnglisch more neededDeutsch fehlen noch
memberNoEnglisch Member no.Deutsch Mitgliedsnr.
sinceEnglisch Member sinceDeutsch Mitglied seit
recentEnglisch Recent activityDeutsch Letzte Aktivität
seeAllEnglisch See all activityDeutsch Alle Aktivitäten
copyEnglisch Copy linkDeutsch Link kopieren
yourLinkEnglisch Your linkDeutsch Dein Link
copiedEnglisch Link copiedDeutsch Link kopiert
shareEnglisch Share by emailDeutsch Per E-Mail teilen
youGetEnglisch You getDeutsch Du bekommst
theyGetEnglisch Your friend getsDeutsch Deine Freundin oder dein Freund bekommt
friendsEnglisch Friends you invitedDeutsch Deine Einladungen
perksEnglisch BenefitsDeutsch Vorteile
howItWorksEnglisch How it worksDeutsch So geht es
step1Englisch Share your linkDeutsch Link teilen
step2Englisch Your friend ordersDeutsch Die Person bestellt
step3Englisch You both get your rewardDeutsch 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
components/ui/loyalty.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert