Zum Inhalt springen
UI Kit
Accent

Beratung

Für Beraterinnen und kleine Beratungen. Pakete, die sich mit Leistungen öffnen, die Methode als Kreis, ein Aufwandsrechner, Referenzen mit Zahlen und ein Briefing in drei Schritten.

Eine von 2 Components in Coaching und Beratung

5 Variants

Variants

Pakete

Feste Pakete als Zeilen mit Umfang, Dauer und Preis ab. Eines öffnet sich mit Leistungen und für wen es passt, eine Leiste wächst an seiner Kante.

Im Studio öffnen

Methode

Die Phasen auf einem Kreis, der Bogen füllt sich bis zur gewählten Phase, eine innere Linie läuft. Jede Phase öffnet sich mit Tätigkeiten und Ergebnis, Pfeile und Pfeiltasten wechseln.

Im Studio öffnen

Aufwandsrechner

Ein paar Regler zum Projekt ergeben Beratertage und eine Preisspanne. Die Tage füllen eine Reihe aus Blöcken, Zahlen gleiten, ein Licht läuft um das Ergebnis.

Im Studio öffnen

Referenzen

Ein Kunde nach dem anderen hinter Logo-Tabs. Ausgangslage, Vorgehen und Ergebnis nebeneinander, die Zahlen zählen hoch, ein Zitat des Kunden daneben.

Im Studio öffnen

Briefing

Ein Briefing in drei Schritten, Thema, Lage und Kontakt. Eine Fortschrittslinie füllt sich, jeder Schritt gleitet von seiner Seite herein, vor dem Senden eine Zusammenfassung.

Im Studio öffnen

Details

Was Beratung mitbringt, aus Registry und Quellcode gelesen.

Bereich
Industries
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/consulting.tsx
Bringt mit
utilskitbuttoniconmotionsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 44 eigene Texte

Props

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

  • variantoptional
    ConsultingVariant

    packagesmethodestimatorreferencesbrief

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • packagesoptional
    ConsultingPackage[]
  • phasesoptional
    ConsultingPhase[]
  • referencesoptional
    ConsultingReference[]
  • factorsoptional
    ConsultingFactor[]

    estimator

  • baseDaysoptional
    number
  • dayRateoptional
    number
  • topicsoptional
    string[]

    brief: topics to pick from

  • currencyoptional
    string
  • onSubmitoptional
    (data: Record<string, string>) => void

Eingebaute Texte

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

Alle 44 Texte anzeigen
fromEnglisch fromDeutsch ab
lengthEnglisch LengthDeutsch Dauer
deliverablesEnglisch You getDeutsch Du bekommst
fitEnglisch Right forDeutsch Passt für
requestEnglisch Request this packageDeutsch Paket anfragen
phaseEnglisch Phase {n}Deutsch Phase {n}
activitiesEnglisch What we doDeutsch Was wir tun
resultEnglisch ResultDeutsch Ergebnis
estimateEnglisch EstimateDeutsch Schätzung
daysEnglisch {n} consulting daysDeutsch {n} Beratertage
rangeEnglisch Likely betweenDeutsch Voraussichtlich zwischen
andEnglisch andDeutsch und
weeksEnglisch about {n} weeksDeutsch etwa {n} Wochen
proposalEnglisch Request a proposalDeutsch Angebot anfragen
noteEnglisch An estimate, not an offer. The proposal follows a first call.Deutsch Eine Schätzung, kein Angebot. Das Angebot folgt nach einem ersten Gespräch.
challengeEnglisch ChallengeDeutsch Ausgangslage
approachEnglisch ApproachDeutsch Vorgehen
outcomeEnglisch ResultDeutsch Ergebnis
clientsEnglisch ClientsDeutsch Kunden
stepEnglisch Step {n} of {total}Deutsch Schritt {n} von {total}
s1Englisch TopicDeutsch Thema
s2Englisch SituationDeutsch Lage
s3Englisch ContactDeutsch Kontakt
topicsQEnglisch What is it about?Deutsch Worum geht es?
pickOneEnglisch Pick at least one topic.Deutsch Wähle mindestens ein Thema.
situationEnglisch Where do you stand today?Deutsch Wo steht ihr heute?
situationPhEnglisch We are 80 people in two offices and decisions take weeks.Deutsch Wir sind 80 Leute an zwei Standorten und Entscheidungen dauern Wochen.
sizeEnglisch Size of the organisationDeutsch Größe der Organisation
sizesEnglisch Up to 20|20 to 100|100 to 500|More than 500Deutsch Bis 20|20 bis 100|100 bis 500|Mehr als 500
whenEnglisch When should it start?Deutsch Wann soll es losgehen?
whensEnglisch Now|This quarter|Later this yearDeutsch Sofort|Dieses Quartal|Später im Jahr
nameEnglisch NameDeutsch Name
companyEnglisch OrganisationDeutsch Organisation
emailEnglisch EmailDeutsch E-Mail
emailPhEnglisch you@example.comDeutsch du@beispiel.de
backEnglisch BackDeutsch Zurück
nextEnglisch NextDeutsch Weiter
sendEnglisch Send briefDeutsch Briefing senden
requiredEnglisch Please fill in this field.Deutsch Bitte fülle dieses Feld aus.
invalidEnglisch Please enter an email address like name@example.com.Deutsch Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.
sentTitleEnglisch Brief receivedDeutsch Briefing ist angekommen
sentTextEnglisch We read it within one working day and suggest three times for a first call.Deutsch Wir lesen es innerhalb eines Werktags und schlagen drei Termine für ein erstes Gespräch vor.
againEnglisch Write another briefDeutsch Weiteres Briefing schreiben
summaryEnglisch Your briefDeutsch Dein Briefing

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Beratung reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links und Pfeil rechts.
  • Setzt aria-controls, aria-current, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby, aria-live und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen status, tab, tablist und tabpanel.
  • 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

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 788 Zeilen
components/ui/consulting.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Stagger, useScrollFade } from '@/components/ui/motion';
import { Section, SectionHeader } from '@/components/ui/section';

