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
Variants
Details
Was Beratung mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Coaching und Beratung
- Bereich
- Industries
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/consulting.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 44 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ConsultingProps aus components/ui/consulting.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalConsultingVariantpackagesmethodestimatorreferencesbrief
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodepackagesoptionalConsultingPackage[]phasesoptionalConsultingPhase[]referencesoptionalConsultingReference[]factorsoptionalConsultingFactor[]estimator
baseDaysoptionalnumberdayRateoptionalnumbertopicsoptionalstring[]brief: topics to pick from
currencyoptionalstringonSubmitoptional(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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| from | Englisch from | Deutsch ab |
| length | Englisch Length | Deutsch Dauer |
| deliverables | Englisch You get | Deutsch Du bekommst |
| fit | Englisch Right for | Deutsch Passt für |
| request | Englisch Request this package | Deutsch Paket anfragen |
| phase | Englisch Phase {n} | Deutsch Phase {n} |
| activities | Englisch What we do | Deutsch Was wir tun |
| result | Englisch Result | Deutsch Ergebnis |
| estimate | Englisch Estimate | Deutsch Schätzung |
| days | Englisch {n} consulting days | Deutsch {n} Beratertage |
| range | Englisch Likely between | Deutsch Voraussichtlich zwischen |
| and | Englisch and | Deutsch und |
| weeks | Englisch about {n} weeks | Deutsch etwa {n} Wochen |
| proposal | Englisch Request a proposal | Deutsch Angebot anfragen |
| note | Englisch 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. |
| challenge | Englisch Challenge | Deutsch Ausgangslage |
| approach | Englisch Approach | Deutsch Vorgehen |
| outcome | Englisch Result | Deutsch Ergebnis |
| clients | Englisch Clients | Deutsch Kunden |
| step | Englisch Step {n} of {total} | Deutsch Schritt {n} von {total} |
| s1 | Englisch Topic | Deutsch Thema |
| s2 | Englisch Situation | Deutsch Lage |
| s3 | Englisch Contact | Deutsch Kontakt |
| topicsQ | Englisch What is it about? | Deutsch Worum geht es? |
| pickOne | Englisch Pick at least one topic. | Deutsch Wähle mindestens ein Thema. |
| situation | Englisch Where do you stand today? | Deutsch Wo steht ihr heute? |
| situationPh | Englisch We are 80 people in two offices and decisions take weeks. | Deutsch Wir sind 80 Leute an zwei Standorten und Entscheidungen dauern Wochen. |
| size | Englisch Size of the organisation | Deutsch Größe der Organisation |
| sizes | Englisch Up to 20|20 to 100|100 to 500|More than 500 | Deutsch Bis 20|20 bis 100|100 bis 500|Mehr als 500 |
| when | Englisch When should it start? | Deutsch Wann soll es losgehen? |
| whens | Englisch Now|This quarter|Later this year | Deutsch Sofort|Dieses Quartal|Später im Jahr |
| name | Englisch Name | Deutsch Name |
| company | Englisch Organisation | Deutsch Organisation |
| Englisch Email | Deutsch E-Mail | |
| emailPh | Englisch you@example.com | Deutsch du@beispiel.de |
| back | Englisch Back | Deutsch Zurück |
| next | Englisch Next | Deutsch Weiter |
| send | Englisch Send brief | Deutsch Briefing senden |
| required | Englisch Please fill in this field. | Deutsch Bitte fülle dieses Feld aus. |
| invalid | Englisch Please enter an email address like name@example.com. | Deutsch Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein. |
| sentTitle | Englisch Brief received | Deutsch Briefing ist angekommen |
| sentText | Englisch 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. |
| again | Englisch Write another brief | Deutsch Weiteres Briefing schreiben |
| summary | Englisch Your brief | Deutsch 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
'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.