Freelancer
Für Freelancer und Ein-Personen-Studios. Ein Auftakt mit Live-Verfügbarkeit, die nächsten Monate Woche für Woche, Sätze mit Tagesrechner, eine Projektanfrage mit Budgetspanne und eine typische Woche als Stundenplan.
Eine von 3 Components in Personal Brand
Variants
Details
Was Freelancer mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Personal Brand
- Bereich
- Industries
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/freelancer.tsx
- Bringt mit
- utilskitbuttoniconmotionnumber-tickersection
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 52 eigene Texte
- In Sammlungen
- Portfolio
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle FreelancerProps aus components/ui/freelancer.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalFreelancerVariantintroavailabilityratesinquiryweek
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodepersonoptional{ name: string; role: string; photo?: React.ReactNode; location?: string; bio?: string }statusoptionalstringe.g. "Available from 3 March"
responseoptionalstringe.g. "Answers within a day"
statsoptional{ value: number; suffix?: string; label: string }[]skillsoptionalstring[]weeksoptionalFreelancerWeek[]ratesoptionalFreelancerRate[]dayRateoptionalnumberrates: price for one day, used by the calculator
typesoptional{ id: string; label: string; icon?: IconName }[]inquiry
budgetoptional{ min: number; max: number; step: number; from: number; to: number }blocksoptionalFreelancerBlock[]channelsoptional{ label: string; detail: string; icon: IconName }[]currencyoptionalstringonSubmitoptional(data: Record<string, string>) => voidlinksoptional{ availability?: string; work?: string; inquiry?: string }Where the jumps go, sections of the same page by default: #availability, #work, #inquiry
Eingebaute Texte
Diese Texte schreibt Freelancer selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 52 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| check | Englisch Check availability | Deutsch Verfügbarkeit ansehen |
| askWeek | Englisch Ask for this week | Deutsch Diese Woche anfragen |
| range | Englisch {a} to {b} | Deutsch {a} bis {b} |
| write | Englisch Write to {name} | Deutsch {name} schreiben |
| work | Englisch See my work | Deutsch Arbeiten ansehen |
| booked | Englisch Booked | Deutsch Ausgebucht |
| partly | Englisch Partly free | Deutsch Teilweise frei |
| free | Englisch Free | Deutsch Frei |
| firstFree | Englisch First free week | Deutsch Erste freie Woche |
| week | Englisch Week of {d} | Deutsch Woche ab {d} |
| legend | Englisch Legend | Deutsch Legende |
| hour | Englisch Hour | Deutsch Stunde |
| day | Englisch Day | Deutsch Tag |
| project | Englisch Project | Deutsch Projekt |
| pricing | Englisch Pricing | Deutsch Abrechnung |
| days | Englisch Days | Deutsch Tage |
| fewer | Englisch One day less | Deutsch Einen Tag weniger |
| more | Englisch One day more | Deutsch Einen Tag mehr |
| total | Englisch Total for {n} days | Deutsch Gesamt für {n} Tage |
| discount | Englisch {p}% off from 10 days | Deutsch {p} % Rabatt ab 10 Tagen |
| book | Englisch Book these days | Deutsch Diese Tage buchen |
| type | Englisch What do you need? | Deutsch Was brauchst du? |
| budget | Englisch Budget | Deutsch Budget |
| budgetMin | Englisch Lowest budget | Deutsch Niedrigstes Budget |
| budgetMax | Englisch Highest budget | Deutsch Höchstes Budget |
| timeline | Englisch When do you need it? | Deutsch Wann brauchst du es? |
| timelines | Englisch Within a month|In 1 to 3 months|Later this year|Not sure yet | Deutsch Innerhalb eines Monats|In 1 bis 3 Monaten|Später im Jahr|Noch offen |
| about | Englisch Tell me about the project | Deutsch Erzähl mir vom Projekt |
| aboutPh | Englisch A new booking flow for our practice, the current one loses half the patients. | Deutsch Eine neue Terminbuchung für unsere Praxis, die jetzige verliert die Hälfte der Patienten. |
| name | Englisch Name | Deutsch Name |
| Englisch Email | Deutsch E-Mail | |
| emailPh | Englisch you@example.com | Deutsch du@beispiel.de |
| send | Englisch Send enquiry | Deutsch Anfrage 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. |
| pickType | Englisch Pick what you need. | Deutsch Wähle, was du brauchst. |
| sentTitle | Englisch Thanks, I will get back to you | Deutsch Danke, ich melde mich |
| sentText | Englisch You get an answer within one working day with first questions and possible dates. | Deutsch Du bekommst innerhalb eines Werktags eine Antwort mit ersten Fragen und möglichen Terminen. |
| again | Englisch Send another enquiry | Deutsch Weitere Anfrage senden |
| summary | Englisch Your enquiry | Deutsch Deine Anfrage |
| open | Englisch open | Deutsch offen |
| focus | Englisch Focus work | Deutsch Fokusarbeit |
| call | Englisch Calls | Deutsch Gespräche |
| admin | Englisch Admin | Deutsch Verwaltung |
| off | Englisch Off | Deutsch Frei |
| reach | Englisch How to reach me | Deutsch So erreichst du mich |
| mon | Englisch Mon | Deutsch Mo |
| tue | Englisch Tue | Deutsch Di |
| wed | Englisch Wed | Deutsch Mi |
| thu | Englisch Thu | Deutsch Do |
| fri | Englisch Fri | Deutsch Fr |
| now | Englisch Now | Deutsch Jetzt |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Freelancer reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links und Pfeil rechts.
- Setzt aria-checked, aria-describedby, aria-invalid, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen radio, radiogroup und status.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 794 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 { Parallax, Stagger, Magnetic } from '@/components/ui/motion';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Section, SectionHeader } from '@/components/ui/section';
/*
* Sections for freelancers: who is available when, what it costs, how an
* enquiry works and how a week with them looks.
*
* intro name, role, availability with a live dot, answer time, a few numbers and the main action
* availability the next months week by week, booked, partly free and free, the first free week marked
* rates hour, day or project behind a gliding switch, a small calculator for days
* inquiry project type, budget range with two handles, timeline and a summary before sending
* week a typical week as a timetable, focus blocks, calls and quiet times, how to reach them
*/
export type FreelancerVariant = 'intro' | 'availability' | 'rates' | 'inquiry' | 'week';
export interface FreelancerWeek {
/** Monday of the week, YYYY-MM-DD */
start: string;
/** 0 free, 0.5 partly, 1 booked */
load: number;
}
export interface FreelancerRate {
id: string;
label: string;
/** e.g. "per hour" */
unit: string;
price: number;
note?: string;
includes?: string[];
}
export interface FreelancerBlock {
/** 0 Monday to 4 Friday */
day: number;
/** Hours as numbers, e.g. 9 and 12.5 */
from: number;
to: number;
label: string;
kind: 'focus' | 'call' | 'admin' | 'off';
}
export interface FreelancerProps {
variant?: FreelancerVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
person?: { name: string; role: string; photo?: React.ReactNode; location?: string; bio?: string };
/** e.g. "Available from 3 March" */
status?: string;
/** e.g. "Answers within a day" */
response?: string;
stats?: { value: number; suffix?: string; label: string }[];
skills?: string[];
weeks?: FreelancerWeek[];
rates?: FreelancerRate[];
/** rates: price for one day, used by the calculator */
dayRate?: number;
/** inquiry */
types?: { id: string; label: string; icon?: IconName }[];
budget?: { min: number; max: number; step: number; from: number; to: number };
blocks?: FreelancerBlock[];
channels?: { label: string; detail: string; icon: IconName }[];
currency?: string;
onSubmit?: (data: Record<string, string>) => void;
/** Where the jumps go, sections of the same page by default: #availability, #work, #inquiry */
links?: { availability?: string; work?: string; inquiry?: string };
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
check: 'Check availability',
askWeek: 'Ask for this week',
range: '{a} to {b}',
write: 'Write to {name}',
work: 'See my work',
booked: 'Booked',
partly: 'Partly free',
free: 'Free',
firstFree: 'First free week',
week: 'Week of {d}',
legend: 'Legend',
hour: 'Hour',
day: 'Day',
project: 'Project',
pricing: 'Pricing',
days: 'Days',
fewer: 'One day less',
more: 'One day more',
total: 'Total for {n} days',
discount: '{p}% off from 10 days',
book: 'Book these days',
type: 'What do you need?',
budget: 'Budget',
budgetMin: 'Lowest budget',
budgetMax: 'Highest budget',
timeline: 'When do you need it?',
timelines: 'Within a month|In 1 to 3 months|Later this year|Not sure yet',
about: 'Tell me about the project',
aboutPh: 'A new booking flow for our practice, the current one loses half the patients.',
name: 'Name',
email: 'Email',
emailPh: 'you@example.com',
send: 'Send enquiry',
required: 'Please fill in this field.',
invalid: 'Please enter an email address like name@example.com.',
pickType: 'Pick what you need.',
sentTitle: 'Thanks, I will get back to you',
sentText: 'You get an answer within one working day with first questions and possible dates.',
again: 'Send another enquiry',
summary: 'Your enquiry',
open: 'open',
focus: 'Focus work',
call: 'Calls',
admin: 'Admin',
off: 'Off',
reach: 'How to reach me',
mon: 'Mon',
tue: 'Tue',
wed: 'Wed',
thu: 'Thu',
fri: 'Fri',
now: 'Now',
},
de: {
check: 'Verfügbarkeit ansehen',
askWeek: 'Diese Woche anfragen',
range: '{a} bis {b}',
write: '{name} schreiben',
work: 'Arbeiten ansehen',
booked: 'Ausgebucht',
partly: 'Teilweise frei',
free: 'Frei',
firstFree: 'Erste freie Woche',
week: 'Woche ab {d}',
legend: 'Legende',
hour: 'Stunde',
day: 'Tag',
project: 'Projekt',
pricing: 'Abrechnung',
days: 'Tage',
fewer: 'Einen Tag weniger',
more: 'Einen Tag mehr',
total: 'Gesamt für {n} Tage',
discount: '{p} % Rabatt ab 10 Tagen',
book: 'Diese Tage buchen',
type: 'Was brauchst du?',
budget: 'Budget',
budgetMin: 'Niedrigstes Budget',
budgetMax: 'Höchstes Budget',
timeline: 'Wann brauchst du es?',
timelines: 'Innerhalb eines Monats|In 1 bis 3 Monaten|Später im Jahr|Noch offen',
about: 'Erzähl mir vom Projekt',
aboutPh: 'Eine neue Terminbuchung für unsere Praxis, die jetzige verliert die Hälfte der Patienten.',
name: 'Name',
email: 'E-Mail',
emailPh: 'du@beispiel.de',
send: 'Anfrage senden',
required: 'Bitte fülle dieses Feld aus.',
invalid: 'Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.',
pickType: 'Wähle, was du brauchst.',
sentTitle: 'Danke, ich melde mich',
sentText: 'Du bekommst innerhalb eines Werktags eine Antwort mit ersten Fragen und möglichen Terminen.',
again: 'Weitere Anfrage senden',
summary: 'Deine Anfrage',
open: 'offen',
focus: 'Fokusarbeit',
call: 'Gespräche',
admin: 'Verwaltung',
off: 'Frei',
reach: 'So erreichst du mich',
mon: 'Mo',
tue: 'Di',
wed: 'Mi',
thu: 'Do',
fri: 'Fr',
now: 'Jetzt',
},
};
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';
const fit = '[&>*]:size-full [&_img]:object-cover';
function money(value: number, currency: string, locale: Locale) {
return new Intl.NumberFormat(TAG[locale], { style: 'currency', currency, maximumFractionDigits: 0 }).format(value);
}
const utc = (iso: string) => {
const [y, m, d] = iso.split('-').map(Number);
return new Date(Date.UTC(y, m - 1, d));
};
const dfmt = (iso: string, locale: Locale, o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(TAG[locale], { timeZone: 'UTC', ...o }).format(utc(iso));
const MOTION_CSS = `
@keyframes kitfree-in{from{opacity:0;transform:translateY(8px)}}
@keyframes kitfree-swap{from{opacity:0;filter:blur(4px);transform:translateY(6px)}}
@keyframes kitfree-pop{0%{transform:scale(.5);opacity:0}60%{transform:scale(1.12);opacity:1}100%{transform:scale(1)}}
@keyframes kitfree-cell{from{opacity:0;transform:scale(.6)}}
@media (prefers-reduced-motion:reduce){[class*="kitfree-"],.kitfree-anim{animation:none!important;transition:none!important}}`;
function FreeMotion() {
return (
<style href="kitfree-motion" precedence="default">
{MOTION_CSS}
</style>
);
}
function useTween(value: number, ms = 450) {
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;
}
function Live({ text, tone = 'success' }: { text: string; tone?: 'success' | 'primary' }) {
const bg = tone === 'success' ? 'bg-success' : 'bg-primary';
return (
<span className={cn('inline-flex min-h-8 items-center gap-2 self-start rounded-full px-3 text-[13.5px] font-medium', tone === 'success' ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground' : 'bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-foreground')}>
<span className="relative flex size-2">
<span aria-hidden className={cn('absolute inset-0 animate-ping rounded-full opacity-60 motion-reduce:animate-none', bg)} />
<span className={cn('relative size-2 rounded-full', bg)} />
</span>
{text}
</span>
);
}
/** A row of choices with a marker that glides to the active one */
function Segmented({ options, value, onChange, label, className }: { options: { id: string; label: React.ReactNode }[]; value: string; onChange: (id: string) => void; label: string; className?: string }) {
const wrap = useRef<HTMLDivElement>(null);
const [box, setBox] = useState<{ x: number; w: number } | null>(null);
useEffect(() => {
const el = wrap.current;
if (!el) return;
const measure = () => {
const b = el.querySelector<HTMLElement>('[aria-checked="true"]');
if (b) setBox({ x: b.offsetLeft, w: b.offsetWidth });
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [value]);
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 = options.findIndex((o) => o.id === value);
const n = options[(i + d + options.length) % options.length];
onChange(n.id);
wrap.current?.querySelector<HTMLElement>(`[data-v="${n.id}"]`)?.focus();
};
return (
<div ref={wrap} role="radiogroup" aria-label={label} onKeyDown={key} className={cn('relative inline-flex max-w-full rounded-xl border border-border bg-muted p-1', className)}>
{box && <span aria-hidden className="absolute bottom-1 left-0 top-1 rounded-lg bg-card shadow-[var(--highlight),var(--shadow-sm)] ring-1 ring-border-strong transition-[transform,width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: box.w, transform: `translateX(${box.x}px)` }} />}
{options.map((o) => {
const on = o.id === value;
return (
<button key={o.id} type="button" role="radio" data-v={o.id} aria-checked={on} tabIndex={on ? 0 : -1} onClick={() => onChange(o.id)} className={cn('relative z-[1] inline-flex min-h-10 flex-1 items-center justify-center whitespace-nowrap rounded-lg px-4 text-[14px] font-medium transition-colors max-sm:min-h-11 sm:flex-none', focusRing, on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', on && !box && 'bg-card shadow-[var(--shadow-sm)]')}>
{o.label}
</button>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ */
/* intro */
/* ------------------------------------------------------------------ */
function Intro({ person, status, response, stats = [], skills = [], links, t }: FreelancerProps & { t: Texts }) {
if (!person) return null;
return (
<div className="grid grid-cols-1 items-center gap-12 lg:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)] lg:gap-16">
<div>
<div className="kit-rise flex flex-wrap items-center gap-2">
{status && <Live text={status} />}
{response && (
<span className="inline-flex min-h-8 items-center gap-1.5 rounded-full border border-border px-3 text-[13.5px] text-muted-foreground">
<Icon name="clock" size={14} />
{response}
</span>
)}
</div>
<h2 className="kit-rise mt-6 text-balance text-h1 font-semibold text-foreground [--kit-delay:60ms]">{person.role}</h2>
{person.bio && <p className="kit-rise mt-5 max-w-[36rem] text-pretty text-lead text-muted-foreground [--kit-delay:120ms]">{person.bio}</p>}
<div className="kit-rise mt-8 flex flex-wrap gap-3 [--kit-delay:180ms] max-sm:flex-col">
<Magnetic className="max-sm:flex max-sm:w-full">
<Button href={links?.availability ?? '#availability'} icon="arrow-right" className="max-sm:w-full">
{t.check}
</Button>
</Magnetic>
<Button href={links?.work ?? '#work'} variant="outline" className="max-sm:w-full">
{t.work}
</Button>
</div>
{skills.length > 0 && (
<Stagger as="ul" step={40} className="mt-8 flex flex-wrap gap-1.5">
{skills.map((s) => (
<li key={s} className="rounded-full border border-border px-3 py-1 text-[13px] text-foreground transition-colors hover:border-border-strong">
{s}
</li>
))}
</Stagger>
)}
</div>
<div className="relative mx-auto w-full max-w-[440px]">
<div className="kit-card relative overflow-hidden">
<div className="relative aspect-[4/3] overflow-hidden bg-muted" data-clip-ok>
<Parallax speed={40} className={cn('absolute -inset-y-6 inset-x-0', fit)}>
{person.photo}
</Parallax>
</div>
<div className="flex items-center justify-between gap-3 border-b border-border px-5 py-4">
<div>
<p className="text-[16px] font-semibold text-foreground">{person.name}</p>
{person.location && <p className="text-[13.5px] text-muted-foreground">{person.location}</p>}
</div>
<a href={links?.inquiry ?? '#inquiry'} aria-label={fill(t.write, { name: person.name })} className={cn('flex size-11 items-center justify-center rounded-full border border-border text-foreground transition-[background-color,transform] duration-300 hover:-translate-y-0.5 hover:bg-accent', focusRing)}>
<Icon name="mail" size={18} />
</a>
</div>
{stats.length > 0 && (
<dl className="grid grid-cols-3 divide-x divide-border">
{stats.slice(0, 3).map((s, i) => (
<div key={s.label} className="flex flex-col-reverse justify-end px-4 py-4">
<dt className="mt-1 text-[12px] leading-snug text-muted-foreground">{s.label}</dt>
<dd className="text-[22px] font-semibold leading-none tracking-[-0.02em] text-foreground">
<NumberTicker value={s.value} delay={i * 120} />
{s.suffix}
</dd>
</div>
))}
</dl>
)}
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* availability */
/* ------------------------------------------------------------------ */
function Availability({ weeks = [], status, links, t }: FreelancerProps & { t: Texts }) {
const locale = useLocale();
const [ref, inView] = useInView<HTMLDivElement>();
const [hover, setHover] = useState<number | null>(null);
const firstFree = weeks.findIndex((w) => w.load === 0);
const months: { key: string; label: string; items: { w: FreelancerWeek; i: number }[] }[] = [];
weeks.forEach((w, i) => {
const key = w.start.slice(0, 7);
let m = months.find((x) => x.key === key);
if (!m) months.push((m = { key, label: dfmt(w.start, locale, months.length === 0 || key.endsWith('-01') ? { month: 'long', year: 'numeric' } : { month: 'long' }), items: [] }));
m.items.push({ w, i });
});
const tone = (load: number) => (load >= 1 ? 'bg-[color-mix(in_oklab,var(--foreground)_14%,transparent)]' : load > 0 ? 'bg-[color-mix(in_oklab,var(--primary)_20%,var(--card))] ring-1 ring-inset ring-[color-mix(in_oklab,var(--primary)_50%,transparent)]' : 'bg-primary');
const shown = hover ?? (firstFree >= 0 ? firstFree : null);
const w = shown !== null ? weeks[shown] : undefined;
const stateText = (l: number) => (l >= 1 ? t.booked : l > 0 ? t.partly : t.free);
return (
<div ref={ref} id="availability">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{status && <Live text={status} />}
<ul aria-label={t.legend} className="flex flex-wrap gap-x-5 gap-y-2 text-[13px] text-muted-foreground">
{[1, 0.5, 0].map((l) => (
<li key={l} className="inline-flex items-center gap-2">
<span className={cn('size-3.5 rounded-[4px]', tone(l))} />
{stateText(l)}
</li>
))}
</ul>
</div>
<div className="mt-8 grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 lg:grid-cols-6">
{months.map((m, mi) => (
<div key={m.key}>
<p className="text-[14px] font-semibold capitalize text-foreground">{m.label}</p>
<ul className="mt-3 grid gap-1.5">
{m.items.map(({ w, i }) => {
const first = i === firstFree;
return (
<li key={w.start}>
<button type="button" onPointerEnter={() => setHover(i)} onPointerLeave={() => setHover(null)} onFocus={() => setHover(i)} onBlur={() => setHover(null)} aria-label={`${fill(t.week, { d: dfmt(w.start, locale, { day: 'numeric', month: 'long' }) })}, ${stateText(w.load)}`} className={cn('relative flex h-11 w-full items-center justify-between rounded-lg px-2.5 sm:h-10 text-[12.5px] font-medium tabular-nums transition-[transform,opacity] duration-500 ease-[var(--ease-kit)] hover:-translate-y-0.5 motion-reduce:transition-none', focusRing, tone(w.load), w.load === 0 ? 'text-primary-foreground' : 'text-foreground', first && 'ring-2 ring-primary ring-offset-2 ring-offset-background')} style={{ opacity: inView ? 1 : 0, transform: inView ? undefined : 'scale(0.85)', transitionDelay: inView ? `${mi * 80 + i * 18}ms` : '0ms' }}>
<span>{dfmt(w.start, locale, { day: 'numeric', month: locale === 'de' ? '2-digit' : 'numeric' })}</span>
{first && <Icon name="sparkle" size={13} />}
</button>
</li>
);
})}
</ul>
</div>
))}
</div>
<div className="mt-8 flex min-h-12 flex-wrap items-center justify-between gap-4 rounded-xl border border-border bg-card px-5 py-3">
{w && (
<p key={w.start} className="text-[14.5px] text-foreground [animation:kitfree-swap_.3s_var(--ease-kit)]" aria-live="polite">
<span className="text-muted-foreground">{hover === null ? t.firstFree : fill(t.week, { d: '' }).trim()} </span>
<span className="font-semibold">{dfmt(w.start, locale, { day: 'numeric', month: 'long', year: 'numeric' })}</span>
<span className="text-muted-foreground"> · {stateText(w.load)}</span>
</p>
)}
<Button href={links?.inquiry ?? '#inquiry'} icon="arrow-right" className="max-sm:w-full">
{t.askWeek}
</Button>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* rates */
/* ------------------------------------------------------------------ */
function Rates({ rates = [], dayRate, currency = 'EUR', links, t }: FreelancerProps & { t: Texts }) {
const locale = useLocale();
const [sel, setSel] = useState(rates[1]?.id ?? rates[0]?.id ?? '');
const r = rates.find((x) => x.id === sel) ?? rates[0];
const price = useTween(r?.price ?? 0);
const rate = dayRate ?? rates.find((x) => x.id === 'day')?.price ?? 0;
const [days, setDays] = useState(5);
const pct = days >= 10 ? 10 : 0;
const total = Math.round(days * rate * (1 - pct / 100));
const tw = useTween(total);
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)] lg:gap-8">
<div className="kit-card p-6 sm:p-8">
<Segmented label={t.pricing} value={sel} onChange={setSel} options={rates.map((x) => ({ id: x.id, label: x.label }))} className="flex w-full sm:inline-flex sm:w-auto" />
{r && (
<div className="mt-8">
<p className="flex items-baseline gap-2">
<span className="text-[52px] font-semibold leading-none tracking-[-0.035em] tabular-nums text-foreground">{money(Math.round(price), currency, locale)}</span>
<span key={r.id} className="text-[15px] text-muted-foreground [animation:kitfree-in_.35s_var(--ease-kit)]">
{r.unit}
</span>
</p>
{r.note && (
<p key={`n${r.id}`} className="mt-3 text-pretty text-[15px] text-muted-foreground [animation:kitfree-swap_.4s_var(--ease-kit)]">
{r.note}
</p>
)}
{r.includes?.length ? (
<ul key={`i${r.id}`} className="mt-6 grid gap-2.5 border-t border-border pt-6 sm:grid-cols-2">
{r.includes.map((x, i) => (
<li key={x} className="flex gap-2.5 text-[14.5px] text-foreground [animation:kitfree-in_.4s_var(--ease-kit)_both]" style={{ animationDelay: `${i * 50}ms` }}>
<Icon name="check" size={16} className="mt-[3px] text-primary" />
{x}
</li>
))}
</ul>
) : null}
</div>
)}
</div>
{rate > 0 && (
<div className="rounded-2xl border border-border bg-surface p-6 sm:p-8">
<p className="text-[15px] font-semibold text-foreground">{t.days}</p>
<div className="mt-4 flex items-center gap-3">
<button type="button" aria-label={t.fewer} disabled={days <= 1} onClick={() => setDays((d) => Math.max(1, d - 1))} className={cn('flex size-11 items-center justify-center rounded-lg border border-border bg-card text-foreground transition-colors hover:bg-accent disabled:opacity-40', focusRing)}>
<Icon name="minus" size={18} />
</button>
<output aria-live="polite" className="min-w-16 text-center text-[32px] font-semibold tabular-nums text-foreground">
<span key={days} className="inline-block [animation:kitfree-pop_.3s_var(--ease-kit)]">
{days}
</span>
</output>
<button type="button" aria-label={t.more} disabled={days >= 40} onClick={() => setDays((d) => Math.min(40, d + 1))} className={cn('flex size-11 items-center justify-center rounded-lg border border-border bg-card text-foreground transition-colors hover:bg-accent disabled:opacity-40', focusRing)}>
<Icon name="plus" size={18} />
</button>
</div>
<div className="mt-5 flex flex-wrap gap-1" aria-hidden>
{Array.from({ length: Math.min(days, 40) }, (_, i) => (
<span key={i} className="size-3 rounded-[3px] bg-primary [animation:kitfree-cell_.25s_var(--ease-kit)_both]" />
))}
</div>
<div className="mt-6 border-t border-border pt-5">
<p className="text-[13px] text-muted-foreground">{fill(t.total, { n: days })}</p>
<p className="mt-1 text-[30px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground">{money(Math.round(tw), currency, locale)}</p>
<p className={cn('mt-2 text-[13px] transition-colors', pct ? 'font-medium text-success-foreground' : 'text-muted-foreground')}>{fill(t.discount, { p: 10 })}</p>
<Button href={links?.inquiry ?? '#inquiry'} icon="arrow-right" className="mt-5 w-full">
{t.book}
</Button>
</div>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* inquiry */
/* ------------------------------------------------------------------ */
function Inquiry({ types = [], budget = { min: 1000, max: 30000, step: 500, from: 4000, to: 12000 }, currency = 'EUR', onSubmit, t }: FreelancerProps & { t: Texts }) {
const locale = useLocale();
const base = useId();
const [type, setType] = useState<string[]>([]);
const [lo, setLo] = useState(budget.from);
const [hi, setHi] = useState(budget.to);
const [time, setTime] = useState(1);
const [about, setAbout] = useState('');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [sent, setSent] = useState(false);
const times = t.timelines.split('|');
const span = budget.max - budget.min;
const pl = round(((lo - budget.min) / span) * 100, 2);
const ph = round(((hi - budget.min) / span) * 100, 2);
const submit = (e: React.FormEvent) => {
e.preventDefault();
const next: Record<string, string> = {};
if (!type.length) next.type = t.pickType;
if (!name.trim()) next.name = t.required;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) next.email = t.invalid;
setErrors(next);
if (Object.keys(next).length) return document.getElementById(`${base}-${Object.keys(next)[0]}`)?.focus();
onSubmit?.({ type: type.join(', '), budget: `${lo}-${hi}`, time: times[time], about, name, email });
setSent(true);
};
const thumb = '[&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-6 [&::-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]:pointer-events-auto [&::-webkit-slider-thumb]:size-6 [&::-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';
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 id="inquiry" role="status" className="kit-card mx-auto max-w-[600px] p-8 text-center [animation:kitfree-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:kitfree-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)}>
{t.again}
</Button>
</div>
);
return (
<form id="inquiry" onSubmit={submit} noValidate className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] lg:gap-12">
<div className="grid gap-8">
<fieldset>
<legend className="mb-3 text-[15px] font-semibold text-foreground">{t.type}</legend>
<div id={`${base}-type`} tabIndex={-1} className="grid grid-cols-2 gap-2.5 sm:grid-cols-3">
{types.map((x) => {
const on = type.includes(x.id);
return (
<label key={x.id} className={cn('flex min-h-[72px] cursor-pointer flex-col justify-between gap-2 rounded-xl border p-3.5 transition-[border-color,background-color,transform] duration-300 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring', on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))]' : 'border-border bg-card hover:-translate-y-0.5 hover:border-border-strong')}>
<input type="checkbox" checked={on} onChange={() => { setType((v) => (on ? v.filter((y) => y !== x.id) : [...v, x.id])); setErrors((e) => ({ ...e, type: '' })); }} className="sr-only" />
<span className="flex items-center justify-between">
<Icon name={x.icon ?? 'sparkle'} size={19} className={on ? 'text-primary' : 'text-muted-foreground'} />
<span aria-hidden className={cn('flex size-5 items-center justify-center rounded-full border-2 transition-colors', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong')}>{on && <Icon name="check" size={11} strokeWidth={2.6} />}</span>
</span>
<span className="text-[14px] font-medium leading-snug text-foreground">{x.label}</span>
</label>
);
})}
</div>
{errors.type && <p className="mt-2 text-[13px] text-destructive">{errors.type}</p>}
</fieldset>
<fieldset>
<legend className="flex w-full items-baseline justify-between gap-3 text-[15px] font-semibold text-foreground">
{t.budget}
<span className="text-[15px] font-semibold tabular-nums text-foreground" aria-hidden>
{fill(t.range, { a: money(lo, currency, locale), b: money(hi, currency, locale) })}
</span>
</legend>
<div className="relative mt-4 h-11">
<div aria-hidden className="pointer-events-none absolute inset-x-3 top-1/2 h-2 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]">
<div className="absolute inset-y-0 rounded-full bg-primary" style={{ left: `${pl}%`, right: `${round(100 - ph, 2)}%` }} />
</div>
<input type="range" aria-label={t.budgetMin} min={budget.min} max={budget.max} step={budget.step} value={lo} onChange={(e) => setLo(Math.min(Number(e.target.value), hi - budget.step))} className={cn('pointer-events-none absolute inset-0 h-11 w-full appearance-none bg-transparent', thumb)} />
<input type="range" aria-label={t.budgetMax} min={budget.min} max={budget.max} step={budget.step} value={hi} onChange={(e) => setHi(Math.max(Number(e.target.value), lo + budget.step))} className={cn('pointer-events-none absolute inset-0 h-11 w-full appearance-none bg-transparent', thumb)} />
</div>
</fieldset>
<fieldset>
<legend className="mb-3 text-[15px] font-semibold text-foreground">{t.timeline}</legend>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{times.map((x, i) => (
<label key={x} className={cn('flex min-h-11 cursor-pointer items-center justify-center rounded-lg border px-2 text-center text-[13.5px] font-medium transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring', time === i ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] font-semibold text-foreground' : 'border-border bg-card text-muted-foreground hover:border-border-strong hover:text-foreground')}>
<input type="radio" name={`${base}-time`} checked={time === i} onChange={() => setTime(i)} className="sr-only" />
{x}
</label>
))}
</div>
</fieldset>
<div>
<label htmlFor={`${base}-about`} className="mb-2 block text-[15px] font-semibold text-foreground">
{t.about}
</label>
<textarea id={`${base}-about`} rows={4} value={about} onChange={(e) => setAbout(e.target.value)} placeholder={t.aboutPh} className={cn('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>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{(['name', '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} value={k === 'name' ? name : email} onChange={(e) => (k === 'name' ? setName : setEmail)(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>
<aside className="lg:sticky lg:top-8 lg:self-start">
<div className="kit-card overflow-hidden">
<p className="border-b border-border px-6 py-4 text-[13px] font-medium text-muted-foreground">{t.summary}</p>
<dl className="divide-y divide-border px-6">
{[
[t.type, type.length ? types.filter((x) => type.includes(x.id)).map((x) => x.label).join(', ') : t.open],
[t.budget, fill(t.range, { a: money(lo, currency, locale), b: money(hi, currency, locale) })],
[t.timeline, times[time]],
].map(([k, v]) => (
<div key={k} className="grid gap-1 py-3.5">
<dt className="text-[13px] text-muted-foreground">{k}</dt>
<dd key={v} className="text-[14.5px] font-medium text-foreground [animation:kitfree-swap_.35s_var(--ease-kit)]">
{v}
</dd>
</div>
))}
</dl>
<div className="border-t border-border p-6">
<Button type="submit" icon="send" className="w-full">
{t.send}
</Button>
</div>
</div>
</aside>
</form>
);
}
/* ------------------------------------------------------------------ */
/* week */
/* ------------------------------------------------------------------ */
function Week({ blocks = [], channels = [], t }: FreelancerProps & { t: Texts }) {
const [ref, inView] = useInView<HTMLDivElement>();
const [now, setNow] = useState<{ day: number; hour: number } | null>(null);
useEffect(() => {
const d = new Date();
const day = (d.getDay() + 6) % 7;
if (day < 5) setNow({ day, hour: d.getHours() + d.getMinutes() / 60 });
}, []);
const startH = 8;
const endH = 18;
const hours = endH - startH;
const days = [t.mon, t.tue, t.wed, t.thu, t.fri];
const kinds: Record<FreelancerBlock['kind'], { label: string; cls: string }> = {
focus: { label: t.focus, cls: 'bg-primary text-primary-foreground' },
call: { label: t.call, cls: 'bg-[color-mix(in_oklab,var(--primary)_18%,var(--card))] text-foreground ring-1 ring-inset ring-[color-mix(in_oklab,var(--primary)_40%,transparent)]' },
admin: { label: t.admin, cls: 'bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)] text-foreground' },
off: { label: t.off, cls: 'bg-[repeating-linear-gradient(135deg,color-mix(in_oklab,var(--foreground)_8%,transparent)_0_5px,transparent_5px_10px)] text-muted-foreground' },
};
const top = (h: number) => round(((h - startH) / hours) * 100, 3);
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1.7fr)_minmax(0,1fr)] lg:gap-10">
<div ref={ref} className="kit-card overflow-hidden p-4 sm:p-6">
<ul aria-label={t.legend} className="mb-5 flex flex-wrap gap-x-5 gap-y-2 text-[13px] text-muted-foreground">
{(Object.keys(kinds) as FreelancerBlock['kind'][]).map((k) => (
<li key={k} className="inline-flex items-center gap-2">
<span className={cn('size-3.5 rounded-[4px]', kinds[k].cls)} />
{kinds[k].label}
</li>
))}
</ul>
<div className="grid grid-cols-[2.25rem_repeat(5,minmax(0,1fr))] gap-x-1.5 sm:grid-cols-[2.75rem_repeat(5,minmax(0,1fr))] sm:gap-x-2">
<span />
{days.map((d, i) => (
<span key={d} className={cn('pb-2 text-center text-[13px] font-medium', now?.day === i ? 'text-primary' : 'text-muted-foreground')}>
{d}
</span>
))}
<div className="relative h-[440px] sm:h-[480px]">
{Array.from({ length: hours + 1 }, (_, i) => (
<span key={i} className="absolute right-1.5 -translate-y-1/2 text-[11.5px] tabular-nums text-subtle-foreground" style={{ top: `${top(startH + i)}%` }}>
{startH + i}
</span>
))}
</div>
{days.map((d, di) => (
<div key={d} className="relative h-[440px] rounded-lg bg-[repeating-linear-gradient(to_bottom,var(--border)_0_1px,transparent_1px_calc(100%/10))] sm:h-[480px]">
{blocks
.filter((b) => b.day === di)
.map((b, bi) => (
<div key={bi} className={cn('absolute inset-x-0.5 overflow-hidden rounded-md px-1.5 py-1 text-[11px] font-medium leading-tight transition-[transform,opacity] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none sm:px-2 sm:text-[12px]', kinds[b.kind].cls)} style={{ top: `${top(b.from)}%`, height: `${round(top(b.to) - top(b.from), 3)}%`, transformOrigin: 'top', transform: inView ? 'scaleY(1)' : 'scaleY(0)', opacity: inView ? 1 : 0, transitionDelay: inView ? `${di * 90 + bi * 60}ms` : '0ms' }}>
<span className="line-clamp-3 max-sm:sr-only">{b.label}</span>
</div>
))}
{now && now.day === di && now.hour >= startH && now.hour <= endH && (
<span aria-hidden className="absolute inset-x-0 z-[1] h-0.5 bg-destructive [animation:kitfree-in_.4s_var(--ease-kit)]" style={{ top: `${top(now.hour)}%` }}>
<span className="absolute -left-1 -top-[3px] size-2 rounded-full bg-destructive" />
</span>
)}
</div>
))}
</div>
</div>
{channels.length > 0 && (
<div>
<p className="text-[15px] font-semibold text-foreground">{t.reach}</p>
<Stagger as="ul" step={70} className="mt-4 grid gap-3">
{channels.map((c) => (
<li key={c.label} className="flex items-start gap-4 rounded-xl border border-border bg-card p-4 transition-[transform,border-color] duration-300 hover:-translate-y-0.5 hover:border-border-strong">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] text-primary">
<Icon name={c.icon} size={18} />
</span>
<span>
<span className="block text-[15px] font-semibold text-foreground">{c.label}</span>
<span className="mt-0.5 block text-pretty text-[14px] leading-relaxed text-muted-foreground">{c.detail}</span>
</span>
</li>
))}
</Stagger>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Freelancer */
/* ------------------------------------------------------------------ */
/**
* Sections for freelancers.
* intro role, live availability, answer time, numbers, skills
* availability the next months week by week, the first free week marked
* rates hour, day or project, a calculator for days
* inquiry type, budget range, timeline and a summary
* week a typical week as a timetable, channels beside it
*/
export function Freelancer(props: FreelancerProps) {
const { variant = 'intro', eyebrow, title, description, labels, className } = props;
const t = useTexts(TEXTS, labels);
const body = (() => {
switch (variant) {
case 'availability':
return <Availability {...props} t={t} />;
case 'rates':
return <Rates {...props} t={t} />;
case 'inquiry':
return <Inquiry {...props} t={t} />;
case 'week':
return <Week {...props} t={t} />;
default:
return <Intro {...props} t={t} />;
}
})();
return (
<Section className={className} background={variant === 'intro' ? 'glow' : variant === 'rates' ? 'default' : variant === 'inquiry' ? 'surface' : 'default'}>
<FreeMotion />
{title && variant !== 'intro' ? <SectionHeader eyebrow={eyebrow} title={title} description={description} /> : null}
{body}
</Section>
);
}Häufige Fragen
Wie installiere ich Freelancer?
Mit aiw add @ai-website/freelancer. Der Befehl legt die Datei components/ui/freelancer.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, motion, number-ticker und section. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Freelancer?
5 Varianten, Auftakt mit Verfügbarkeit, Verfügbarkeit, Sätze, Projektanfrage und Eine typische Woche. Du wählst sie mit der Prop variant, zum Beispiel variant="availability".
Kann ich die Texte von Freelancer übersetzen?
Ja. Freelancer bringt 52 eigene Texte auf Deutsch und Englisch mit, etwa „Verfügbarkeit ansehen“, „Diese Woche anfragen“ und „Arbeiten ansehen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Freelancer einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Freelancer mit aiw_components und installieren sie mit aiw_add @ai-website/freelancer. Nenn im Prompt die Variante, etwa variant="intro", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Freelancer ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Freelancer-Code zum Kopieren.