/*
 * Sections for consultants and small advisory firms: fixed packages, the
 * method as a cycle, an estimate of the effort, references with numbers and
 * a brief in three steps.
 *
 *   packages   packages as rows with scope, length and price from, one opens with deliverables
 *   method     the phases on a ring with a running line, each phase opens with activities and results
 *   estimator  a few sliders give days and a price range, both glide
 *   references one client at a time, challenge, approach and result with numbers that count
 *   brief      a brief in three steps with a progress line, a summary before sending
 */

export type ConsultingVariant = 'packages' | 'method' | 'estimator' | 'references' | 'brief';

export interface ConsultingPackage {
  id: string;
  title: string;
  /** One line about the scope */
  summary: string;
  /** e.g. "2 weeks" */
  length: string;
  /** Price from */
  price: number;
  /** What the client gets */
  deliverables?: string[];
  /** For whom */
  fit?: string;
  icon?: IconName;
  href?: string;
}

export interface ConsultingPhase {
  id: string;
  title: string;
  text: string;
  activities?: string[];
  result?: string;
  icon?: IconName;
}

export interface ConsultingReference {
  id: string;
  client: string;
  industry?: string;
  logo?: React.ReactNode;
  challenge: string;
  approach: string;
  result: string;
  numbers?: { value: number; suffix?: string; label: string }[];
  quote?: { text: string; name: string; role?: string };
}

export interface ConsultingFactor {
  id: string;
  label: string;
  min: number;
  max: number;
  step?: number;
  value: number;
  /** Days added per unit */
  days: number;
  /** Shown after the value, e.g. "people" */
  unit?: string;
}

export interface ConsultingProps {
  variant?: ConsultingVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  packages?: ConsultingPackage[];
  phases?: ConsultingPhase[];
  references?: ConsultingReference[];
  /** estimator */
  factors?: ConsultingFactor[];
  baseDays?: number;
  dayRate?: number;
  /** brief: topics to pick from */
  topics?: string[];
  currency?: string;
  onSubmit?: (data: Record<string, string>) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    from: 'from',
    length: 'Length',
    deliverables: 'You get',
    fit: 'Right for',
    request: 'Request this package',
    phase: 'Phase {n}',
    activities: 'What we do',
    result: 'Result',
    estimate: 'Estimate',
    days: '{n} consulting days',
    range: 'Likely between',
    and: 'and',
    weeks: 'about {n} weeks',
    proposal: 'Request a proposal',
    note: 'An estimate, not an offer. The proposal follows a first call.',
    challenge: 'Challenge',
    approach: 'Approach',
    outcome: 'Result',
    clients: 'Clients',
    step: 'Step {n} of {total}',
    s1: 'Topic',
    s2: 'Situation',
    s3: 'Contact',
    topicsQ: 'What is it about?',
    pickOne: 'Pick at least one topic.',
    situation: 'Where do you stand today?',
    situationPh: 'We are 80 people in two offices and decisions take weeks.',
    size: 'Size of the organisation',
    sizes: 'Up to 20|20 to 100|100 to 500|More than 500',
    when: 'When should it start?',
    whens: 'Now|This quarter|Later this year',
    name: 'Name',
    company: 'Organisation',
    email: 'Email',
    emailPh: 'you@example.com',
    back: 'Back',
    next: 'Next',
    send: 'Send brief',
    required: 'Please fill in this field.',
    invalid: 'Please enter an email address like name@example.com.',
    sentTitle: 'Brief received',
    sentText: 'We read it within one working day and suggest three times for a first call.',
    again: 'Write another brief',
    summary: 'Your brief',
  },
  de: {
    from: 'ab',
    length: 'Dauer',
    deliverables: 'Du bekommst',
    fit: 'Passt für',
    request: 'Paket anfragen',
    phase: 'Phase {n}',
    activities: 'Was wir tun',
    result: 'Ergebnis',
    estimate: 'Schätzung',
    days: '{n} Beratertage',
    range: 'Voraussichtlich zwischen',
    and: 'und',
    weeks: 'etwa {n} Wochen',
    proposal: 'Angebot anfragen',
    note: 'Eine Schätzung, kein Angebot. Das Angebot folgt nach einem ersten Gespräch.',
    challenge: 'Ausgangslage',
    approach: 'Vorgehen',
    outcome: 'Ergebnis',
    clients: 'Kunden',
    step: 'Schritt {n} von {total}',
    s1: 'Thema',
    s2: 'Lage',
    s3: 'Kontakt',
    topicsQ: 'Worum geht es?',
    pickOne: 'Wähle mindestens ein Thema.',
    situation: 'Wo steht ihr heute?',
    situationPh: 'Wir sind 80 Leute an zwei Standorten und Entscheidungen dauern Wochen.',
    size: 'Größe der Organisation',
    sizes: 'Bis 20|20 bis 100|100 bis 500|Mehr als 500',
    when: 'Wann soll es losgehen?',
    whens: 'Sofort|Dieses Quartal|Später im Jahr',
    name: 'Name',
    company: 'Organisation',
    email: 'E-Mail',
    emailPh: 'du@beispiel.de',
    back: 'Zurück',
    next: 'Weiter',
    send: 'Briefing senden',
    required: 'Bitte fülle dieses Feld aus.',
    invalid: 'Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.',
    sentTitle: 'Briefing ist angekommen',
    sentText: 'Wir lesen es innerhalb eines Werktags und schlagen drei Termine für ein erstes Gespräch vor.',
    again: 'Weiteres Briefing schreiben',
    summary: 'Dein Briefing',
  },
};
type Texts = typeof TEXTS.en;

