Zum Inhalt springen
UI Kit
Accent

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

5 Variants

Variants

Auftakt mit Verfügbarkeit

Die Rolle als Überschrift, Verfügbarkeit mit Live-Punkt, Antwortzeit, Fähigkeiten, die nacheinander aufsteigen. Eine Karte mit Porträt und drei Zahlen, die hochzählen.

Im Studio öffnen

Verfügbarkeit

Die nächsten sechs Monate Woche für Woche als ausgebucht, teilweise frei und frei. Die erste freie Woche ist markiert, Zeigen auf eine Woche nennt ihren Stand, die Zellen erscheinen Monat für Monat.

Im Studio öffnen

Sätze

Stunde, Tag oder Projekt hinter einem gleitenden Schalter, der Preis gleitet und der Inhalt wechselt. Ein Rechner daneben zählt Tage als Blöcke und zeigt die Summe.

Im Studio öffnen

Projektanfrage

Was gebraucht wird als Karten, eine Budgetspanne mit zwei Griffen, der Zeitraum und ein paar Worte. Die Zusammenfassung daneben folgt jeder Wahl.

Im Studio öffnen

Eine typische Woche

Eine Woche als Stundenplan, Fokusblöcke, Gespräche, Verwaltung und frei. Die Blöcke wachsen Tag für Tag herein, eine rote Linie zeigt jetzt. Daneben, wie man mich erreicht.

Im Studio öffnen

Details

Was Freelancer mitbringt, aus Registry und Quellcode gelesen.

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

Props

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

  • variantoptional
    FreelancerVariant

    introavailabilityratesinquiryweek

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • personoptional
    { name: string; role: string; photo?: React.ReactNode; location?: string; bio?: string }
  • statusoptional
    string

    e.g. "Available from 3 March"

  • responseoptional
    string

    e.g. "Answers within a day"

  • statsoptional
    { value: number; suffix?: string; label: string }[]
  • skillsoptional
    string[]
  • weeksoptional
    FreelancerWeek[]
  • ratesoptional
    FreelancerRate[]
  • dayRateoptional
    number

    rates: 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 }
  • blocksoptional
    FreelancerBlock[]
  • channelsoptional
    { label: string; detail: string; icon: IconName }[]
  • currencyoptional
    string
  • onSubmitoptional
    (data: Record<string, string>) => void
  • linksoptional
    { 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
checkEnglisch Check availabilityDeutsch Verfügbarkeit ansehen
askWeekEnglisch Ask for this weekDeutsch Diese Woche anfragen
rangeEnglisch {a} to {b}Deutsch {a} bis {b}
writeEnglisch Write to {name}Deutsch {name} schreiben
workEnglisch See my workDeutsch Arbeiten ansehen
bookedEnglisch BookedDeutsch Ausgebucht
partlyEnglisch Partly freeDeutsch Teilweise frei
freeEnglisch FreeDeutsch Frei
firstFreeEnglisch First free weekDeutsch Erste freie Woche
weekEnglisch Week of {d}Deutsch Woche ab {d}
legendEnglisch LegendDeutsch Legende
hourEnglisch HourDeutsch Stunde
dayEnglisch DayDeutsch Tag
projectEnglisch ProjectDeutsch Projekt
pricingEnglisch PricingDeutsch Abrechnung
daysEnglisch DaysDeutsch Tage
fewerEnglisch One day lessDeutsch Einen Tag weniger
moreEnglisch One day moreDeutsch Einen Tag mehr
totalEnglisch Total for {n} daysDeutsch Gesamt für {n} Tage
discountEnglisch {p}% off from 10 daysDeutsch {p} % Rabatt ab 10 Tagen
bookEnglisch Book these daysDeutsch Diese Tage buchen
typeEnglisch What do you need?Deutsch Was brauchst du?
budgetEnglisch BudgetDeutsch Budget
budgetMinEnglisch Lowest budgetDeutsch Niedrigstes Budget
budgetMaxEnglisch Highest budgetDeutsch Höchstes Budget
timelineEnglisch When do you need it?Deutsch Wann brauchst du es?
timelinesEnglisch Within a month|In 1 to 3 months|Later this year|Not sure yetDeutsch Innerhalb eines Monats|In 1 bis 3 Monaten|Später im Jahr|Noch offen
aboutEnglisch Tell me about the projectDeutsch Erzähl mir vom Projekt
aboutPhEnglisch 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.
nameEnglisch NameDeutsch Name
emailEnglisch EmailDeutsch E-Mail
emailPhEnglisch you@example.comDeutsch du@beispiel.de
sendEnglisch Send enquiryDeutsch Anfrage 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.
pickTypeEnglisch Pick what you need.Deutsch Wähle, was du brauchst.
sentTitleEnglisch Thanks, I will get back to youDeutsch Danke, ich melde mich
sentTextEnglisch 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.
againEnglisch Send another enquiryDeutsch Weitere Anfrage senden
summaryEnglisch Your enquiryDeutsch Deine Anfrage
openEnglisch openDeutsch offen
focusEnglisch Focus workDeutsch Fokusarbeit
callEnglisch CallsDeutsch Gespräche
adminEnglisch AdminDeutsch Verwaltung
offEnglisch OffDeutsch Frei
reachEnglisch How to reach meDeutsch So erreichst du mich
monEnglisch MonDeutsch Mo
tueEnglisch TueDeutsch Di
wedEnglisch WedDeutsch Mi
thuEnglisch ThuDeutsch Do
friEnglisch FriDeutsch Fr
nowEnglisch NowDeutsch 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
components/ui/freelancer.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 { 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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert