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
Variants
Installv1.0.0
aiw add @ai-website/partner-programBrings along utils, kit, button, icon, input, slider, motion, reveal, section, number-ticker
Usage
<PartnerProgram variant="benefits" title="…" highlight={{ value: 25, suffix: "%", label }} benefits={[{ title, text, icon }]} quote={{ text, name, role }} />Details
What Partner program brings along, read from the registry and the source.
- Category
- Referral and partners
- Area
- Marketing
- 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
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The PartnerProgramProps interface from components/ui/partner-program.tsx, with the comments from the code.
variantoptionalPartnerProgramVarianttiersearningsapplydirectorybenefits
eyebrowoptionalReact.ReactNodetitlerequiredReact.ReactNodedescriptionoptionalReact.ReactNodetiersoptionalPartnerTier[]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
partnersoptionalPartnerEntry[]directory
highlightoptional{ value: number; suffix?: string; label: string }benefits
benefitsoptional{ title: string; text: string; icon?: IconName }[]quoteoptional{ text: string; name: string; role: string }currencyoptionalstringctaoptional{ label: string; href: string }actionoptionalstringonApplyoptional(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
| Key | English | German |
|---|---|---|
| commission | English Commission | German Provision |
| needs | English To reach | German Voraussetzung |
| current | English Most partners | German Die meisten Partner |
| perMonth | English per month | German pro Monat |
| perYear | English in the first year | German im ersten Jahr |
| month | English Month {n} | German Monat {n} |
| recurring | English Recurring commission per month | German Wiederkehrende Provision pro Monat |
| chart | English Monthly commission over 12 months | German Monatliche Provision über 12 Monate |
| after | English After 12 months | German Nach 12 Monaten |
| type | English What kind of partner are you? | German Welche Art Partner bist du? |
| company | English Company | German Firma |
| website | English Website | German Website |
| English Work email | German Geschäftliche E-Mail | |
| clients | English Clients per year | German Kunden im Jahr |
| send | English Send application | German Bewerbung senden |
| sentTitle | English Thanks, we got your application | German Danke, deine Bewerbung ist da |
| sentText | English This is what happens next. | German So geht es weiter. |
| region | English Region | German Region |
| focus | English Focus | German Schwerpunkt |
| all | English All | German Alle |
| projects | English {n} projects | German {n} Projekte |
| shown | English {n} partners shown | German {n} Partner gefunden |
| none | English No partner matches both filters yet. | German Noch kein Partner passt zu beiden Filtern. |
| reset | English Show all partners | German Alle Partner zeigen |
| visit | English View profile of {n} | German Profil von {n} ansehen |
| required | English Please fill this in. | German Bitte ausfüllen. |
| emailInvalid | English 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
'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.