/* ------------------------------------------------------------------ */
/* Helpers                                                             */
/* ------------------------------------------------------------------ */

const TAG: Record<Locale, string> = { en: 'en-US', de: 'de-DE' };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const focusRing = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background';
function money(value: number, currency: string, locale: Locale) {
  return new Intl.NumberFormat(TAG[locale], { style: 'currency', currency, maximumFractionDigits: 0 }).format(value);
}

const MOTION_CSS = `
@keyframes kitcons-in{from{opacity:0;transform:translateY(8px)}}
@keyframes kitcons-swap{from{opacity:0;filter:blur(4px);transform:translateY(6px)}}
@keyframes kitcons-pop{0%{transform:scale(.5);opacity:0}60%{transform:scale(1.12);opacity:1}100%{transform:scale(1)}}
@keyframes kitcons-next{from{opacity:0;transform:translateX(24px)}}
@keyframes kitcons-prev{from{opacity:0;transform:translateX(-24px)}}
@keyframes kitcons-dash{to{stroke-dashoffset:-40}}
@media (prefers-reduced-motion:reduce){[class*="kitcons-"],.kitcons-anim{animation:none!important;transition:none!important}}`;

function ConsMotion() {
  return (
    <style href="kitcons-motion" precedence="default">
      {MOTION_CSS}
    </style>
  );
}

