Zum Inhalt springen
UI Kit
Accent

Fotografen-Buchung

Für Fotografinnen und Fotografen mit Buchungen. Pakete nach Art des Shootings, ein Kalender zum Prüfen und Reservieren, Galerien nach Thema, ein Preisrechner und eine Auswahlseite, auf der Kunden ihre Favoriten wählen.

Eine von 3 Components in Kunst und Musik

5 Variants

Variants

Pakete

Arten von Shootings hinter einem gleitenden Schalter, Pakete mit Stunden, Fotos und Lieferzeit. Karten kommen nacheinander, Licht folgt dem Zeiger, das empfohlene ist markiert.

Im Studio öffnen

Datum prüfen

Ein Monat zum Blättern, die Monate gleiten von ihrer Seite herein. Ausgebuchte Tage sind durchgestrichen, Tage mit einem freien Termin markiert. Ein freier Tag lässt sich 48 Stunden reservieren.

Im Studio öffnen

Galerien

Themen als große Bildkarten, die erste breit. Beim Zeigen oder Fokus rückt das Bild näher und ein Streifen mit vier Vorschauen steigt auf, auf dem Telefon ist er immer da.

Im Studio öffnen

Preisrechner

Stunden als Blöcke, Extras als Karten und die Entfernung auf einem Regler mit markierten Freikilometern. Das Angebot baut sich Zeile für Zeile, die Summe gleitet.

Im Studio öffnen

Auswahlgalerie

Die Kundengalerie nach dem Shooting. Herzen springen auf, ein Balken zählt gegen die enthaltenen Fotos und warnt, bevor Extras kosten. Nur Favoriten zeigen, dann die Auswahl senden.

Im Studio öffnen

Details

Was Fotografen-Buchung mitbringt, aus Registry und Quellcode gelesen.

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

Props

Die Schnittstelle PhotographerBookingProps aus components/ui/photographer-booking.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    PhotographerBookingVariant

    packagesdate-checkgalleriesquoteproofing

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • packagesoptional
    PhotoPackage[]
  • monthoptional
    string

    date-check: first month shown, YYYY-MM

  • bookedoptional
    string[]

    date-check: booked days and days with one slot left, YYYY-MM-DD

  • partlyoptional
    string[]
  • galleriesoptional
    PhotoGallery[]
  • hourlyoptional
    number

    quote

  • extrasoptional
    PhotoExtra[]
  • perKmoptional
    number
  • freeKmoptional
    number
  • baseoptional
    string

    quote: where the travel is counted from, e.g. "Hamburg"

  • photosoptional
    React.ReactNode[]

    proofing

  • includedoptional
    number
  • extraPhotooptional
    number
  • clientoptional
    string
  • currencyoptional
    string
  • onSubmitoptional
    (data: Record<string, unknown>) => void

Eingebaute Texte

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

Alle 41 Texte anzeigen
kindEnglisch Kind of shootDeutsch Art des Shootings
hoursEnglisch TimeDeutsch Zeit
photosEnglisch PhotosDeutsch Fotos
deliveryEnglisch DeliveryDeutsch Lieferung
bookEnglisch Check the dateDeutsch Datum prüfen
popularEnglisch Most bookedDeutsch Am häufigsten gebucht
prevEnglisch Previous monthDeutsch Vorheriger Monat
nextEnglisch Next monthDeutsch Nächster Monat
freeEnglisch FreeDeutsch Frei
partlyEnglisch One slot leftDeutsch Noch ein Termin
bookedEnglisch BookedDeutsch Ausgebucht
pickDayEnglisch Pick a day to see if it is freeDeutsch Wähle einen Tag, um zu sehen, ob er frei ist
dayFreeEnglisch {d} is freeDeutsch {d} ist frei
dayPartlyEnglisch {d} has one slot leftDeutsch Am {d} ist noch ein Termin frei
holdEnglisch Hold this date for 48 hoursDeutsch Datum 48 Stunden reservieren
heldEnglisch Held until {d}, the confirmation is on its way.Deutsch Reserviert bis {d}, die Bestätigung ist unterwegs.
otherEnglisch Pick another dayDeutsch Anderen Tag wählen
imagesEnglisch {n} photosDeutsch {n} Fotos
openEnglisch Open galleryDeutsch Galerie öffnen
hoursLabelEnglisch HoursDeutsch Stunden
fewerEnglisch One hour lessDeutsch Eine Stunde weniger
moreEnglisch One hour moreDeutsch Eine Stunde mehr
extrasEnglisch ExtrasDeutsch Extras
distanceEnglisch DistanceDeutsch Entfernung
distanceFromEnglisch Distance from {base}Deutsch Entfernung ab {base}
kmEnglisch {n} kmDeutsch {n} km
shootingEnglisch Shooting, {n} hoursDeutsch Shooting, {n} Stunden
travelEnglisch Travel, {n} kmDeutsch Anfahrt, {n} km
travelFreeEnglisch Travel, first {n} km includedDeutsch Anfahrt, erste {n} km inklusive
totalEnglisch TotalDeutsch Gesamt
requestEnglisch Request this offerDeutsch Angebot anfragen
vatEnglisch All prices include VAT.Deutsch Alle Preise inklusive Mehrwertsteuer.
chosenEnglisch {n} of {total} chosenDeutsch {n} von {total} gewählt
overEnglisch {n} more at {price} eachDeutsch {n} weitere je {price}
favouritesEnglisch Favourites onlyDeutsch Nur Favoriten
allEnglisch AllDeutsch Alle
likeEnglisch Choose photo {n}Deutsch Foto {n} wählen
unlikeEnglisch Remove photo {n}Deutsch Foto {n} abwählen
sendEnglisch Send my choiceDeutsch Auswahl senden
sentEnglisch Thanks, {n} photos are on their way to editing.Deutsch Danke, {n} Fotos gehen in die Bearbeitung.
galleryEnglisch Gallery for {name}Deutsch Galerie für {name}

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Fotografen-Buchung reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-checked, aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen grid, 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

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

Quellcode

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

Datei anzeigen, 691 Zeilen
components/ui/photographer-booking.tsx
'use client';

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

/*
 * Sections for photographers who take bookings: packages per kind of
 * shoot, a calendar to check a date, galleries by theme, a price builder and
 * the proofing page where clients pick their favourites.
 *
 *   packages     kinds of shoot behind a gliding switch, packages with hours, photos and delivery
 *   date-check   a month to browse, booked and free days, a chosen day can be held for 48 hours
 *   galleries    themes as large cards, the preview strip slides on hover or focus
 *   quote        hours, extras and distance, the price builds itself line by line
 *   proofing     a client gallery with hearts, a counter against the included number and sending the choice
 */

export type PhotographerBookingVariant = 'packages' | 'date-check' | 'galleries' | 'quote' | 'proofing';

export interface PhotoPackage {
  id: string;
  kind: string;
  title: string;
  hours: string;
  photos: string;
  delivery: string;
  price: number;
  includes?: string[];
  featured?: boolean;
  href?: string;
}

export interface PhotoGallery {
  id: string;
  title: string;
  count: number;
  cover?: React.ReactNode;
  previews?: React.ReactNode[];
  href?: string;
}

export interface PhotoExtra {
  id: string;
  label: string;
  price: number;
  icon?: IconName;
}

export interface PhotographerBookingProps {
  variant?: PhotographerBookingVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  packages?: PhotoPackage[];
  /** date-check: first month shown, YYYY-MM */
  month?: string;
  /** date-check: booked days and days with one slot left, YYYY-MM-DD */
  booked?: string[];
  partly?: string[];
  galleries?: PhotoGallery[];
  /** quote */
  hourly?: number;
  extras?: PhotoExtra[];
  perKm?: number;
  freeKm?: number;
  /** quote: where the travel is counted from, e.g. "Hamburg" */
  base?: string;
  /** proofing */
  photos?: React.ReactNode[];
  included?: number;
  extraPhoto?: number;
  client?: string;
  currency?: string;
  onSubmit?: (data: Record<string, unknown>) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    kind: 'Kind of shoot',
    hours: 'Time',
    photos: 'Photos',
    delivery: 'Delivery',
    book: 'Check the date',
    popular: 'Most booked',
    prev: 'Previous month',
    next: 'Next month',
    free: 'Free',
    partly: 'One slot left',
    booked: 'Booked',
    pickDay: 'Pick a day to see if it is free',
    dayFree: '{d} is free',
    dayPartly: '{d} has one slot left',
    hold: 'Hold this date for 48 hours',
    held: 'Held until {d}, the confirmation is on its way.',
    other: 'Pick another day',
    images: '{n} photos',
    open: 'Open gallery',
    hoursLabel: 'Hours',
    fewer: 'One hour less',
    more: 'One hour more',
    extras: 'Extras',
    distance: 'Distance',
    distanceFrom: 'Distance from {base}',
    km: '{n} km',
    shooting: 'Shooting, {n} hours',
    travel: 'Travel, {n} km',
    travelFree: 'Travel, first {n} km included',
    total: 'Total',
    request: 'Request this offer',
    vat: 'All prices include VAT.',
    chosen: '{n} of {total} chosen',
    over: '{n} more at {price} each',
    favourites: 'Favourites only',
    all: 'All',
    like: 'Choose photo {n}',
    unlike: 'Remove photo {n}',
    send: 'Send my choice',
    sent: 'Thanks, {n} photos are on their way to editing.',
    gallery: 'Gallery for {name}',
  },
  de: {
    kind: 'Art des Shootings',
    hours: 'Zeit',
    photos: 'Fotos',
    delivery: 'Lieferung',
    book: 'Datum prüfen',
    popular: 'Am häufigsten gebucht',
    prev: 'Vorheriger Monat',
    next: 'Nächster Monat',
    free: 'Frei',
    partly: 'Noch ein Termin',
    booked: 'Ausgebucht',
    pickDay: 'Wähle einen Tag, um zu sehen, ob er frei ist',
    dayFree: '{d} ist frei',
    dayPartly: 'Am {d} ist noch ein Termin frei',
    hold: 'Datum 48 Stunden reservieren',
    held: 'Reserviert bis {d}, die Bestätigung ist unterwegs.',
    other: 'Anderen Tag wählen',
    images: '{n} Fotos',
    open: 'Galerie öffnen',
    hoursLabel: 'Stunden',
    fewer: 'Eine Stunde weniger',
    more: 'Eine Stunde mehr',
    extras: 'Extras',
    distance: 'Entfernung',
    distanceFrom: 'Entfernung ab {base}',
    km: '{n} km',
    shooting: 'Shooting, {n} Stunden',
    travel: 'Anfahrt, {n} km',
    travelFree: 'Anfahrt, erste {n} km inklusive',
    total: 'Gesamt',
    request: 'Angebot anfragen',
    vat: 'Alle Preise inklusive Mehrwertsteuer.',
    chosen: '{n} von {total} gewählt',
    over: '{n} weitere je {price}',
    favourites: 'Nur Favoriten',
    all: 'Alle',
    like: 'Foto {n} wählen',
    unlike: 'Foto {n} abwählen',
    send: 'Auswahl senden',
    sent: 'Danke, {n} Fotos gehen in die Bearbeitung.',
    gallery: 'Galerie für {name}',
  },
};
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 iso = (y: number, m: number, d: number) => `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const utc = (s: string) => {
  const [y, m, d] = s.split('-').map(Number);
  return new Date(Date.UTC(y, m - 1, d || 1));
};
const dfmt = (s: string, locale: Locale, o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(TAG[locale], { timeZone: 'UTC', ...o }).format(utc(s));

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

function PhoMotion() {
  return (
    <style href="kitpho-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 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 shrink-0 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>
  );
}

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

function Packages({ packages = [], currency = 'EUR', t }: PhotographerBookingProps & { t: Texts }) {
  const locale = useLocale();
  const kinds = [...new Set(packages.map((p) => p.kind))];
  const [kind, setKind] = useState(kinds[0] ?? '');
  const shown = packages.filter((p) => p.kind === kind);
  const spot = useSpotlight<HTMLElement>();
  return (
    <div>
      <div className="mb-10 flex justify-center">
        <Segmented label={t.kind} value={kind} onChange={setKind} options={kinds.map((k) => ({ id: k, label: k }))} className="flex w-full sm:inline-flex sm:w-auto" />
      </div>
      <ul key={kind} className={cn('grid grid-cols-1 gap-5 md:grid-cols-2', shown.length >= 3 && 'lg:grid-cols-3')}>
        {shown.map((p, i) => (
          <li key={p.id} className={cn('flex [animation:kitpho-in_.5s_var(--ease-kit)_both]', shown.length === 3 && i === 2 && 'md:col-span-2 lg:col-span-1')} style={{ animationDelay: `${i * 80}ms` }}>
            <article onPointerMove={spot} className={cn('kit-spotlight flex w-full flex-col rounded-2xl border bg-card p-6 transition-[transform,box-shadow] duration-300 ease-[var(--ease-kit)] hover:-translate-y-1 sm:p-8', p.featured ? 'border-primary shadow-[var(--highlight),var(--shadow-lg)]' : 'border-border shadow-[var(--highlight),var(--shadow-sm)]')}>
              <div className="flex items-start justify-between gap-3">
                <h3 className="text-[21px] font-semibold tracking-[-0.02em] text-foreground">{p.title}</h3>
                {p.featured && <span className="shrink-0 rounded-full bg-primary px-2.5 py-1 text-[12px] font-semibold text-primary-foreground">{t.popular}</span>}
              </div>
              <p className="mt-4 flex items-baseline gap-1.5">
                <span className="text-[36px] font-semibold leading-none tracking-[-0.03em] tabular-nums text-foreground">{money(p.price, currency, locale)}</span>
              </p>
              <dl className="mt-6 grid grid-cols-3 gap-px overflow-hidden rounded-xl border border-border bg-border text-center">
                {[
                  [t.hours, p.hours, 'clock'],
                  [t.photos, p.photos, 'image'],
                  [t.delivery, p.delivery, 'send'],
                ].map(([k, v, icon]) => (
                  <div key={k} className="flex flex-col-reverse items-center justify-end bg-card px-2 py-3">
                    <dt className="mt-1 text-[11.5px] text-muted-foreground">{k}</dt>
                    <dd className="flex flex-col items-center gap-1.5 text-[14px] font-semibold text-foreground">
                      <Icon name={icon as IconName} size={16} className="text-muted-foreground" />
                      {v}
                    </dd>
                  </div>
                ))}
              </dl>
              {p.includes?.length ? (
                <ul className="mt-6 grid gap-2.5">
                  {p.includes.map((x) => (
                    <li key={x} className="flex gap-2.5 text-[14.5px] text-foreground">
                      <Icon name="check" size={16} className="mt-[3px] text-primary" />
                      {x}
                    </li>
                  ))}
                </ul>
              ) : null}
              <div className="mt-auto pt-8">
                <Button href={p.href ?? '#'} variant={p.featured ? 'primary' : 'outline'} icon="arrow-right" className="w-full">
                  {t.book}
                </Button>
              </div>
            </article>
          </li>
        ))}
      </ul>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* date-check                                                          */
/* ------------------------------------------------------------------ */

function DateCheck({ month = '2027-05', booked = [], partly = [], t }: PhotographerBookingProps & { t: Texts }) {
  const locale = useLocale();
  const [y0, m0] = month.split('-').map(Number);
  const [offset, setOffset] = useState(0);
  const [dir, setDir] = useState<'next' | 'prev'>('next');
  const [sel, setSel] = useState<string | null>(null);
  const [held, setHeld] = useState(false);
  const [heldUntil, setHeldUntil] = useState('');
  const d = new Date(Date.UTC(y0, m0 - 1 + offset, 1));
  const y = d.getUTCFullYear();
  const m = d.getUTCMonth();
  const days = new Date(Date.UTC(y, m + 1, 0)).getUTCDate();
  const lead = (new Date(Date.UTC(y, m, 1)).getUTCDay() + 6) % 7;
  const weekdays = Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(TAG[locale], { weekday: 'short', timeZone: 'UTC' }).format(new Date(Date.UTC(2024, 0, 1 + i))).replace('.', ''));
  const state = (s: string) => (booked.includes(s) ? 'booked' : partly.includes(s) ? 'partly' : 'free');
  const go = (n: number) => {
    setDir(n > 0 ? 'next' : 'prev');
    setOffset((o) => o + n);
  };
  const st = sel ? state(sel) : null;
  const long = sel ? dfmt(sel, locale, { weekday: 'long', day: 'numeric', month: 'long' }) : '';
  const tone = { free: 'bg-card text-foreground !border-border hover:!border-primary', partly: 'bg-[color-mix(in_oklab,var(--warning)_22%,var(--card))] text-foreground hover:border-primary', booked: 'bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] text-subtle-foreground line-through' };
  return (
    <div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)] lg:gap-10">
      <div className="kit-card p-3.5 sm:p-7">
        <div className="flex items-center justify-between gap-3">
          <button type="button" aria-label={t.prev} disabled={offset <= 0} onClick={() => go(-1)} className={cn('flex size-11 items-center justify-center rounded-full border border-border text-foreground transition-colors hover:bg-accent disabled:opacity-35', focusRing)}>
            <Icon name="chevron-left" size={18} />
          </button>
          <p key={`${y}-${m}`} aria-live="polite" className="text-[17px] font-semibold capitalize text-foreground [animation:kitpho-swap_.35s_var(--ease-kit)]">
            {new Intl.DateTimeFormat(TAG[locale], { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(d)}
          </p>
          <button type="button" aria-label={t.next} disabled={offset >= 11} onClick={() => go(1)} className={cn('flex size-11 items-center justify-center rounded-full border border-border text-foreground transition-colors hover:bg-accent disabled:opacity-35', focusRing)}>
            <Icon name="chevron-right" size={18} />
          </button>
        </div>
        <div className="mt-5 grid grid-cols-7 gap-1 text-center sm:gap-1.5 text-[12px] font-medium text-muted-foreground" aria-hidden>
          {weekdays.map((w) => (
            <span key={w}>{w}</span>
          ))}
        </div>
        <div key={`${y}-${m}`} role="grid" aria-label={new Intl.DateTimeFormat(TAG[locale], { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(d)} className={cn('mt-2 grid grid-cols-7 gap-1 sm:gap-1.5', dir === 'next' ? '[animation:kitpho-next_.4s_var(--ease-kit)]' : '[animation:kitpho-prev_.4s_var(--ease-kit)]')}>
          {Array.from({ length: lead }, (_, i) => (
            <span key={`e${i}`} />
          ))}
          {Array.from({ length: days }, (_, i) => {
            const s = iso(y, m, i + 1);
            const stt = state(s);
            const on = s === sel;
            return (
              <button key={s} type="button" aria-pressed={on} aria-label={`${dfmt(s, locale, { day: 'numeric', month: 'long' })}, ${stt === 'free' ? t.free : stt === 'partly' ? t.partly : t.booked}`} disabled={stt === 'booked'} onClick={() => { setSel(s); setHeld(false); }} className={cn('relative flex h-11 items-center justify-center rounded-lg border border-transparent text-[14px] sm:h-12 font-medium tabular-nums transition-[background-color,border-color,color,transform] duration-200', focusRing, tone[stt], on && 'scale-105 border-primary bg-primary text-primary-foreground hover:border-primary')}>
                {i + 1}
                {stt === 'partly' && !on && <span aria-hidden className="absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full bg-warning-foreground" />}
              </button>
            );
          })}
        </div>
        <ul className="mt-5 flex flex-wrap gap-x-5 gap-y-2 text-[12.5px] text-muted-foreground">
          {(['free', 'partly', 'booked'] as const).map((k) => (
            <li key={k} className="inline-flex items-center gap-2">
              <span className={cn('size-3.5 rounded-[4px] border border-border', tone[k].split(' ')[0])} />
              {k === 'free' ? t.free : k === 'partly' ? t.partly : t.booked}
            </li>
          ))}
        </ul>
      </div>
      <div className="flex flex-col justify-center rounded-2xl border border-dashed border-border-strong p-6 sm:p-8" aria-live="polite">
        {!sel ? (
          <div className="flex flex-col items-center text-center">
            <span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
              <Icon name="calendar" size={22} />
            </span>
            <p className="mt-4 max-w-[16rem] text-[15px] text-muted-foreground">{t.pickDay}</p>
          </div>
        ) : (
          <div key={`${sel}-${held}`} className="[animation:kitpho-swap_.4s_var(--ease-kit)]">
            <span className={cn('inline-flex size-12 items-center justify-center rounded-full', st === 'free' ? 'bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-success' : 'bg-[color-mix(in_oklab,var(--warning)_25%,transparent)] text-warning-foreground', held && '[animation:kitpho-pop_.45s_var(--ease-kit)]')}>
              <Icon name={held ? 'check' : 'calendar-check'} size={22} />
            </span>
            <p className="mt-4 text-[22px] font-semibold leading-snug tracking-[-0.01em] text-foreground">{fill(st === 'partly' ? t.dayPartly : t.dayFree, { d: long })}</p>
            {held ? (
              <>
                <p className="mt-2 text-[15px] text-muted-foreground">{fill(t.held, { d: heldUntil })}</p>
                <Button variant="ghost" className="mt-5 -ml-3" iconLeft="rotate-ccw" onClick={() => { setSel(null); setHeld(false); }}>
                  {t.other}
                </Button>
              </>
            ) : (
              <Magnetic className="mt-6 max-sm:flex max-sm:w-full"><Button icon="arrow-right" className="max-sm:w-full" onClick={() => {
                  // 48 hours from now, worked out at the click
                  setHeldUntil(new Intl.DateTimeFormat(TAG[locale], { weekday: 'long', day: 'numeric', month: 'long', hour: 'numeric', minute: '2-digit' }).format(new Date(Date.now() + 48 * 36e5)));
                  setHeld(true);
                }}>
                {t.hold}
              </Button></Magnetic>
            )}
          </div>
        )}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* galleries                                                           */
/* ------------------------------------------------------------------ */

function Galleries({ galleries = [], t }: PhotographerBookingProps & { t: Texts }) {
  return (
    <Stagger as="ul" step={90} className="grid grid-cols-1 gap-5 md:grid-cols-2">
      {galleries.map((g, i) => (
        <li key={g.id} className={cn(galleries.length % 2 === 1 && i === 0 && 'md:col-span-2')}>
          <a href={g.href ?? '#'} className={cn('group relative block overflow-hidden rounded-2xl bg-bezel', focusRing)} data-clip-ok>
            <span className={cn('relative block overflow-hidden', galleries.length % 2 === 1 && i === 0 ? 'aspect-[4/3] md:aspect-[21/9]' : 'aspect-[4/3]')}>
              <span className={cn('absolute inset-0 block transition-transform duration-1000 ease-[var(--ease-kit)] group-hover:scale-[1.04] group-focus-visible:scale-[1.04] motion-reduce:transition-none', fit)}>{g.cover}</span>
              <span className="absolute inset-0 bg-[linear-gradient(to_top,rgb(0_0_0/0.85),rgb(0_0_0/0.5)_45%,transparent_85%)]" />
            </span>
            <span className="absolute inset-x-0 bottom-0 p-5 text-white sm:p-6">
              <span className="flex items-end justify-between gap-4">
                <span>
                  <span className="block text-[13px] font-medium text-white/75">{fill(t.images, { n: g.count })}</span>
                  <span className="mt-1 block text-[24px] font-semibold tracking-[-0.02em] sm:text-[28px]">{g.title}</span>
                </span>
                <span className="flex size-11 shrink-0 items-center justify-center rounded-full bg-white/15 backdrop-blur transition-[background-color,transform] duration-300 group-hover:translate-x-0.5 group-hover:bg-white group-hover:text-black">
                  <Icon name="arrow-right" size={18} />
                </span>
              </span>
              {g.previews?.length ? (
                <span aria-hidden className="mt-4 flex gap-2 transition-[transform,opacity] duration-500 ease-[var(--ease-kit)] sm:translate-y-3 sm:opacity-0 sm:group-hover:translate-y-0 sm:group-hover:opacity-100 sm:group-focus-visible:translate-y-0 sm:group-focus-visible:opacity-100">
                  {g.previews.slice(0, 4).map((p, k) => (
                    <span key={k} className={cn('size-14 overflow-hidden rounded-md ring-1 ring-white/30 transition-transform duration-500 ease-[var(--ease-kit)]', fit)} style={{ transitionDelay: `${k * 60}ms` }}>
                      {p}
                    </span>
                  ))}
                </span>
              ) : null}
            </span>
            <span className="sr-only">{t.open}</span>
          </a>
        </li>
      ))}
    </Stagger>
  );
}

/* ------------------------------------------------------------------ */
/* quote                                                               */
/* ------------------------------------------------------------------ */

function Quote({ hourly = 240, extras = [], perKm = 0.6, freeKm = 50, base, currency = 'EUR', onSubmit, t }: PhotographerBookingProps & { t: Texts }) {
  const locale = useLocale();
  const [hours, setHours] = useState(6);
  const [picked, setPicked] = useState<string[]>([]);
  const [km, setKm] = useState(80);
  const travel = Math.max(0, Math.round((km - freeKm) * perKm * 2));
  const lines = [
    { k: 'shoot', label: fill(t.shooting, { n: hours }), v: hours * hourly },
    ...extras.filter((e) => picked.includes(e.id)).map((e) => ({ k: e.id, label: e.label, v: e.price })),
    { k: 'travel', label: travel ? fill(t.travel, { n: km }) : fill(t.travelFree, { n: freeKm }), v: travel },
  ];
  const total = lines.reduce((a, l) => a + l.v, 0);
  const tw = useTween(total);
  const pct = round((km / 400) * 100, 2);
  return (
    <div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] lg:gap-10">
      <div className="kit-card grid gap-8 p-6 sm:p-8">
        <div>
          <p className="text-[15px] font-semibold text-foreground">{t.hoursLabel}</p>
          <div className="mt-3 flex items-center gap-4">
            <button type="button" aria-label={t.fewer} disabled={hours <= 1} onClick={() => setHours((h) => Math.max(1, h - 1))} className={cn('flex size-11 items-center justify-center rounded-lg border border-border text-foreground transition-colors hover:bg-accent disabled:opacity-40', focusRing)}>
              <Icon name="minus" size={18} />
            </button>
            <div className="flex flex-1 gap-1" aria-hidden>
              {Array.from({ length: 12 }, (_, i) => (
                <span key={i} className={cn('h-9 flex-1 rounded-md transition-[background-color,transform] duration-300', i < hours ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]')} style={{ transitionDelay: `${i * 15}ms` }} />
              ))}
            </div>
            <button type="button" aria-label={t.more} disabled={hours >= 12} onClick={() => setHours((h) => Math.min(12, h + 1))} className={cn('flex size-11 items-center justify-center rounded-lg border border-border text-foreground transition-colors hover:bg-accent disabled:opacity-40', focusRing)}>
              <Icon name="plus" size={18} />
            </button>
          </div>
          <p className="mt-2 text-[14px] tabular-nums text-muted-foreground" aria-live="polite">
            {hours} × {money(hourly, currency, locale)}
          </p>
        </div>
        {extras.length > 0 && (
          <fieldset>
            <legend className="mb-3 text-[15px] font-semibold text-foreground">{t.extras}</legend>
            <div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
              {extras.map((e) => {
                const on = picked.includes(e.id);
                return (
                  <label key={e.id} className={cn('flex min-h-14 cursor-pointer items-center gap-3 rounded-xl border px-4 transition-[border-color,background-color] 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 hover:border-border-strong')}>
                    <input type="checkbox" checked={on} onChange={() => setPicked((v) => (on ? v.filter((x) => x !== e.id) : [...v, e.id]))} className="sr-only" />
                    <Icon name={e.icon ?? 'plus'} size={18} className={on ? 'text-primary' : 'text-muted-foreground'} />
                    <span className="min-w-0 flex-1 text-[14.5px] font-medium text-foreground">{e.label}</span>
                    <span className="text-[13.5px] tabular-nums text-muted-foreground">+{money(e.price, currency, locale)}</span>
                  </label>
                );
              })}
            </div>
          </fieldset>
        )}
        <div>
          <div className="flex items-baseline justify-between gap-3">
            <label htmlFor="pho-km" className="text-[15px] font-semibold text-foreground">
              {base ? fill(t.distanceFrom, { base }) : t.distance}
            </label>
            <span className="text-[15px] font-semibold tabular-nums text-foreground">{fill(t.km, { n: km })}</span>
          </div>
          <div className="relative mt-3">
            <div aria-hidden className="pointer-events-none absolute inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]">
              <div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
              <span className="absolute -top-1 h-3.5 w-0.5 rounded-full bg-success" style={{ left: `${round((freeKm / 400) * 100, 2)}%` }} />
            </div>
            <input id="pho-km" type="range" min={0} max={400} step={10} value={km} onChange={(e) => setKm(Number(e.target.value))} className="relative z-[1] h-11 w-full cursor-pointer appearance-none bg-transparent [&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:shadow-[0_0_0_2px_var(--primary),var(--shadow-md)] [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_var(--primary),var(--shadow-md)] focus-visible:outline-none focus-visible:[&::-webkit-slider-thumb]:ring-4 focus-visible:[&::-webkit-slider-thumb]:ring-ring" />
          </div>
        </div>
      </div>
      <aside className="lg:sticky lg:top-8 lg:self-start">
        <div className="kit-card overflow-hidden">
          <ul className="divide-y divide-border px-6">
            {lines.map((l) => (
              <li key={l.k} className="flex items-baseline justify-between gap-4 py-3.5 [animation:kitpho-line_.35s_var(--ease-kit)]">
                <span className="text-[14px] text-foreground">{l.label}</span>
                <span key={l.v} className="text-[14px] font-medium tabular-nums text-foreground [animation:kitpho-swap_.3s_var(--ease-kit)]">
                  {money(l.v, currency, locale)}
                </span>
              </li>
            ))}
          </ul>
          <div className="border-t border-border bg-surface p-6">
            <div className="flex items-end justify-between gap-4">
              <span className="text-[14px] text-muted-foreground">{t.total}</span>
              <span className="text-[34px] font-semibold leading-none tracking-[-0.03em] tabular-nums text-foreground" aria-live="polite">
                {money(Math.round(tw), currency, locale)}
              </span>
            </div>
            <Button type="button" icon="arrow-right" className="mt-5 w-full" onClick={() => onSubmit?.({ hours, extras: picked, km, total })}>
              {t.request}
            </Button>
            <p className="mt-3 text-[12.5px] text-muted-foreground">{t.vat}</p>
          </div>
        </div>
      </aside>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* proofing                                                            */
/* ------------------------------------------------------------------ */

function Proofing({ photos = [], included = 20, extraPhoto = 15, client, currency = 'EUR', onSubmit, t }: PhotographerBookingProps & { t: Texts }) {
  const locale = useLocale();
  const [liked, setLiked] = useState<number[]>([0, 2, 5]);
  const [only, setOnly] = useState('all');
  const [sent, setSent] = useState(false);
  const n = liked.length;
  const over = Math.max(0, n - included);
  const pct = round(Math.min(1, n / included) * 100, 2);
  const shown = photos.map((p, i) => ({ p, i })).filter(({ i }) => only === 'all' || liked.includes(i));
  const toggle = (i: number) => setLiked((v) => (v.includes(i) ? v.filter((x) => x !== i) : [...v, i]));
  return (
    <div>
      <div className="sticky top-0 z-[2] -mx-6 mb-6 border-b border-border bg-background/85 px-6 py-4 backdrop-blur sm:mx-0 sm:rounded-xl sm:border sm:px-5">
        <div className="flex flex-wrap items-center justify-between gap-4">
          <div className="min-w-0 basis-full sm:flex-1 sm:basis-auto">
            {client && <p className="text-[13px] text-muted-foreground">{fill(t.gallery, { name: client })}</p>}
            <p key={n} className="text-[16px] font-semibold tabular-nums text-foreground [animation:kitpho-swap_.3s_var(--ease-kit)]" aria-live="polite">
              {fill(t.chosen, { n, total: included })}
              {over > 0 && <span className="ml-2 text-[13.5px] font-medium text-warning-foreground">{fill(t.over, { n: over, price: money(extraPhoto, currency, locale) })}</span>}
            </p>
            <div className="mt-2 h-1.5 max-w-[320px] overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" aria-hidden>
              <div className={cn('h-full rounded-full transition-[width,background-color] duration-500 ease-[var(--ease-kit)]', over ? 'bg-warning' : 'bg-primary')} style={{ width: `${pct}%` }} />
            </div>
          </div>
          <div className="flex items-center gap-3">
            <Segmented label={t.favourites} value={only} onChange={setOnly} options={[{ id: 'all', label: t.all }, { id: 'fav', label: <span className="inline-flex items-center gap-1.5"><Icon name="heart" size={14} />{n}</span> }]} />
            <Button size="sm" icon="send" disabled={!n || sent} onClick={() => { setSent(true); onSubmit?.({ photos: liked }); }}>
              {t.send}
            </Button>
          </div>
        </div>
        {sent && (
          <p role="status" className="mt-3 flex items-center gap-2 text-[14px] font-medium text-success-foreground [animation:kitpho-swap_.4s_var(--ease-kit)]">
            <Icon name="check-circle" size={16} />
            {fill(t.sent, { n })}
          </p>
        )}
      </div>
      <ul key={only} className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-4">
        {shown.map(({ p, i }, k) => {
          const on = liked.includes(i);
          return (
            <li key={i} className="[animation:kitpho-in_.4s_var(--ease-kit)_both]" style={{ animationDelay: `${Math.min(k, 12) * 30}ms` }}>
              <div className="group relative aspect-[4/5] overflow-hidden rounded-lg bg-muted" data-clip-ok>
                <div className={cn('absolute inset-0 transition-[transform,filter] duration-500 ease-[var(--ease-kit)] group-hover:scale-[1.03]', fit, sent && !on && 'opacity-40 grayscale')}>{p}</div>
                <span className="absolute left-2 top-2 rounded bg-black/45 px-1.5 py-0.5 text-[11px] tabular-nums text-white">{String(i + 1).padStart(3, '0')}</span>
                <button type="button" aria-pressed={on} aria-label={on ? fill(t.unlike, { n: i + 1 }) : fill(t.like, { n: i + 1 })} disabled={sent} onClick={() => toggle(i)} className={cn('absolute bottom-2 right-2 flex size-11 items-center justify-center rounded-full backdrop-blur transition-[background-color,color,transform] duration-300 active:scale-90', focusRing, on ? 'bg-white text-destructive' : 'bg-black/35 text-white hover:bg-black/55')}>
                  <span key={String(on)} className={cn('inline-flex', on && '[animation:kitpho-pop_.4s_var(--ease-kit)]')}>
                    <Icon name="heart" size={19} className={on ? 'fill-current' : undefined} />
                  </span>
                </button>
                {on && <span aria-hidden className="pointer-events-none absolute inset-0 rounded-lg ring-[3px] ring-inset ring-white/90" />}
              </div>
            </li>
          );
        })}
      </ul>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* PhotographerBooking                                                 */
/* ------------------------------------------------------------------ */

/**
 * Sections for photographers who take bookings.
 *   packages    kinds of shoot, packages with hours, photos and delivery
 *   date-check  a month with free and booked days, hold a date
 *   galleries   themes as large cards with a preview strip
 *   quote       hours, extras and distance build the price
 *   proofing    clients pick their favourites with hearts
 */
export function PhotographerBooking(props: PhotographerBookingProps) {
  const { variant = 'packages', eyebrow, title, description, labels, className } = props;
  const t = useTexts(TEXTS, labels);
  const body = (() => {
    switch (variant) {
      case 'date-check':
        return <DateCheck {...props} t={t} />;
      case 'galleries':
        return <Galleries {...props} t={t} />;
      case 'quote':
        return <Quote {...props} t={t} />;
      case 'proofing':
        return <Proofing {...props} t={t} />;
      default:
        return <Packages {...props} t={t} />;
    }
  })();
  return (
    <Section className={className} background={variant === 'quote' ? 'surface' : 'default'}>
      <PhoMotion />
      {title ? <SectionHeader eyebrow={eyebrow} title={title} description={description} align={variant === 'packages' ? 'center' : 'left'} /> : null}
      {body}
    </Section>
  );
}

Häufige Fragen

Wie installiere ich Fotografen-Buchung?

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

Welche Varianten hat Fotografen-Buchung?

5 Varianten, Pakete, Datum prüfen, Galerien, Preisrechner und Auswahlgalerie. Du wählst sie mit der Prop variant, zum Beispiel variant="date-check".

Kann ich die Texte von Fotografen-Buchung übersetzen?

Ja. Fotografen-Buchung bringt 41 eigene Texte auf Deutsch und Englisch mit, etwa „Art des Shootings“, „Zeit“ und „Fotos“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Fotografen-Buchung einbauen?

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

Kann ich Fotografen-Buchung ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert