Zum Inhalt springen
UI Kit
Accent

Partnerprogramm

Für Agenturen, Freelancer und Wiederverkäufer. Partnerstufen, wiederkehrende Provision über ein Jahr, die Bewerbung mit den nächsten Schritten, ein Verzeichnis zertifizierter Partner und was Partner bekommen.

Eine von 2 Components in Empfehlung und Partner

5 Variants

Variants

Partnerstufen

Stufen nebeneinander mit Provision, Voraussetzung und Vorteilen. Die häufigste trägt ein umlaufendes Licht, eine kleine Skala zeigt die Stufe, Licht folgt dem Zeiger.

Im Studio öffnen

Wiederkehrende Provision

Neue Kunden pro Monat und ihr Tarif als Regler. Jeder Monat baut auf dem letzten auf, zwölf Balken steigen nacheinander, Hover nennt den Betrag.

Im Studio öffnen

Bewerbung

Die Partnerart als Karten, dann vier Felder mit klaren Fehlern. Nach dem Senden steigen die nächsten Schritte auf, der erste ist abgehakt.

Im Studio öffnen

Partnerverzeichnis

Zertifizierte Partner, gefiltert nach Region und Schwerpunkt. Karten steigen nach jedem Filter neu auf, ein leeres Ergebnis bietet an, alle zu zeigen.

Im Studio öffnen

Partnervorteile

Die Provision groß und zählend, was Partner bekommen in einem lückenlosen Raster, ein Partner mit eigenen Worten. Die linke Seite bleibt beim Scrollen stehen.

Im Studio öffnen

Details

Was Partnerprogramm mitbringt, aus Registry und Quellcode gelesen.

Bereich
Marketing
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/partner-program.tsx
Bringt mit
utilskitbuttoniconinputslidermotionrevealsectionnumber-ticker
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 27 eigene Texte

Props

Die Schnittstelle PartnerProgramProps aus components/ui/partner-program.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    PartnerProgramVariant

    tiersearningsapplydirectorybenefits

  • eyebrowoptional
    React.ReactNode
  • titlePflicht
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • tiersoptional
    PartnerTier[]
  • earningsoptional
    { clients: { label: string; min: number; max: number; value: number }; value: { label: string; min: number; max: number; step?: number; value: number }; rate: number; months?: number }

    earnings: the model

  • typesoptional
    { id: string; title: string; text: string; icon?: IconName }[]

    apply: partner types to choose

  • nextoptional
    { title: string; text: string }[]

    apply: what happens after sending

  • partnersoptional
    PartnerEntry[]

    directory

  • highlightoptional
    { value: number; suffix?: string; label: string }

    benefits

  • benefitsoptional
    { title: string; text: string; icon?: IconName }[]
  • quoteoptional
    { text: string; name: string; role: string }
  • currencyoptional
    string
  • ctaoptional
    { label: string; href: string }
  • actionoptional
    string
  • onApplyoptional
    (data: Record<string, string>) => void

Eingebaute Texte

Diese Texte schreibt Partnerprogramm selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 27 Texte anzeigen
commissionEnglisch CommissionDeutsch Provision
needsEnglisch To reachDeutsch Voraussetzung
currentEnglisch Most partnersDeutsch Die meisten Partner
perMonthEnglisch per monthDeutsch pro Monat
perYearEnglisch in the first yearDeutsch im ersten Jahr
monthEnglisch Month {n}Deutsch Monat {n}
recurringEnglisch Recurring commission per monthDeutsch Wiederkehrende Provision pro Monat
chartEnglisch Monthly commission over 12 monthsDeutsch Monatliche Provision über 12 Monate
afterEnglisch After 12 monthsDeutsch Nach 12 Monaten
typeEnglisch What kind of partner are you?Deutsch Welche Art Partner bist du?
companyEnglisch CompanyDeutsch Firma
websiteEnglisch WebsiteDeutsch Website
emailEnglisch Work emailDeutsch Geschäftliche E-Mail
clientsEnglisch Clients per yearDeutsch Kunden im Jahr
sendEnglisch Send applicationDeutsch Bewerbung senden
sentTitleEnglisch Thanks, we got your applicationDeutsch Danke, deine Bewerbung ist da
sentTextEnglisch This is what happens next.Deutsch So geht es weiter.
regionEnglisch RegionDeutsch Region
focusEnglisch FocusDeutsch Schwerpunkt
allEnglisch AllDeutsch Alle
projectsEnglisch {n} projectsDeutsch {n} Projekte
shownEnglisch {n} partners shownDeutsch {n} Partner gefunden
noneEnglisch No partner matches both filters yet.Deutsch Noch kein Partner passt zu beiden Filtern.
resetEnglisch Show all partnersDeutsch Alle Partner zeigen
visitEnglisch View profile of {n}Deutsch Profil von {n} ansehen
requiredEnglisch Please fill this in.Deutsch Bitte ausfüllen.
emailInvalidEnglisch Please enter an email address like name@company.com.Deutsch Bitte gib eine E-Mail-Adresse wie name@firma.de ein.

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen group, img und status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • 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).
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 588 Zeilen
components/ui/partner-program.tsx
'use client';

import { useEffect, useId, useState } from 'react';
import { cn, round, useInView, useLocale, useSpotlight, useTexts, type Locale } from '@/lib/kit';
import { Stagger, Magnetic } from '@/components/ui/motion';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Reveal } from '@/components/ui/reveal';
import { Eyebrow, Section } from '@/components/ui/section';
import { Slider } from '@/components/ui/slider';

/*
 * For agencies, freelancers and resellers who sell or set up a product for
 * their own clients. Levels, earnings, the application, a directory of
 * certified partners and what partners get.
 *
 *   tiers      partner levels side by side, what each needs and gives
 *   earnings   clients per month and value on sliders, recurring commission builds over a year
 *   apply      partner type as cards, a short form, then the next steps with dates
 *   directory  certified partners, filtered by region and focus
 *   benefits   the commission large, what partners get, a partner in their own words
 */

export type PartnerProgramVariant = 'tiers' | 'earnings' | 'apply' | 'directory' | 'benefits';

export interface PartnerTier {
  name: string;
  /** e.g. 20 % recurring */
  commission: string;
  requirement: string;
  benefits: string[];
  highlight?: boolean;
  icon?: IconName;
}

export interface PartnerEntry {
  name: string;
  city: string;
  region: string;
  focus: string[];
  level: string;
  projects?: number;
  href?: string;
}

export interface PartnerProgramProps {
  variant?: PartnerProgramVariant;
  eyebrow?: React.ReactNode;
  title: React.ReactNode;
  description?: React.ReactNode;
  tiers?: PartnerTier[];
  /** earnings: the model */
  earnings?: { clients: { label: string; min: number; max: number; value: number }; value: { label: string; min: number; max: number; step?: number; value: number }; rate: number; months?: number };
  /** apply: partner types to choose */
  types?: { id: string; title: string; text: string; icon?: IconName }[];
  /** apply: what happens after sending */
  next?: { title: string; text: string }[];
  /** directory */
  partners?: PartnerEntry[];
  /** benefits */
  highlight?: { value: number; suffix?: string; label: string };
  benefits?: { title: string; text: string; icon?: IconName }[];
  quote?: { text: string; name: string; role: string };
  currency?: string;
  cta?: { label: string; href: string };
  action?: string;
  onApply?: (data: Record<string, string>) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    commission: 'Commission',
    needs: 'To reach',
    current: 'Most partners',
    perMonth: 'per month',
    perYear: 'in the first year',
    month: 'Month {n}',
    recurring: 'Recurring commission per month',
    chart: 'Monthly commission over 12 months',
    after: 'After 12 months',
    type: 'What kind of partner are you?',
    company: 'Company',
    website: 'Website',
    email: 'Work email',
    clients: 'Clients per year',
    send: 'Send application',
    sentTitle: 'Thanks, we got your application',
    sentText: 'This is what happens next.',
    region: 'Region',
    focus: 'Focus',
    all: 'All',
    projects: '{n} projects',
    shown: '{n} partners shown',
    none: 'No partner matches both filters yet.',
    reset: 'Show all partners',
    visit: 'View profile of {n}',
    required: 'Please fill this in.',
    emailInvalid: 'Please enter an email address like name@company.com.',
  },
  de: {
    commission: 'Provision',
    needs: 'Voraussetzung',
    current: 'Die meisten Partner',
    perMonth: 'pro Monat',
    perYear: 'im ersten Jahr',
    month: 'Monat {n}',
    recurring: 'Wiederkehrende Provision pro Monat',
    chart: 'Monatliche Provision über 12 Monate',
    after: 'Nach 12 Monaten',
    type: 'Welche Art Partner bist du?',
    company: 'Firma',
    website: 'Website',
    email: 'Geschäftliche E-Mail',
    clients: 'Kunden im Jahr',
    send: 'Bewerbung senden',
    sentTitle: 'Danke, deine Bewerbung ist da',
    sentText: 'So geht es weiter.',
    region: 'Region',
    focus: 'Schwerpunkt',
    all: 'Alle',
    projects: '{n} Projekte',
    shown: '{n} Partner gefunden',
    none: 'Noch kein Partner passt zu beiden Filtern.',
    reset: 'Alle Partner zeigen',
    visit: 'Profil von {n} ansehen',
    required: 'Bitte ausfüllen.',
    emailInvalid: 'Bitte gib eine E-Mail-Adresse wie name@firma.de ein.',
  },
};

