Skip to content
UI Kit
Accent color

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

5 variants

Variants

Levels

Four levels side by side with rate and benefits. A line above fills while scrolling up to where the member stands, the current level glows in the accent and reached levels tick their benefits.

Open in Studio

Ways to earn

Ways to earn points as tiles with their points. The icon tilts on hover, a tap earns the points, turns the tile green with a check and lets the total in the corner roll up.

Open in Studio

Rewards

Rewards with their point price. Rewards out of reach show a bar of how far there is to go, redeeming one rolls the balance down with a "-500" that rises from it, and the card turns to "Redeemed".

Open in Studio

Member card

The member card on a dark surface with a ring that draws to the next level while the points count up, next to the latest activity with earned and spent points.

Open in Studio

Refer a friend

What both get as two cards, a link that selects itself and copies with a check, sharing by email, three steps joined by a dashed line and the friends who already joined, each with their state.

Open in Studio

Details

What Loyalty program brings along, read from the registry and the source.

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

Props

The LoyaltyProps interface from components/ui/loyalty.tsx, with the comments from the code.

  • 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

Built-in texts

Loyalty program writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 29 texts
pointsEnglish pointsGerman Punkte
ptsEnglish ptsGerman P.
fromEnglish fromGerman ab
youAreHereEnglish You are hereGerman Hier stehst du
yourLevelEnglish Your levelGerman Deine Stufe
toGoEnglish points toGerman Punkte bis
topLevelEnglish You reached the top levelGerman Du hast die höchste Stufe erreicht
earnNowEnglish Earn nowGerman Jetzt sammeln
earnedEnglish EarnedGerman Gesammelt
balanceEnglish Your balanceGerman Dein Guthaben
redeemEnglish RedeemGerman Einlösen
redeemedEnglish RedeemedGerman Eingelöst
notEnoughEnglish more neededGerman fehlen noch
memberNoEnglish Member no.German Mitgliedsnr.
sinceEnglish Member sinceGerman Mitglied seit
recentEnglish Recent activityGerman Letzte Aktivität
seeAllEnglish See all activityGerman Alle Aktivitäten
copyEnglish Copy linkGerman Link kopieren
yourLinkEnglish Your linkGerman Dein Link
copiedEnglish Link copiedGerman Link kopiert
shareEnglish Share by emailGerman Per E-Mail teilen
youGetEnglish You getGerman Du bekommst
theyGetEnglish Your friend getsGerman Deine Freundin oder dein Freund bekommt
friendsEnglish Friends you invitedGerman Deine Einladungen
perksEnglish BenefitsGerman Vorteile
howItWorksEnglish How it worksGerman So geht es
step1English Share your linkGerman Link teilen
step2English Your friend ordersGerman Die Person bestellt
step3English You both get your rewardGerman 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
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} />;
}

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.

Often searched

Jump to

Copied to the clipboard