Skip to content
UI Kit
Accent color

Partner program

For agencies, freelancers and resellers. Partner levels, recurring earnings over a year, the application with its next steps, a directory of certified partners and what partners get.

One of 2 components in Referral and partners

5 variants

Variants

Partner levels

Levels side by side with commission, requirement and benefits. The most common one carries a running light, a small scale shows the level, light follows the pointer.

Open in Studio

Recurring earnings

New clients per month and their plan on sliders. Each month stacks on the last, twelve bars rise one after another, hover names the amount.

Open in Studio

Application

The partner type as cards, then four fields with clear errors. After sending, the next steps rise in with the first one ticked.

Open in Studio

Partner directory

Certified partners filtered by region and focus. Cards rise in again after every filter, an empty result offers to show all.

Open in Studio

Partner benefits

The commission large and counting, what partners get in a seamless grid, a partner in their own words. The left side stays while the grid scrolls.

Open in Studio

Details

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

Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/partner-program.tsx
Brings along
utilskitbuttoniconinputslidermotionrevealsectionnumber-ticker
npm packages
None
Languages
English and German, 27 texts of its own

Props

The PartnerProgramProps interface from components/ui/partner-program.tsx, with the comments from the code.

  • variantoptional
    PartnerProgramVariant

    tiersearningsapplydirectorybenefits

  • eyebrowoptional
    React.ReactNode
  • titlerequired
    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

Built-in texts

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

Show all 27 texts
commissionEnglish CommissionGerman Provision
needsEnglish To reachGerman Voraussetzung
currentEnglish Most partnersGerman Die meisten Partner
perMonthEnglish per monthGerman pro Monat
perYearEnglish in the first yearGerman im ersten Jahr
monthEnglish Month {n}German Monat {n}
recurringEnglish Recurring commission per monthGerman Wiederkehrende Provision pro Monat
chartEnglish Monthly commission over 12 monthsGerman Monatliche Provision über 12 Monate
afterEnglish After 12 monthsGerman Nach 12 Monaten
typeEnglish What kind of partner are you?German Welche Art Partner bist du?
companyEnglish CompanyGerman Firma
websiteEnglish WebsiteGerman Website
emailEnglish Work emailGerman Geschäftliche E-Mail
clientsEnglish Clients per yearGerman Kunden im Jahr
sendEnglish Send applicationGerman Bewerbung senden
sentTitleEnglish Thanks, we got your applicationGerman Danke, deine Bewerbung ist da
sentTextEnglish This is what happens next.German So geht es weiter.
regionEnglish RegionGerman Region
focusEnglish FocusGerman Schwerpunkt
allEnglish AllGerman Alle
projectsEnglish {n} projectsGerman {n} Projekte
shownEnglish {n} partners shownGerman {n} Partner gefunden
noneEnglish No partner matches both filters yet.German Noch kein Partner passt zu beiden Filtern.
resetEnglish Show all partnersGerman Alle Partner zeigen
visitEnglish View profile of {n}German Profil von {n} ansehen
requiredEnglish Please fill this in.German Bitte ausfüllen.
emailInvalidEnglish Please enter an email address like name@company.com.German Bitte gib eine E-Mail-Adresse wie name@firma.de ein.

Accessibility

Read from the source of the component.

  • Sets aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
  • Uses the roles group, img and status.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • 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).
  • With reduced motion (prefers-reduced-motion) it stays still. 3 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, 588 lines
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} />;
}

Frequent questions

How do I install Partner program?

With aiw add @ai-website/partner-program. The command puts components/ui/partner-program.tsx into your project, the code is yours from then on. It brings along button, icon, input, slider, motion, reveal, section and number-ticker. It needs no further npm packages.

Which variants does Partner program have?

5 variants, Partner levels, Recurring earnings, Application, Partner directory and Partner benefits. You pick one with the variant prop, for example variant="earnings".

Can I translate the texts of Partner program?

Yes. Partner program carries 27 texts of its own in English and German, such as “Commission”, “To reach” and “Most partners”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Partner program?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Partner program with aiw_components and install it with aiw_add @ai-website/partner-program. Name the variant in your prompt, for example variant="tiers", and the assistant writes your content straight into the props.

Can I adjust Partner program without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching PartnerProgram code to copy.

Often searched

Jump to

Copied to the clipboard