type Texts = (typeof TEXTS)['en'];
type ViewProps = PartnerProgramProps & { t: Texts };

const nf = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const money = (n: number, l: Locale, currency = 'EUR') => new Intl.NumberFormat(nf(l), { style: 'currency', currency, maximumFractionDigits: 0 }).format(Math.round(n));
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const initials = (name: string) =>
  name
    .split(/\s+/)
    .filter((w) => /^[A-Za-zÄÖÜäöü]/.test(w))
    .map((w) => w[0])
    .slice(0, 2)
    .join('')
    .toUpperCase();

function Head({ eyebrow, title, description, center, className }: Pick<PartnerProgramProps, 'eyebrow' | 'title' | 'description'> & { center?: boolean; className?: string }) {
  return (
    <div className={cn('max-w-[46rem]', center && 'mx-auto max-w-[42rem] text-center', className)}>
      {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-5 max-w-[36rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')} style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
          {description}
        </p>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* tiers                                                               */
/* ------------------------------------------------------------------ */

function TierCard({ tier, t, index, total }: { tier: PartnerTier; t: Texts; index: number; total: number }) {
  const spot = useSpotlight<HTMLElement>();
  return (
    <article onPointerMove={spot} className={cn('kit-spotlight relative flex h-full flex-col rounded-[1.5rem] border p-6 transition-[transform,box-shadow] duration-300 ease-[var(--ease-kit)] hover:-translate-y-1 motion-reduce:hover:translate-y-0 sm:p-7', tier.highlight ? 'kit-beam border-transparent bg-card shadow-[var(--highlight),var(--shadow-lg)]' : 'border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] hover:shadow-[var(--highlight),var(--shadow-md)]')}>
      {tier.highlight && <span className="absolute -top-3 left-6 rounded-full bg-primary px-2.5 py-0.5 text-[11.5px] font-semibold text-primary-foreground shadow-[var(--shadow-sm)]">{t.current}</span>}
      <div className="flex items-center justify-between gap-3">
        <span className="flex items-center gap-2.5">
          <span className={cn('grid size-10 place-items-center rounded-xl', tier.highlight ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground')}>
            <Icon name={tier.icon ?? 'award'} size={18} />
          </span>
          <h3 className="text-[18px] font-semibold text-foreground">{tier.name}</h3>
        </span>
        <span aria-hidden className="flex gap-1">
          {Array.from({ length: total }, (_, i) => (
            <span key={i} className={cn('h-1.5 w-4 rounded-full', i <= index ? 'bg-primary' : 'bg-track')} />
          ))}
        </span>
      </div>
      <p className="mt-6 text-[12.5px] font-medium text-muted-foreground">{t.commission}</p>
      <p className="mt-1 text-[clamp(1.75rem,1.45rem+1.2vw,2.25rem)] font-semibold leading-none tracking-[-0.03em] text-foreground">{tier.commission}</p>
      <p className="mt-5 text-[12.5px] font-medium text-muted-foreground">{t.needs}</p>
      <p className="mt-1 text-pretty text-[14.5px] leading-snug text-foreground">{tier.requirement}</p>
      <ul className="mt-6 grid gap-2.5 border-t border-border pt-5">
        {tier.benefits.map((b) => (
          <li key={b} className="flex gap-2.5 text-[14px] leading-snug text-foreground">
            <Icon name="check" size={15} className="mt-0.5 shrink-0 text-primary" />
            {b}
          </li>
        ))}
      </ul>
    </article>
  );
}

function Tiers(p: ViewProps) {
  const tiers = p.tiers ?? [];
  return (
    <Section className={p.className}>
      <Head {...p} center />
      <Stagger step={100} className={cn('mx-auto mt-12 grid max-w-[34rem] grid-cols-[minmax(0,1fr)] gap-5 sm:mt-14 lg:max-w-none lg:grid-cols-3 lg:items-stretch lg:gap-4', tiers.length === 2 && 'lg:max-w-[52rem] lg:grid-cols-2')}>
        {tiers.map((tier, i) => (
          <TierCard key={i} tier={tier} t={p.t} index={i} total={tiers.length} />
        ))}
      </Stagger>
      {p.cta && (
        <div className="mt-10 flex justify-center">
          <Magnetic className="max-sm:flex max-sm:w-full">
            <Button href={p.cta.href} size="lg" icon="arrow-right" className="max-sm:w-full">
              {p.cta.label}
            </Button>
          </Magnetic>
        </div>
      )}
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* earnings                                                            */
/* ------------------------------------------------------------------ */

function Earnings(p: ViewProps) {
  const { t } = p;
  const locale = useLocale();
  const cur = p.currency ?? 'EUR';
  const e = p.earnings ?? { clients: { label: '', min: 0, max: 10, value: 1 }, value: { label: '', min: 0, max: 100, value: 10 }, rate: 0.2 };
  const [clients, setClients] = useState(e.clients.value);
  const [value, setValue] = useState(e.value.value);
  useEffect(() => setClients(e.clients.value), [e.clients.value]);
  useEffect(() => setValue(e.value.value), [e.value.value]);
  const months = e.months ?? 12;
  // Every month new clients join and keep paying, the commission stacks up
  const perMonth = Array.from({ length: months }, (_, i) => (i + 1) * clients * value * e.rate);
  const year = perMonth.reduce((a, b) => a + b, 0);
  const max = Math.max(1, perMonth[months - 1]);
  const [ref, inView] = useInView<HTMLDivElement>('0px 0px -10% 0px');
  return (
    <Section className={p.className}>
      <div className="grid grid-cols-[minmax(0,1fr)] gap-12 lg:grid-cols-[minmax(0,24rem)_minmax(0,1fr)] lg:gap-16">
        <div>
          <Head {...p} />
          <div className="mt-8 grid gap-6">
            <Slider label={e.clients.label} min={e.clients.min} max={e.clients.max} value={clients} onValueChange={setClients} />
            <Slider label={e.value.label} min={e.value.min} max={e.value.max} step={e.value.step ?? 1} value={value} onValueChange={setValue} format={(v) => money(v, locale, cur)} />
          </div>
          <p className="mt-6 rounded-xl bg-surface px-4 py-3 text-[13.5px] text-muted-foreground">
            {t.commission} <strong className="font-semibold text-foreground">{Math.round(e.rate * 100)}{locale === 'de' ? ' %' : '%'}</strong>
          </p>
        </div>
        <Reveal effect="scale" className="min-w-0">
          <div ref={ref} className="rounded-[1.5rem] border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-lg)] sm:p-7" aria-live="polite">
            <div className="grid grid-cols-2 gap-4">
              <div>
                <p className="text-[13px] text-muted-foreground">{t.after}</p>
                <p className="mt-1 text-[clamp(1.5rem,1.2rem+1.3vw,2.25rem)] font-semibold leading-none tracking-[-0.03em] text-foreground">
                  {money(perMonth[months - 1], locale, cur)}
                  <span className="ml-1.5 text-[13px] font-normal tracking-normal text-muted-foreground">{t.perMonth}</span>
                </p>
              </div>
              <div>
                <p className="text-[13px] text-muted-foreground">{t.perYear}</p>
                <p className="mt-1 text-[clamp(1.5rem,1.2rem+1.3vw,2.25rem)] font-semibold leading-none tracking-[-0.03em] text-success-foreground">{money(year, locale, cur)}</p>
              </div>
            </div>
            <p className="mt-7 text-[12.5px] font-medium text-muted-foreground">{t.recurring}</p>
            <div role="img" aria-label={t.chart} className="mt-3 flex h-48 items-end gap-1 sm:h-56 sm:gap-2">
              {perMonth.map((v, i) => (
                <div key={i} className="group/bar relative flex h-full min-w-0 flex-1 flex-col justify-end">
                  <span className={cn('block w-full origin-bottom rounded-t-md transition-[transform,background-color] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', i === months - 1 ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--primary)_35%,var(--card))] group-hover/bar:bg-[color-mix(in_oklab,var(--primary)_60%,var(--card))]')} style={{ height: '100%', transform: `scaleY(${inView ? round(v / max, 4) : 0})`, transitionDelay: inView ? `${i * 45}ms` : '0ms' }} />
                  <span className="pointer-events-none absolute bottom-full left-1/2 mb-1 hidden -translate-x-1/2 whitespace-nowrap rounded-md bg-foreground px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-background opacity-0 transition-opacity group-hover/bar:opacity-100 sm:block" style={{ bottom: `${round((v / max) * 100, 2)}%` }}>
                    {money(v, locale, cur)}
                  </span>
                </div>
              ))}
            </div>
            <div aria-hidden className="mt-2 flex gap-1 text-center text-[11px] tabular-nums text-subtle-foreground sm:gap-2">
              {perMonth.map((_, i) => (
                <span key={i} className="min-w-0 flex-1">
                  {i % 3 === 2 || i === 0 ? i + 1 : ''}
                </span>
              ))}
            </div>
            {p.cta && (
              <div className="mt-7 border-t border-border pt-6">
                <Button href={p.cta.href} size="lg" icon="arrow-right" className="w-full sm:w-auto">
                  {p.cta.label}
                </Button>
              </div>
            )}
          </div>
        </Reveal>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* apply                                                               */
/* ------------------------------------------------------------------ */

function Apply(p: ViewProps) {
  const { t } = p;
  const id = useId();
  const types = p.types ?? [];
  const [type, setType] = useState(types[0]?.id ?? '');
  const [form, setForm] = useState({ company: '', website: '', email: '', clients: '' });
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [sent, setSent] = useState(false);
  const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
    setForm((f) => ({ ...f, [k]: e.target.value }));
    setErrors((x) => ({ ...x, [k]: '' }));
  };
  return (
    <Section className={p.className}>
      <div className="grid grid-cols-[minmax(0,1fr)] gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,32rem)] lg:gap-16">
        <div>
          <Head {...p} />
          {p.next && (
            <ol className="mt-10 hidden gap-5 lg:grid">
              {p.next.map((s, i) => (
                <li key={i} className="grid grid-cols-[2rem_minmax(0,1fr)] gap-3.5">
                  <span className="grid size-8 place-items-center rounded-full border border-border bg-card text-[13px] font-semibold tabular-nums text-foreground">{i + 1}</span>
                  <span className="pt-1">
                    <span className="block text-[15px] font-semibold text-foreground">{s.title}</span>
                    <span className="mt-0.5 block text-pretty text-[14px] text-muted-foreground">{s.text}</span>
                  </span>
                </li>
              ))}
            </ol>
          )}
        </div>
        <Reveal effect="scale">
          <div className="rounded-[1.5rem] border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-lg)] sm:p-7">
            {sent ? (
              <div className="kit-rise" role="status">
                <span className="grid size-12 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_16%,var(--card))] text-success-foreground">
                  <Icon name="check" size={22} />
                </span>
                <p className="mt-4 text-[19px] font-semibold text-foreground">{t.sentTitle}</p>
                <p className="mt-1 text-[14.5px] text-muted-foreground">{t.sentText}</p>
                <Stagger as="ol" step={120} className="mt-5 grid gap-3">
                  {(p.next ?? []).map((s, i) => (
                    <li key={i} className="flex gap-3 rounded-xl border border-border bg-surface p-3.5">
                      <span className={cn('grid size-6 shrink-0 place-items-center rounded-full text-[12px] font-semibold', i === 0 ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground')}>{i === 0 ? <Icon name="check" size={12} strokeWidth={3} /> : i + 1}</span>
                      <span>
                        <span className="block text-[14px] font-semibold text-foreground">{s.title}</span>
                        <span className="block text-pretty text-[13px] text-muted-foreground">{s.text}</span>
                      </span>
                    </li>
                  ))}
                </Stagger>
              </div>
            ) : (
              <form
                action={p.action}
                method="post"
                noValidate
                onSubmit={(e) => {
                  const err: Record<string, string> = {};
                  if (!form.company.trim()) err.company = t.required;
                  if (!EMAIL.test(form.email.trim())) err.email = t.emailInvalid;
                  if (Object.keys(err).length) {
                    e.preventDefault();
                    setErrors(err);
                    return;
                  }
                  if (p.onApply || !p.action) {
                    e.preventDefault();
                    p.onApply?.({ type, ...form });
                    setSent(true);
                  }
                }}
                className="grid gap-5"
              >
                <fieldset>
                  <legend className="mb-2.5 text-[14px] font-medium text-foreground">{t.type}</legend>
                  <div className="grid grid-cols-[minmax(0,1fr)] gap-2 sm:grid-cols-3">
                    {types.map((x) => (
                      <label key={x.id} className="cursor-pointer">
                        <input type="radio" name="type" value={x.id} className="peer sr-only" checked={type === x.id} onChange={() => setType(x.id)} />
                        <span className={cn('flex h-full items-center gap-3 rounded-xl border p-3 transition-[border-color,box-shadow,background-color] duration-300 peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--ring)] sm:flex-col sm:items-start sm:gap-2', type === x.id ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))] shadow-[0_0_0_3px_var(--glow)]' : 'border-border hover:border-border-strong')}>
                          <span className={cn('grid size-9 shrink-0 place-items-center rounded-lg transition-colors', type === x.id ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground')}>
                            <Icon name={x.icon ?? 'briefcase'} size={16} />
                          </span>
                          <span className="min-w-0">
                            <span className="block text-[14px] font-semibold text-foreground">{x.title}</span>
                            <span className="block text-[12.5px] leading-snug text-muted-foreground">{x.text}</span>
                          </span>
                        </span>
                      </label>
                    ))}
                  </div>
                </fieldset>
                <div className="grid grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-2">
                  <Input id={`${id}-c`} size="lg" label={t.company} name="company" autoComplete="organization" value={form.company} onChange={set('company')} error={errors.company || undefined} />
                  <Input id={`${id}-w`} size="lg" label={t.website} name="website" optional autoComplete="url" inputMode="url" placeholder="https://" value={form.website} onChange={set('website')} />
                </div>
                <div className="grid grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-[minmax(0,1fr)_11rem]">
                  <Input id={`${id}-e`} size="lg" label={t.email} name="email" type="email" autoComplete="email" value={form.email} onChange={set('email')} error={errors.email || undefined} />
                  <Input id={`${id}-n`} size="lg" label={t.clients} name="clients" inputMode="numeric" optional value={form.clients} onChange={set('clients')} />
                </div>
                <Button type="submit" size="lg" icon="arrow-right" className="w-full">
                  {t.send}
                </Button>
              </form>
            )}
          </div>
        </Reveal>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* directory                                                           */
/* ------------------------------------------------------------------ */

function Chips({ label, options, value, onChange, all }: { label: string; options: string[]; value: string; onChange: (v: string) => void; all: string }) {
  return (
    <div role="group" aria-label={label} className="flex flex-wrap items-center gap-2">
      <span aria-hidden className="mr-1 w-full text-[13px] font-medium text-muted-foreground sm:w-auto">{label}</span>
      {['', ...options].map((o) => (
        <button key={o || 'all'} type="button" aria-pressed={value === o} onClick={() => onChange(o)} className={cn('inline-flex h-9 items-center rounded-full border px-3.5 text-[13.5px] font-medium transition-[background-color,border-color,color] duration-200 max-sm:h-11', value === o ? 'border-foreground bg-foreground text-background' : 'border-border bg-card text-foreground hover:border-border-strong')}>
          {o || all}
        </button>
      ))}
    </div>
  );
}

function Directory(p: ViewProps) {
  const { t } = p;
  const list = p.partners ?? [];
  const regions = Array.from(new Set(list.map((x) => x.region)));
  const focuses = Array.from(new Set(list.flatMap((x) => x.focus)));
  const [region, setRegion] = useState('');
  const [focus, setFocus] = useState('');
  const shown = list.filter((x) => (!region || x.region === region) && (!focus || x.focus.includes(focus)));
  return (
    <Section className={p.className}>
      <Head {...p} />
      <div className="mt-10 grid gap-3 border-y border-border py-4">
        <Chips label={t.region} options={regions} value={region} onChange={setRegion} all={t.all} />
        <Chips label={t.focus} options={focuses} value={focus} onChange={setFocus} all={t.all} />
      </div>
      <p className="sr-only" aria-live="polite">
        {t.shown.replace('{n}', String(shown.length))}
      </p>
      {shown.length ? (
        <div key={`${region}|${focus}`} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-3 sm:grid-cols-2 lg:grid-cols-3">
          {shown.map((x, i) => (
            <a key={x.name} href={x.href ?? '#'} aria-label={t.visit.replace('{n}', x.name)} className="kit-rise group/partner flex h-full flex-col rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow,border-color] duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] motion-reduce:hover:translate-y-0" style={{ '--kit-delay': `${i * 50}ms` } as React.CSSProperties}>
              <span className="flex items-start gap-3.5">
                <span className="grid size-11 shrink-0 place-items-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[14px] font-semibold text-foreground">{initials(x.name)}</span>
                <span className="min-w-0 flex-1">
                  <span className="block truncate text-[15.5px] font-semibold text-foreground">{x.name}</span>
                  <span className="mt-0.5 flex items-center gap-1 text-[13px] text-muted-foreground">
                    <Icon name="map-pin" size={13} />
                    {x.city}
                  </span>
                </span>
                <span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[11.5px] font-semibold text-foreground">
                  <Icon name="award" size={12} className="text-primary" />
                  {x.level}
                </span>
              </span>
              <span className="mt-4 flex flex-wrap gap-1.5">
                {x.focus.map((f) => (
                  <span key={f} className={cn('rounded-md border px-2 py-0.5 text-[12px]', f === focus ? 'border-primary text-foreground' : 'border-border text-muted-foreground')}>
                    {f}
                  </span>
                ))}
              </span>
              <span className="mt-auto flex items-center justify-between pt-5 text-[13px]">
                <span className="text-muted-foreground">{x.projects !== undefined ? t.projects.replace('{n}', String(x.projects)) : ''}</span>
                <Icon name="arrow-up-right" size={16} className="text-subtle-foreground transition-[transform,color] duration-300 group-hover/partner:-translate-y-0.5 group-hover/partner:translate-x-0.5 group-hover/partner:text-foreground" />
              </span>
            </a>
          ))}
        </div>
      ) : (
        <div className="kit-rise mt-6 rounded-2xl border border-dashed border-border-strong p-10 text-center">
          <p className="text-[15px] text-muted-foreground">{t.none}</p>
          <Button variant="outline" className="mt-4" onClick={() => (setRegion(''), setFocus(''))}>
            {t.reset}
          </Button>
        </div>
      )}
      {p.cta && (
        <div className="mt-8">
          <Button href={p.cta.href} variant="ghost" icon="arrow-right">
            {p.cta.label}
          </Button>
        </div>
      )}
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* benefits                                                            */
/* ------------------------------------------------------------------ */

function Benefits(p: ViewProps) {
  const h = p.highlight;
  return (
    <Section className={p.className}>
      <div className="grid grid-cols-[minmax(0,1fr)] gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] lg:gap-16">
        <div className="lg:sticky lg:top-24 lg:self-start">
          <Head {...p} />
          {h && (
            <Reveal className="mt-10 rounded-[1.5rem] border border-border bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))] p-6 shadow-[var(--highlight),var(--shadow-md)] sm:p-7">
              <p className="flex items-baseline gap-x-[0.08em] text-[clamp(3rem,2.2rem+3.5vw,4.75rem)] font-semibold leading-none tracking-[-0.045em] text-foreground">
                <NumberTicker value={h.value} duration={1400} />
                {h.suffix && <span className="kit-accent-text">{h.suffix.trim()}</span>}
              </p>
              <p className="mt-3 max-w-[22rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{h.label}</p>
            </Reveal>
          )}
          {p.cta && (
            <div className="mt-8">
              <Magnetic className="max-sm:flex max-sm:w-full">
                <Button href={p.cta.href} size="lg" icon="arrow-right" className="max-sm:w-full">
                  {p.cta.label}
                </Button>
              </Magnetic>
            </div>
          )}
        </div>
        <div className="min-w-0">
          <Stagger step={80} className="grid grid-cols-[minmax(0,1fr)] gap-px overflow-hidden rounded-[1.5rem] border border-border bg-border sm:grid-cols-2">
            {(p.benefits ?? []).map((b, i) => (
              <div key={i} className="group/b bg-card p-6 transition-colors duration-300 hover:bg-[color-mix(in_oklab,var(--foreground)_2%,var(--card))]">
                <span className="grid size-10 place-items-center rounded-xl bg-muted text-foreground transition-[background-color,color,transform] duration-300 group-hover/b:-rotate-6 group-hover/b:bg-primary group-hover/b:text-primary-foreground">
                  <Icon name={b.icon ?? 'check'} size={18} />
                </span>
                <h3 className="mt-5 text-[16px] font-semibold text-foreground">{b.title}</h3>
                <p className="mt-1.5 text-pretty text-[14px] leading-relaxed text-muted-foreground">{b.text}</p>
              </div>
            ))}
          </Stagger>
          {p.quote && (
            <Reveal as="div" className="mt-4 rounded-[1.5rem] border border-border bg-surface p-6 sm:p-7">
              <Icon name="quote" size={20} className="text-primary" />
              <p className="mt-3 text-pretty text-[17px] font-medium leading-relaxed text-foreground">{p.quote.text}</p>
              <p className="mt-4 flex items-center gap-3 text-[13.5px]">
                <span className="grid size-9 place-items-center rounded-full bg-muted text-[12px] font-semibold text-foreground">{initials(p.quote.name)}</span>
                <span>
                  <span className="block font-semibold text-foreground">{p.quote.name}</span>
                  <span className="block text-muted-foreground">{p.quote.role}</span>
                </span>
              </p>
            </Reveal>
          )}
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* PartnerProgram                                                      */
/* ------------------------------------------------------------------ */

const VARIANTS: Record<PartnerProgramVariant, (p: ViewProps) => React.ReactNode> = {
  tiers: Tiers,
  earnings: Earnings,
  apply: Apply,
  directory: Directory,
  benefits: Benefits,
};

/**
 * For agencies, freelancers and resellers.
 *   <PartnerProgram variant="tiers" title="…" tiers={[{ name, commission, requirement, benefits, highlight }]} cta={{ label, href }} />
 */
export function PartnerProgram({ variant = 'tiers', labels, ...props }: PartnerProgramProps) {
  const t = useTexts(TEXTS, labels);
  const View = VARIANTS[variant] ?? Tiers;
  return <View {...props} t={t} />;
}

Häufige Fragen

Wie installiere ich Partnerprogramm?

Mit aiw add @ai-website/partner-program. Der Befehl legt die Datei components/ui/partner-program.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, input, slider, motion, reveal, section und number-ticker. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Partnerprogramm?

5 Varianten, Partnerstufen, Wiederkehrende Provision, Bewerbung, Partnerverzeichnis und Partnervorteile. Du wählst sie mit der Prop variant, zum Beispiel variant="earnings".

Kann ich die Texte von Partnerprogramm übersetzen?

Ja. Partnerprogramm bringt 27 eigene Texte auf Deutsch und Englisch mit, etwa „Provision“, „Voraussetzung“ und „Die meisten Partner“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Partnerprogramm einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Partnerprogramm mit aiw_components und installieren sie mit aiw_add @ai-website/partner-program. Nenn im Prompt die Variante, etwa variant="tiers", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Partnerprogramm ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden PartnerProgram-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert