Photographer booking
For photographers who take bookings. Packages by kind of shoot, a calendar to check and hold a date, galleries by theme, a price builder and a proofing page where clients pick their favourites.
One of 3 components in Artists and musicians
Variants
Details
What Photographer booking brings along, read from the registry and the source.
- Category
- Artists and musicians
- Area
- Industries
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/photographer-booking.tsx
- npm packages
- None
- Languages
- English and German, 41 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The PhotographerBookingProps interface from components/ui/photographer-booking.tsx, with the comments from the code.
variantoptionalPhotographerBookingVariantpackagesdate-checkgalleriesquoteproofing
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodepackagesoptionalPhotoPackage[]monthoptionalstringdate-check: first month shown, YYYY-MM
bookedoptionalstring[]date-check: booked days and days with one slot left, YYYY-MM-DD
partlyoptionalstring[]galleriesoptionalPhotoGallery[]hourlyoptionalnumberquote
extrasoptionalPhotoExtra[]perKmoptionalnumberfreeKmoptionalnumberbaseoptionalstringquote: where the travel is counted from, e.g. "Hamburg"
photosoptionalReact.ReactNode[]proofing
includedoptionalnumberextraPhotooptionalnumberclientoptionalstringcurrencyoptionalstringonSubmitoptional(data: Record<string, unknown>) => void
Built-in texts
Photographer booking writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 41 texts
| Key | English | German |
|---|---|---|
| kind | English Kind of shoot | German Art des Shootings |
| hours | English Time | German Zeit |
| photos | English Photos | German Fotos |
| delivery | English Delivery | German Lieferung |
| book | English Check the date | German Datum prüfen |
| popular | English Most booked | German Am häufigsten gebucht |
| prev | English Previous month | German Vorheriger Monat |
| next | English Next month | German Nächster Monat |
| free | English Free | German Frei |
| partly | English One slot left | German Noch ein Termin |
| booked | English Booked | German Ausgebucht |
| pickDay | English Pick a day to see if it is free | German Wähle einen Tag, um zu sehen, ob er frei ist |
| dayFree | English {d} is free | German {d} ist frei |
| dayPartly | English {d} has one slot left | German Am {d} ist noch ein Termin frei |
| hold | English Hold this date for 48 hours | German Datum 48 Stunden reservieren |
| held | English Held until {d}, the confirmation is on its way. | German Reserviert bis {d}, die Bestätigung ist unterwegs. |
| other | English Pick another day | German Anderen Tag wählen |
| images | English {n} photos | German {n} Fotos |
| open | English Open gallery | German Galerie öffnen |
| hoursLabel | English Hours | German Stunden |
| fewer | English One hour less | German Eine Stunde weniger |
| more | English One hour more | German Eine Stunde mehr |
| extras | English Extras | German Extras |
| distance | English Distance | German Entfernung |
| distanceFrom | English Distance from {base} | German Entfernung ab {base} |
| km | English {n} km | German {n} km |
| shooting | English Shooting, {n} hours | German Shooting, {n} Stunden |
| travel | English Travel, {n} km | German Anfahrt, {n} km |
| travelFree | English Travel, first {n} km included | German Anfahrt, erste {n} km inklusive |
| total | English Total | German Gesamt |
| request | English Request this offer | German Angebot anfragen |
| vat | English All prices include VAT. | German Alle Preise inklusive Mehrwertsteuer. |
| chosen | English {n} of {total} chosen | German {n} von {total} gewählt |
| over | English {n} more at {price} each | German {n} weitere je {price} |
| favourites | English Favourites only | German Nur Favoriten |
| all | English All | German Alle |
| like | English Choose photo {n} | German Foto {n} wählen |
| unlike | English Remove photo {n} | German Foto {n} abwählen |
| send | English Send my choice | German Auswahl senden |
| sent | English Thanks, {n} photos are on their way to editing. | German Danke, {n} Fotos gehen in die Bearbeitung. |
| gallery | English Gallery for {name} | German Galerie für {name} |
Accessibility
Read from the source of the component.
- Photographer booking handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
- Sets aria-checked, aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
- Uses the roles grid, radio, radiogroup and status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 691 lines
'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>
);
}Frequent questions
How do I install Photographer booking?
With aiw add @ai-website/photographer-booking. The command puts components/ui/photographer-booking.tsx into your project, the code is yours from then on. It brings along button, icon, motion and section. It needs no further npm packages.
Which variants does Photographer booking have?
5 variants, Packages, Date check, Galleries, Price builder and Proofing gallery. You pick one with the variant prop, for example variant="date-check".
Can I translate the texts of Photographer booking?
Yes. Photographer booking carries 41 texts of its own in English and German, such as “Kind of shoot”, “Time” and “Photos”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Photographer booking?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Photographer booking with aiw_components and install it with aiw_add @ai-website/photographer-booking. Name the variant in your prompt, for example variant="packages", and the assistant writes your content straight into the props.
Can I adjust Photographer booking without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching PhotographerBooking code to copy.