function useTween(value: number, ms = 500) {
  const [shown, setShown] = useState(value);
  const from = useRef(value);
  useEffect(() => {
    const start = from.current;
    if (start === value) return;
    if (prefersReducedMotion()) {
      from.current = value;
      setShown(value);
      return;
    }
    let raf = 0;
    const t0 = performance.now();
    const tick = (now: number) => {
      const k = Math.min(1, (now - t0) / ms);
      const v = k >= 1 ? value : start + (value - start) * (1 - Math.pow(1 - k, 3));
      from.current = v;
      setShown(v);
      if (k < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [value, ms]);
  return shown;
}

/* ------------------------------------------------------------------ */
/* packages                                                            */
/* ------------------------------------------------------------------ */

function Packages({ packages = [], currency = 'EUR', t }: ConsultingProps & { t: Texts }) {
  const locale = useLocale();
  const [open, setOpen] = useState<string | null>(packages[1]?.id ?? packages[0]?.id ?? null);
  const base = useId();
  return (
    <Stagger as="ul" step={80} className="border-t border-border">
      {packages.map((p) => {
        const on = open === p.id;
        return (
          <li key={p.id} className={cn('relative border-b border-border transition-colors duration-500', on && 'bg-[color-mix(in_oklab,var(--primary)_4%,transparent)]')}>
            <span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px] origin-top bg-primary transition-transform duration-500 ease-[var(--ease-kit)]', on ? 'scale-y-100' : 'scale-y-0')} />
            <h3>
              <button type="button" aria-expanded={on} aria-controls={`${base}-${p.id}`} onClick={() => setOpen(on ? null : p.id)} className={cn('group grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-4 gap-y-2 px-4 py-6 text-left sm:px-6 md:grid-cols-[auto_minmax(0,1.6fr)_minmax(0,0.7fr)_minmax(0,0.8fr)_auto] md:gap-x-8', focusRing, 'focus-visible:ring-inset focus-visible:ring-offset-0')}>
                <span className={cn('flex size-11 items-center justify-center rounded-xl border transition-colors duration-300', on ? 'border-transparent bg-primary text-primary-foreground' : 'border-border text-foreground group-hover:border-border-strong')}>
                  <Icon name={p.icon ?? 'briefcase'} size={19} />
                </span>
                <span className="min-w-0">
                  <span className="block text-[18px] font-semibold tracking-[-0.01em] text-foreground">{p.title}</span>
                  <span className="mt-0.5 block text-pretty text-[14px] text-muted-foreground">{p.summary}</span>
                </span>
                <span className="col-start-2 text-[14px] text-muted-foreground md:col-start-3 md:row-start-1">
                  <span className="sr-only">{t.length} </span>
                  <span className="inline-flex items-center gap-1.5">
                    <Icon name="clock" size={14} />
                    {p.length}
                  </span>
                </span>
                <span className="col-start-2 text-[14px] md:col-start-4 md:row-start-1 md:text-right">
                  <span className="text-muted-foreground">{t.from} </span>
                  <span className="text-[17px] font-semibold tabular-nums text-foreground">{money(p.price, currency, locale)}</span>
                </span>
                <Icon name="chevron-down" size={18} className={cn('col-start-3 row-start-1 text-muted-foreground transition-transform duration-300 md:col-start-5', on && 'rotate-180')} />
              </button>
            </h3>
            <div id={`${base}-${p.id}`} hidden={!on} className="px-4 pb-8 sm:px-6 md:pl-[6.25rem]">
              {on && (
                <div className="grid grid-cols-1 gap-6 [animation:kitcons-swap_.45s_var(--ease-kit)] md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)] md:gap-10">
                  {p.deliverables?.length ? (
                    <div>
                      <p className="text-[13px] font-medium text-muted-foreground">{t.deliverables}</p>
                      <ul className="mt-3 grid gap-2 sm:grid-cols-2">
                        {p.deliverables.map((d, i) => (
                          <li key={d} className="flex gap-2.5 text-[14.5px] text-foreground [animation:kitcons-in_.4s_var(--ease-kit)_both]" style={{ animationDelay: `${100 + i * 60}ms` }}>
                            <Icon name="file-check" size={16} className="mt-[3px] text-primary" />
                            {d}
                          </li>
                        ))}
                      </ul>
                    </div>
                  ) : null}
                  <div className="flex flex-col gap-4">
                    {p.fit && (
                      <p className="text-[14.5px] text-foreground">
                        <span className="block text-[13px] font-medium text-muted-foreground">{t.fit}</span>
                        {p.fit}
                      </p>
                    )}
                    <Button href={p.href ?? '#'} icon="arrow-right" className="self-start max-sm:w-full">
                      {t.request}
                    </Button>
                  </div>
                </div>
              )}
            </div>
          </li>
        );
      })}
    </Stagger>
  );
}

/* ------------------------------------------------------------------ */
/* method                                                              */
/* ------------------------------------------------------------------ */

function Method({ phases = [], t }: ConsultingProps & { t: Texts }) {
  const [sel, setSel] = useState(0);
  const [ref, inView] = useInView<HTMLDivElement>();
  const n = phases.length || 1;
  const size = 360;
  const c = size / 2;
  const r = 132;
  const pts = phases.map((_, i) => {
    const a = (i / n) * Math.PI * 2 - Math.PI / 2;
    return { x: round(c + r * Math.cos(a), 2), y: round(c + r * Math.sin(a), 2) };
  });
  const p = phases[sel];
  const key = (e: React.KeyboardEvent) => {
    const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
    if (!d) return;
    e.preventDefault();
    const i = (sel + d + n) % n;
    setSel(i);
    (e.currentTarget as HTMLElement).querySelector<HTMLElement>(`[data-i="${i}"]`)?.focus();
  };
  const arc = round(2 * Math.PI * r, 2);
  return (
    <div className="grid grid-cols-1 items-center gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-16">
      <div ref={ref} role="tablist" aria-label={t.activities} onKeyDown={key} className="relative mx-auto aspect-square w-full max-w-[420px]">
        <svg viewBox={`0 0 ${size} ${size}`} className="absolute inset-0 size-full" aria-hidden>
          <circle cx={c} cy={c} r={r} fill="none" stroke="var(--border)" strokeWidth="1.5" />
          <circle cx={c} cy={c} r={r} fill="none" stroke="var(--primary)" strokeWidth="2" strokeLinecap="round" transform={`rotate(-90 ${c} ${c})`} strokeDasharray={arc} strokeDashoffset={round(inView ? arc * (1 - (sel + 1) / n) : arc, 2)} className="transition-[stroke-dashoffset] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" />
          <circle cx={c} cy={c} r={r - 26} fill="none" stroke="color-mix(in oklab, var(--primary) 35%, transparent)" strokeWidth="1" strokeDasharray="3 7" className="kitcons-anim [animation:kitcons-dash_3s_linear_infinite]" />
        </svg>
        <div className="absolute inset-[30%] flex flex-col items-center justify-center rounded-full text-center">
          <span key={sel} className="text-[13px] font-medium text-primary [animation:kitcons-in_.4s_var(--ease-kit)]">
            {fill(t.phase, { n: sel + 1 })}
          </span>
          <span key={`t${sel}`} className="mt-1 text-balance text-[17px] font-semibold leading-tight text-foreground [animation:kitcons-in_.45s_var(--ease-kit)]">
            {p?.title}
          </span>
        </div>
        {phases.map((ph, i) => {
          const on = i === sel;
          const done = i < sel;
          return (
            <button key={ph.id} data-i={i} type="button" role="tab" aria-selected={on} aria-label={`${fill(t.phase, { n: i + 1 })}, ${ph.title}`} tabIndex={on ? 0 : -1} onClick={() => setSel(i)} className={cn('absolute flex size-14 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border-2 bg-background transition-[border-color,background-color,color,transform] duration-500 hover:scale-105', focusRing, on ? 'scale-110 border-primary bg-primary text-primary-foreground shadow-[0_0_0_8px_color-mix(in_oklab,var(--primary)_16%,transparent)]' : done ? 'border-primary text-primary' : 'border-border-strong text-muted-foreground')} style={{ left: `${round((pts[i].x / size) * 100, 3)}%`, top: `${round((pts[i].y / size) * 100, 3)}%` }}>
              <Icon name={ph.icon ?? 'circle'} size={20} />
            </button>
          );
        })}
      </div>
      {p && (
        <div key={sel} role="tabpanel" className="[animation:kitcons-swap_.5s_var(--ease-kit)]">
          <p className="text-[13px] font-medium text-muted-foreground">{fill(t.phase, { n: sel + 1 })}</p>
          <h3 className="mt-1.5 text-[28px] font-semibold tracking-[-0.02em] text-foreground">{p.title}</h3>
          <p className="mt-3 text-pretty text-[16px] leading-relaxed text-muted-foreground">{p.text}</p>
          {p.activities?.length ? (
            <>
              <p className="mt-7 text-[13px] font-medium text-muted-foreground">{t.activities}</p>
              <ul className="mt-3 grid gap-2.5">
                {p.activities.map((a, i) => (
                  <li key={a} className="flex items-start gap-3 text-[15px] text-foreground [animation:kitcons-in_.4s_var(--ease-kit)_both]" style={{ animationDelay: `${120 + i * 60}ms` }}>
                    <span className="mt-[9px] h-px w-4 shrink-0 bg-primary" />
                    {a}
                  </li>
                ))}
              </ul>
            </>
          ) : null}
          {p.result && (
            <p className="mt-7 flex items-start gap-3 rounded-xl border border-border bg-card p-4 text-[15px] text-foreground">
              <Icon name="flag" size={18} className="mt-0.5 shrink-0 text-primary" />
              <span>
                <span className="block text-[13px] font-medium text-muted-foreground">{t.result}</span>
                {p.result}
              </span>
            </p>
          )}
          <div className="mt-6 flex gap-2">
            <Button variant="outline" size="sm" iconLeft="arrow-left" disabled={sel === 0} onClick={() => setSel((v) => Math.max(0, v - 1))} aria-label={t.back}>
              <span className="sr-only">{t.back}</span>
            </Button>
            <Button variant="outline" size="sm" icon="arrow-right" disabled={sel === n - 1} onClick={() => setSel((v) => Math.min(n - 1, v + 1))}>
              {t.next}
            </Button>
          </div>
        </div>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* estimator                                                           */
/* ------------------------------------------------------------------ */

function Estimator({ factors = [], baseDays = 4, dayRate = 1400, currency = 'EUR', t }: ConsultingProps & { t: Texts }) {
  const locale = useLocale();
  const [values, setValues] = useState<Record<string, number>>(Object.fromEntries(factors.map((f) => [f.id, f.value])));
  const days = Math.round(baseDays + factors.reduce((a, f) => a + (values[f.id] ?? f.value) * f.days, 0));
  const low = Math.round((days * dayRate * 0.9) / 500) * 500;
  const high = Math.round((days * dayRate * 1.2) / 500) * 500;
  const d = useTween(days);
  const lo = useTween(low);
  const hi = useTween(high);
  const weeks = Math.max(1, Math.round(days / 2.5));
  const nf = new Intl.NumberFormat(TAG[locale]);
  return (
    <div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] lg:gap-10">
      <div className="kit-card grid gap-7 p-6 sm:p-8">
        {factors.map((f) => {
          const v = values[f.id] ?? f.value;
          const pct = round(((v - f.min) / (f.max - f.min)) * 100, 2);
          return (
            <div key={f.id}>
              <div className="flex items-baseline justify-between gap-4">
                <label htmlFor={`est-${f.id}`} className="text-[15px] font-medium text-foreground">
                  {f.label}
                </label>
                <span className="text-[15px] font-semibold tabular-nums text-foreground">
                  {nf.format(v)}
                  {f.unit && <span className="ml-1 font-normal text-muted-foreground">{f.unit}</span>}
                </span>
              </div>
              <div className="relative mt-3">
                <div aria-hidden className="pointer-events-none absolute inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]">
                  <div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
                </div>
                <input id={`est-${f.id}`} type="range" min={f.min} max={f.max} step={f.step ?? 1} value={v} onChange={(e) => setValues((s) => ({ ...s, [f.id]: Number(e.target.value) }))} className="relative z-[1] h-11 w-full cursor-pointer appearance-none bg-transparent [&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:shadow-[0_0_0_2px_var(--primary),var(--shadow-md)] [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_var(--primary),var(--shadow-md)] focus-visible:outline-none focus-visible:[&::-webkit-slider-thumb]:ring-4 focus-visible:[&::-webkit-slider-thumb]:ring-ring" />
              </div>
              <div className="flex justify-between text-[12px] tabular-nums text-subtle-foreground" aria-hidden>
                <span>{nf.format(f.min)}</span>
                <span>{nf.format(f.max)}</span>
              </div>
            </div>
          );
        })}
      </div>
      <aside className="lg:sticky lg:top-8 lg:self-start">
        <div className="kit-card kit-beam overflow-hidden">
          <div className="p-6 sm:p-8">
            <p className="text-[13px] font-medium text-muted-foreground">{t.estimate}</p>
            <p className="mt-2 text-[44px] font-semibold leading-none tracking-[-0.03em] tabular-nums text-foreground" aria-live="polite">
              {Math.round(d)}
            </p>
            <p className="mt-1 text-[15px] text-muted-foreground">
              {fill(t.days, { n: '' }).trim()} · {fill(t.weeks, { n: weeks })}
            </p>
            <div className="mt-6 flex gap-1" aria-hidden>
              {Array.from({ length: 30 }, (_, i) => (
                <span key={i} className={cn('h-6 flex-1 rounded-[3px] transition-colors duration-300', i < Math.min(30, days) ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]')} style={{ transitionDelay: `${i * 12}ms` }} />
              ))}
            </div>
          </div>
          <div className="border-t border-border bg-surface p-6 sm:p-8">
            <p className="text-[13px] text-muted-foreground">{t.range}</p>
            <p className="mt-1 text-[24px] font-semibold tracking-[-0.02em] tabular-nums text-foreground">
              {money(Math.round(lo / 100) * 100, currency, locale)} <span className="text-[15px] font-normal text-muted-foreground">{t.and}</span> {money(Math.round(hi / 100) * 100, currency, locale)}
            </p>
            <Button href="#" icon="arrow-right" className="mt-5 w-full">
              {t.proposal}
            </Button>
            <p className="mt-3 text-[12.5px] leading-snug text-muted-foreground">{t.note}</p>
          </div>
        </div>
      </aside>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* references                                                          */
/* ------------------------------------------------------------------ */

function Count({ value, run, locale }: { value: number; run: boolean; locale: Locale }) {
  const [v, setV] = useState(value);
  useEffect(() => {
    if (!run || prefersReducedMotion()) return setV(value);
    let raf = 0;
    const t0 = performance.now();
    const tick = (now: number) => {
      const k = Math.min(1, (now - t0) / 1200);
      setV(value * (1 - Math.pow(1 - k, 3)));
      if (k < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [run, value]);
  return <>{new Intl.NumberFormat(TAG[locale]).format(Math.round(v))}</>;
}

function References({ references = [], t }: ConsultingProps & { t: Texts }) {
  const locale = useLocale();
  const [sel, setSel] = useState(0);
  const [ref, inView] = useInView<HTMLDivElement>();
  const [row, fade] = useScrollFade<HTMLDivElement>();
  const r = references[sel];
  const base = useId();
  return (
    <div ref={ref}>
      <div ref={row} className={cn('kit-no-scrollbar -mx-6 overflow-x-auto px-6 sm:mx-0 sm:px-0', fade)}>
        <div role="tablist" aria-label={t.clients} className="flex gap-1 border-b border-border sm:gap-2">
          {references.map((x, i) => {
            const on = i === sel;
            return (
              <button key={x.id} type="button" role="tab" id={`${base}-t${i}`} aria-selected={on} aria-controls={`${base}-p`} onClick={() => setSel(i)} onKeyDown={(e) => { const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0; if (!d) return; e.preventDefault(); const n = (sel + d + references.length) % references.length; setSel(n); document.getElementById(`${base}-t${n}`)?.focus(); }} tabIndex={on ? 0 : -1} className={cn('relative -mb-px flex min-h-14 shrink-0 items-center gap-2.5 border-b-2 px-3 pb-3 pt-2 text-[15px] font-medium transition-colors max-sm:flex-1 max-sm:justify-center max-sm:px-1.5', focusRing, on ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground')}>
                {x.logo && <span className={cn('flex h-6 items-center transition-opacity [&_svg]:h-5', on ? 'opacity-100' : 'opacity-60 grayscale')}>{x.logo}</span>}
                {!x.logo && x.client}
                {x.logo && <span className="sr-only">{x.client}</span>}
              </button>
            );
          })}
        </div>
      </div>
      {r && (
        <div key={sel} id={`${base}-p`} role="tabpanel" aria-labelledby={`${base}-t${sel}`} className="mt-10 [animation:kitcons-swap_.5s_var(--ease-kit)]">
          <p className="text-[14px] text-muted-foreground">
            <span className="font-semibold text-foreground">{r.client}</span>
            {r.industry && ` · ${r.industry}`}
          </p>
          <div className="mt-6 grid grid-cols-1 gap-px overflow-hidden rounded-2xl border border-border bg-border md:grid-cols-3">
            {[
              [t.challenge, r.challenge, 'alert-circle'],
              [t.approach, r.approach, 'route'],
              [t.outcome, r.result, 'trending-up'],
            ].map(([label, text, icon], i) => (
              <div key={label} className="bg-card p-6">
                <div className="[animation:kitcons-in_.45s_var(--ease-kit)_both]" style={{ animationDelay: `${i * 90}ms` }}>
                  <span className={cn('flex size-9 items-center justify-center rounded-lg', i === 2 ? 'bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-primary' : 'bg-muted text-foreground')}>
                    <Icon name={icon as IconName} size={17} />
                  </span>
                  <p className="mt-4 text-[13px] font-medium text-muted-foreground">{label}</p>
                  <p className="mt-1.5 text-pretty text-[15px] leading-relaxed text-foreground">{text}</p>
                </div>
              </div>
            ))}
          </div>
          {(r.numbers?.length || r.quote) && (
            <div className="mt-10 grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
              {r.numbers?.length ? (
                <dl className="grid grid-cols-3 gap-4">
                  {r.numbers.map((n) => (
                    <div key={n.label} className="flex flex-col-reverse justify-end">
                      <dt className="mt-1.5 text-[13px] leading-snug text-muted-foreground">{n.label}</dt>
                      <dd className="text-[30px] font-semibold leading-none tracking-[-0.03em] tabular-nums text-foreground sm:text-[36px]">
                        <Count value={n.value} run={inView} locale={locale} />
                        {n.suffix}
                      </dd>
                    </div>
                  ))}
                </dl>
              ) : null}
              {r.quote && (
                <figure className="border-l-2 border-primary pl-5">
                  <blockquote className="text-pretty text-[16px] italic leading-relaxed text-foreground">{r.quote.text}</blockquote>
                  <figcaption className="mt-2 text-[13.5px] text-muted-foreground">
                    {r.quote.name}
                    {r.quote.role && `, ${r.quote.role}`}
                  </figcaption>
                </figure>
              )}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* brief                                                               */
/* ------------------------------------------------------------------ */

function Brief({ topics = [], onSubmit, t }: ConsultingProps & { t: Texts }) {
  const base = useId();
  const [step, setStep] = useState(0);
  const [dir, setDir] = useState<'next' | 'prev'>('next');
  const [picked, setPicked] = useState<string[]>([]);
  const [situation, setSituation] = useState('');
  const [size, setSize] = useState(1);
  const [when, setWhen] = useState(1);
  const [contact, setContact] = useState({ name: '', company: '', email: '' });
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [sent, setSent] = useState(false);
  const sizes = t.sizes.split('|');
  const whens = t.whens.split('|');
  const steps = [t.s1, t.s2, t.s3];
  const go = (n: number) => {
    setDir(n > step ? 'next' : 'prev');
    setStep(n);
  };
  const next = () => {
    const e: Record<string, string> = {};
    if (step === 0 && !picked.length) e.topics = t.pickOne;
    if (step === 2) {
      if (!contact.name.trim()) e.name = t.required;
      if (!contact.company.trim()) e.company = t.required;
      if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contact.email)) e.email = t.invalid;
    }
    setErrors(e);
    if (Object.keys(e).length) return;
    if (step < 2) return go(step + 1);
    onSubmit?.({ topics: picked.join(', '), situation, size: sizes[size], when: whens[when], ...contact });
    setSent(true);
  };
  const field = 'h-11 w-full rounded-lg border bg-background px-3 text-[15px] text-foreground placeholder:text-subtle-foreground';
  if (sent)
    return (
      <div role="status" className="kit-card mx-auto max-w-[620px] p-8 text-center [animation:kitcons-swap_.5s_var(--ease-kit)] sm:p-12">
        <span className="mx-auto flex size-14 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-success [animation:kitcons-pop_.5s_var(--ease-kit)]">
          <Icon name="check" size={26} />
        </span>
        <h3 className="mt-5 text-h3 font-semibold text-foreground">{t.sentTitle}</h3>
        <p className="mt-2 text-pretty text-[15px] text-muted-foreground">{t.sentText}</p>
        <Button variant="outline" className="mt-6" onClick={() => { setSent(false); setStep(0); }}>
          {t.again}
        </Button>
      </div>
    );
  return (
    <div className="kit-card mx-auto max-w-[760px] overflow-hidden">
      <div className="border-b border-border px-6 pt-6 sm:px-8">
        <ol className="flex gap-2">
          {steps.map((s, i) => (
            <li key={s} className="min-w-0 flex-1">
              <button type="button" disabled={i > step} onClick={() => go(i)} aria-current={i === step ? 'step' : undefined} className={cn('w-full pb-4 text-left', focusRing, 'rounded-sm disabled:cursor-default')}>
                <span className="relative block h-1 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]">
                  <span className="absolute inset-y-0 left-0 w-full origin-left rounded-full bg-primary transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${i <= step ? 1 : 0})` }} />
                </span>
                <span className={cn('mt-2.5 block truncate text-[13px] font-medium', i === step ? 'text-foreground' : 'text-muted-foreground')}>
                  <span className="tabular-nums">{i + 1}</span> · {s}
                </span>
              </button>
            </li>
          ))}
        </ol>
      </div>
      <div key={step} className={cn('min-h-[300px] p-6 sm:p-8', dir === 'next' ? '[animation:kitcons-next_.45s_var(--ease-kit)]' : '[animation:kitcons-prev_.45s_var(--ease-kit)]')}>
        <p className="sr-only" aria-live="polite">
          {fill(t.step, { n: step + 1, total: 3 })}
        </p>
        {step === 0 && (
          <fieldset>
            <legend className="text-[19px] font-semibold text-foreground">{t.topicsQ}</legend>
            <div className="mt-5 flex flex-wrap gap-2">
              {topics.map((x) => {
                const on = picked.includes(x);
                return (
                  <label key={x} className={cn('inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-full border px-4 text-[14.5px] font-medium transition-[background-color,border-color,color] duration-300 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring', on ? 'border-transparent bg-foreground text-background' : 'border-border text-foreground hover:border-border-strong')}>
                    <input type="checkbox" checked={on} onChange={() => { setPicked((v) => (on ? v.filter((y) => y !== x) : [...v, x])); setErrors({}); }} className="sr-only" />
                    {on && <Icon name="check" size={15} className="[animation:kitcons-pop_.3s_var(--ease-kit)]" />}
                    {x}
                  </label>
                );
              })}
            </div>
            {errors.topics && <p className="mt-3 text-[13px] text-destructive">{errors.topics}</p>}
          </fieldset>
        )}
        {step === 1 && (
          <div className="grid gap-6">
            <div>
              <label htmlFor={`${base}-sit`} className="text-[19px] font-semibold text-foreground">
                {t.situation}
              </label>
              <textarea id={`${base}-sit`} rows={3} value={situation} onChange={(e) => setSituation(e.target.value)} placeholder={t.situationPh} className={cn('mt-4 w-full resize-y rounded-lg border border-border-strong bg-background px-3 py-2.5 text-[15px] text-foreground placeholder:text-subtle-foreground', focusRing)} />
            </div>
            {[
              { label: t.size, list: sizes, value: size, set: setSize },
              { label: t.when, list: whens, value: when, set: setWhen },
            ].map((g) => (
              <fieldset key={g.label}>
                <legend className="mb-2.5 text-[14px] font-medium text-foreground">{g.label}</legend>
                <div className={cn('grid gap-2', g.list.length === 4 ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-1 sm:grid-cols-3')}>
                  {g.list.map((s, i) => (
                    <label key={s} className={cn('flex min-h-11 cursor-pointer items-center justify-center rounded-lg border px-2 text-center text-[14px] font-medium transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring', g.value === i ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:text-foreground')}>
                      <input type="radio" name={`${base}-${g.label}`} checked={g.value === i} onChange={() => g.set(i)} className="sr-only" />
                      {s}
                    </label>
                  ))}
                </div>
              </fieldset>
            ))}
          </div>
        )}
        {step === 2 && (
          <div className="grid grid-cols-1 gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,0.85fr)]">
            <div className="grid content-start gap-5">
              {(['name', 'company', 'email'] as const).map((k) => (
                <div key={k}>
                  <label htmlFor={`${base}-${k}`} className="mb-2 block text-[14px] font-medium text-foreground">
                    {t[k]}
                  </label>
                  <input id={`${base}-${k}`} type={k === 'email' ? 'email' : 'text'} autoComplete={k === 'company' ? 'organization' : k} value={contact[k]} onChange={(e) => setContact((c) => ({ ...c, [k]: e.target.value }))} placeholder={k === 'email' ? t.emailPh : undefined} aria-invalid={!!errors[k]} aria-describedby={errors[k] ? `${base}-${k}-e` : undefined} className={cn(field, errors[k] ? 'border-destructive' : 'border-border-strong', focusRing)} />
                  {errors[k] && (
                    <p id={`${base}-${k}-e`} className="mt-1.5 text-[13px] text-destructive">
                      {errors[k]}
                    </p>
                  )}
                </div>
              ))}
            </div>
            <div className="self-start rounded-xl border border-border bg-surface p-5">
              <p className="text-[13px] font-medium text-muted-foreground">{t.summary}</p>
              <ul className="mt-3 flex flex-wrap gap-1.5">
                {picked.map((x) => (
                  <li key={x} className="rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] px-2.5 py-1 text-[12.5px] font-medium text-foreground">
                    {x}
                  </li>
                ))}
              </ul>
              {situation && <p className="mt-3 line-clamp-3 text-[13.5px] text-foreground/85">{situation}</p>}
              <p className="mt-3 text-[13px] text-muted-foreground">
                {sizes[size]} · {whens[when]}
              </p>
            </div>
          </div>
        )}
      </div>
      <div className="flex items-center justify-between gap-3 border-t border-border px-6 py-4 sm:px-8">
        <Button variant="ghost" iconLeft="arrow-left" onClick={() => go(Math.max(0, step - 1))} className={cn(step === 0 && 'invisible max-sm:hidden')}>
          {t.back}
        </Button>
        <span className="text-[13px] tabular-nums text-muted-foreground max-sm:hidden">{fill(t.step, { n: step + 1, total: 3 })}</span>
        <Button icon={step === 2 ? 'send' : 'arrow-right'} onClick={next} className="max-sm:flex-1">
          {step === 2 ? t.send : t.next}
        </Button>
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Consulting                                                          */
/* ------------------------------------------------------------------ */

/**
 * Sections for consultants and advisory firms.
 *   packages    fixed packages as rows that open with deliverables
 *   method      the phases on a ring, each with activities and a result
 *   estimator   sliders give days and a price range
 *   references  one client at a time with challenge, approach, result and numbers
 *   brief       a brief in three steps with a summary
 */
export function Consulting(props: ConsultingProps) {
  const { variant = 'packages', eyebrow, title, description, labels, className } = props;
  const t = useTexts(TEXTS, labels);
  const body = (() => {
    switch (variant) {
      case 'method':
        return <Method {...props} t={t} />;
      case 'estimator':
        return <Estimator {...props} t={t} />;
      case 'references':
        return <References {...props} t={t} />;
      case 'brief':
        return <Brief {...props} t={t} />;
      default:
        return <Packages {...props} t={t} />;
    }
  })();
  return (
    <Section className={className} background={variant === 'brief' ? 'surface' : variant === 'estimator' ? 'glow' : 'default'}>
      <ConsMotion />
      {title ? <SectionHeader eyebrow={eyebrow} title={title} description={description} align={variant === 'brief' ? 'center' : variant === 'packages' ? 'split' : 'left'} /> : null}
      {body}
    </Section>
  );
}

Häufige Fragen

Wie installiere ich Beratung?

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

Welche Varianten hat Beratung?

5 Varianten, Pakete, Methode, Aufwandsrechner, Referenzen und Briefing. Du wählst sie mit der Prop variant, zum Beispiel variant="method".

Kann ich die Texte von Beratung übersetzen?

Ja. Beratung bringt 44 eigene Texte auf Deutsch und Englisch mit, etwa „ab“, „Dauer“ und „Du bekommst“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Beratung einbauen?

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

Kann ich Beratung ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert