Buchlaunch
Alles, was eine Buchseite braucht. Ein Cover, das sich zum Zeiger neigt, eine Leseprobe mit Fortschritt, das Inhaltsverzeichnis, Vorbestellung mit Boni und signiertem Exemplar und Rezensionen.
Eine von 3 Components in Creator
Variants
Details
Was Buchlaunch mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Creator
- Bereich
- Industries
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/book-launch.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 34 eigene Texte
- In Sammlungen
- Creator-Seite
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle BookLaunchProps aus components/ui/book-launch.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalBookLaunchVariantheroexcerptchapterspreorderreviews
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodebookPflicht{ title: string; subtitle?: string; author: string; /** YYYY-MM-DD */ release?: string; pages?: number; cover?: React.ReactNode; }formatsoptionalBookFormat[]chaptersoptionalBookChapter[]excerptoptional{ chapter: string; paragraphs: string[] }excerpt: paragraphs of the sample, the first gets a drop cap
quotesoptionalBookQuote[]ratingsoptionalnumber[]reviews: count of ratings per star, five first
bonusesoptional{ label: string; value?: string; icon?: IconName }[]preorder: bonuses for ordering before the release
signedExtraoptionalnumberpreorder: price of a signed copy on top
currencyoptionalstringonSubmitoptional(data: Record<string, string>) => voidlinksoptional{ excerpt?: string }Where the jumps go, sections of the same page by default: #excerpt
Eingebaute Texte
Diese Texte schreibt Buchlaunch selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 34 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| out | Englisch Out on {date} | Deutsch Erscheint am {date} |
| outNow | Englisch Out now | Deutsch Jetzt erhältlich |
| days | Englisch {n} days to go | Deutsch Noch {n} Tage |
| day | Englisch 1 day to go | Deutsch Noch 1 Tag |
| order | Englisch Pre-order now | Deutsch Jetzt vorbestellen |
| buy | Englisch Buy now | Deutsch Jetzt kaufen |
| sample | Englisch Read a sample | Deutsch Leseprobe lesen |
| by | Englisch by {name} | Deutsch von {name} |
| pages | Englisch {n} pages | Deutsch {n} Seiten |
| keepReading | Englisch Read the whole first chapter | Deutsch Das ganze erste Kapitel lesen |
| keepText | Englisch The full chapter as a PDF, straight to your inbox. | Deutsch Das vollständige Kapitel als PDF, direkt in dein Postfach. |
| Englisch Email | Deutsch E-Mail | |
| emailPh | Englisch you@example.com | Deutsch du@beispiel.de |
| send | Englisch Send me chapter 1 | Deutsch Kapitel 1 schicken |
| sent | Englisch Chapter 1 is on its way to {e}. | Deutsch Kapitel 1 ist unterwegs an {e}. |
| invalid | Englisch Please enter an email address like name@example.com. | Deutsch Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein. |
| contents | Englisch Contents | Deutsch Inhalt |
| free | Englisch Free sample | Deutsch Leseprobe |
| page | Englisch p. {n} | Deutsch S. {n} |
| format | Englisch Format | Deutsch Ausgabe |
| signed | Englisch Signed copy with dedication | Deutsch Signiert mit Widmung |
| dedication | Englisch Name for the dedication | Deutsch Name für die Widmung |
| dedicationPh | Englisch For Sam | Deutsch Für Sam |
| bonuses | Englisch Included with every pre-order | Deutsch Bei jeder Vorbestellung dabei |
| total | Englisch Total | Deutsch Gesamt |
| worth | Englisch worth {v} | Deutsch Wert {v} |
| readers | Englisch Reader ratings | Deutsch Bewertungen |
| ratingOf | Englisch {r} of 5 | Deutsch {r} von 5 |
| based | Englisch from {n} ratings | Deutsch aus {n} Bewertungen |
| prev | Englisch Previous quote | Deutsch Vorheriges Zitat |
| next | Englisch Next quote | Deutsch Nächstes Zitat |
| quote | Englisch Quote {n} of {total} | Deutsch Zitat {n} von {total} |
| press | Englisch In the press | Deutsch In der Presse |
| stars | Englisch {n} stars | Deutsch {n} Sterne |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-controls, aria-describedby, aria-expanded, aria-invalid, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen img, radiogroup, status und switch.
- 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. 5 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 701 Zeilen
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Magnetic, Parallax, Stagger, useScrollProgress } from '@/components/ui/motion';
import { Reveal } from '@/components/ui/reveal';
import { Section, SectionHeader } from '@/components/ui/section';
/*
* Sections for a book: the launch, a reading sample, the chapters, the
* pre-order with bonuses and what readers and the press say. The cover is a
* picture slot, the kit draws nothing in it.
*
* hero cover that tilts toward the pointer and drifts on scroll, release date, formats and one quote
* excerpt the first pages as a page, a progress line while reading, the rest by email
* chapters the contents with page numbers, each chapter opens with its summary, free ones marked
* preorder format cards, a signed copy, bonuses that stack up and the days until release
* reviews one large quote at a time, reader ratings as bars and short press lines
*/
export type BookLaunchVariant = 'hero' | 'excerpt' | 'chapters' | 'preorder' | 'reviews';
export interface BookFormat {
id: string;
label: string;
/** e.g. "Hardcover, 288 pages" */
detail?: string;
price: number;
icon?: IconName;
href?: string;
}
export interface BookChapter {
id: string;
title: string;
page: number;
summary?: string;
/** Part of the free sample */
free?: boolean;
}
export interface BookQuote {
quote: string;
name: string;
/** e.g. "Author of ..." or a newspaper */
role?: string;
rating?: number;
}
export interface BookLaunchProps {
variant?: BookLaunchVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
book: {
title: string;
subtitle?: string;
author: string;
/** YYYY-MM-DD */
release?: string;
pages?: number;
cover?: React.ReactNode;
};
formats?: BookFormat[];
chapters?: BookChapter[];
/** excerpt: paragraphs of the sample, the first gets a drop cap */
excerpt?: { chapter: string; paragraphs: string[] };
quotes?: BookQuote[];
/** reviews: count of ratings per star, five first */
ratings?: number[];
/** preorder: bonuses for ordering before the release */
bonuses?: { label: string; value?: string; icon?: IconName }[];
/** preorder: price of a signed copy on top */
signedExtra?: number;
currency?: string;
onSubmit?: (data: Record<string, string>) => void;
/** Where the jumps go, sections of the same page by default: #excerpt */
links?: { excerpt?: string };
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
out: 'Out on {date}',
outNow: 'Out now',
days: '{n} days to go',
day: '1 day to go',
order: 'Pre-order now',
buy: 'Buy now',
sample: 'Read a sample',
by: 'by {name}',
pages: '{n} pages',
keepReading: 'Read the whole first chapter',
keepText: 'The full chapter as a PDF, straight to your inbox.',
email: 'Email',
emailPh: 'you@example.com',
send: 'Send me chapter 1',
sent: 'Chapter 1 is on its way to {e}.',
invalid: 'Please enter an email address like name@example.com.',
contents: 'Contents',
free: 'Free sample',
page: 'p. {n}',
format: 'Format',
signed: 'Signed copy with dedication',
dedication: 'Name for the dedication',
dedicationPh: 'For Sam',
bonuses: 'Included with every pre-order',
total: 'Total',
worth: 'worth {v}',
readers: 'Reader ratings',
ratingOf: '{r} of 5',
based: 'from {n} ratings',
prev: 'Previous quote',
next: 'Next quote',
quote: 'Quote {n} of {total}',
press: 'In the press',
stars: '{n} stars',
},
de: {
out: 'Erscheint am {date}',
outNow: 'Jetzt erhältlich',
days: 'Noch {n} Tage',
day: 'Noch 1 Tag',
order: 'Jetzt vorbestellen',
buy: 'Jetzt kaufen',
sample: 'Leseprobe lesen',
by: 'von {name}',
pages: '{n} Seiten',
keepReading: 'Das ganze erste Kapitel lesen',
keepText: 'Das vollständige Kapitel als PDF, direkt in dein Postfach.',
email: 'E-Mail',
emailPh: 'du@beispiel.de',
send: 'Kapitel 1 schicken',
sent: 'Kapitel 1 ist unterwegs an {e}.',
invalid: 'Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.',
contents: 'Inhalt',
free: 'Leseprobe',
page: 'S. {n}',
format: 'Ausgabe',
signed: 'Signiert mit Widmung',
dedication: 'Name für die Widmung',
dedicationPh: 'Für Sam',
bonuses: 'Bei jeder Vorbestellung dabei',
total: 'Gesamt',
worth: 'Wert {v}',
readers: 'Bewertungen',
ratingOf: '{r} von 5',
based: 'aus {n} Bewertungen',
prev: 'Vorheriges Zitat',
next: 'Nächstes Zitat',
quote: 'Zitat {n} von {total}',
press: 'In der Presse',
stars: '{n} Sterne',
},
};
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) {
const whole = Number.isInteger(value);
return new Intl.NumberFormat(TAG[locale], { style: 'currency', currency, minimumFractionDigits: whole ? 0 : 2, maximumFractionDigits: whole ? 0 : 2 }).format(value);
}
function dateText(iso: string, locale: Locale) {
const [y, m, d] = iso.split('-').map(Number);
return new Intl.DateTimeFormat(TAG[locale], { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(Date.UTC(y, m - 1, d)));
}
/** Days until the release, only in the browser. On the server null, the date shows alone. */
function useDaysLeft(iso?: string) {
const [n, setN] = useState<number | null>(null);
useEffect(() => {
if (!iso) return;
const [y, m, d] = iso.split('-').map(Number);
const now = new Date();
const today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
setN(Math.round((Date.UTC(y, m - 1, d) - today) / 864e5));
}, [iso]);
return n;
}
const MOTION_CSS = `
@keyframes kitbook-in{from{opacity:0;transform:translateY(8px)}}
@keyframes kitbook-swap{from{opacity:0;filter:blur(4px);transform:translateY(6px)}}
@keyframes kitbook-quote{from{opacity:0;transform:translateY(14px)}}
@keyframes kitbook-grow{from{transform:scaleX(0)}}
@keyframes kitbook-pop{0%{transform:scale(.5);opacity:0}60%{transform:scale(1.12);opacity:1}100%{transform:scale(1)}}
@media (prefers-reduced-motion:reduce){[class*="kitbook-"],.kitbook-anim{animation:none!important;transition:none!important}}`;
function BookMotion() {
return (
<style href="kitbook-motion" precedence="default">
{MOTION_CSS}
</style>
);
}
function Stars({ value, size = 16, className, label }: { value: number; size?: number; className?: string; label?: string }) {
return (
<span role="img" aria-label={label} className={cn('inline-flex gap-0.5', className)}>
{[1, 2, 3, 4, 5].map((i) => (
<svg key={i} width={size} height={size} viewBox="0 0 24 24" aria-hidden className={i <= Math.round(value) ? 'text-[color-mix(in_oklab,var(--warning)_85%,var(--foreground))]' : 'text-border-strong'}>
<path fill="currentColor" d="M12 2.8l2.7 5.6 6.1.8-4.5 4.2 1.1 6.1L12 16.6l-5.4 2.9 1.1-6.1-4.5-4.2 6.1-.8z" />
</svg>
))}
</span>
);
}
/** The cover in a book shape: spine shade on the left, pages on the right edge, tilts toward a mouse */
function Cover({ cover, title, tilt = true, className }: { cover?: React.ReactNode; title: string; tilt?: boolean; className?: string }) {
const ref = useRef<HTMLDivElement>(null);
const move = (e: React.PointerEvent<HTMLDivElement>) => {
const el = ref.current;
if (!tilt || !el || e.pointerType !== 'mouse' || prefersReducedMotion()) return;
const r = el.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width - 0.5;
const y = (e.clientY - r.top) / r.height - 0.5;
el.style.transform = `perspective(1200px) rotateY(${(x * 14).toFixed(2)}deg) rotateX(${(-y * 10).toFixed(2)}deg)`;
el.style.setProperty('--gx', `${((x + 0.5) * 100).toFixed(1)}%`);
el.style.setProperty('--gy', `${((y + 0.5) * 100).toFixed(1)}%`);
};
const leave = () => {
if (ref.current) ref.current.style.transform = '';
};
return (
<div onPointerMove={move} onPointerLeave={leave} className={cn('relative', className)}>
<div ref={ref} className="relative aspect-[2/3] transition-transform duration-500 ease-[var(--ease-kit)] [transform-style:preserve-3d] motion-reduce:transition-none">
{/* pages behind the cover */}
<span aria-hidden className="absolute inset-y-[1.2%] -right-[3%] left-[4%] rounded-r-[6px] bg-[repeating-linear-gradient(to_bottom,#f4efe6_0,#f4efe6_2px,#e2dccf_2px,#e2dccf_3px)] shadow-[var(--shadow-md)]" />
<div className="relative size-full overflow-hidden rounded-[3px_8px_8px_3px] bg-muted shadow-[0_30px_60px_-20px_rgb(0_0_0/0.45),var(--shadow-md)]" data-clip-ok>
<div className={cn('size-full', fit)} role="img" aria-label={title}>
{cover}
</div>
<span aria-hidden className="absolute inset-y-0 left-0 w-[7%] bg-[linear-gradient(to_right,rgb(0_0_0/0.35),rgb(255_255_255/0.12)_45%,rgb(0_0_0/0.08))]" />
<span aria-hidden className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_var(--gx,30%)_var(--gy,20%),rgb(255_255_255/0.22),transparent_55%)] mix-blend-soft-light" />
</div>
</div>
</div>
);
}
function ReleaseChip({ release, t }: { release?: string; t: Texts }) {
const locale = useLocale();
const left = useDaysLeft(release);
if (!release) return null;
const out = left !== null && left <= 0;
return (
<span className="inline-flex min-h-8 flex-wrap items-center gap-x-2 gap-y-0.5 rounded-full border max-sm:rounded-xl border-border bg-card/70 px-3 py-1 text-[13px] text-foreground backdrop-blur">
<span className="relative flex size-2">
<span aria-hidden className="absolute inset-0 animate-ping rounded-full bg-primary opacity-60 motion-reduce:animate-none" />
<span className="relative size-2 rounded-full bg-primary" />
</span>
<span className="font-medium">{out ? t.outNow : fill(t.out, { date: dateText(release, locale) })}</span>
{left !== null && left > 0 && <span className="whitespace-nowrap text-muted-foreground [animation:kitbook-in_.4s_var(--ease-kit)] max-sm:basis-full max-sm:pl-4"><span aria-hidden className="max-sm:hidden">· </span>{left === 1 ? t.day : fill(t.days, { n: left })}</span>}
</span>
);
}
/* ------------------------------------------------------------------ */
/* hero */
/* ------------------------------------------------------------------ */
function Hero({ book, formats = [], quotes = [], links, t }: BookLaunchProps & { t: Texts }) {
const locale = useLocale();
const left = useDaysLeft(book.release);
const preorder = left === null ? !!book.release : left > 0;
const q = quotes[0];
return (
<div className="grid grid-cols-1 items-center gap-12 md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] lg:gap-20">
<div className="relative mx-auto w-full max-w-[220px] sm:max-w-[280px] md:order-2 md:max-w-[380px]">
<span aria-hidden data-clip-ok className="absolute inset-x-[-20%] top-[10%] h-[80%] rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklab,var(--primary)_28%,transparent),transparent)] blur-2xl" />
<Parallax speed={50}>
<div className="kit-rise [--kit-delay:120ms]">
<Cover cover={book.cover} title={book.title} />
</div>
</Parallax>
</div>
<div className="md:order-1">
<div className="kit-rise">
<ReleaseChip release={book.release} t={t} />
</div>
<h2 className="kit-rise mt-6 text-balance text-h1 font-semibold text-foreground [--kit-delay:60ms]">{book.title}</h2>
{book.subtitle && <p className="kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground [--kit-delay:120ms]">{book.subtitle}</p>}
<p className="kit-rise mt-5 text-[15px] text-foreground [--kit-delay:160ms]">
{fill(t.by, { name: book.author })}
{book.pages && <span className="text-muted-foreground"> · {fill(t.pages, { n: book.pages })}</span>}
</p>
<div className="kit-rise mt-8 flex flex-wrap items-center gap-3 [--kit-delay:220ms] max-sm:flex-col max-sm:items-stretch">
<Magnetic className="max-sm:flex max-sm:w-full">
<Button href={formats[0]?.href ?? '#'} icon="arrow-right" className="max-sm:w-full">
{preorder ? t.order : t.buy}
</Button>
</Magnetic>
<Button href={links?.excerpt ?? '#excerpt'} variant="outline" iconLeft="book-open" className="max-sm:w-full">
{t.sample}
</Button>
</div>
{formats.length > 0 && (
<ul className="kit-rise mt-6 flex flex-wrap gap-x-5 gap-y-2 text-[13.5px] text-muted-foreground [--kit-delay:260ms]">
{formats.map((f) => (
<li key={f.id} className="inline-flex items-center gap-1.5">
<Icon name={f.icon ?? 'book-open'} size={15} />
{f.label}
</li>
))}
</ul>
)}
{q && (
<Reveal delay={300} className="mt-10 border-l-2 border-primary pl-5">
<p className="text-pretty text-[17px] italic leading-relaxed text-foreground">{locale === 'de' ? '„' : '“'}{q.quote}{locale === 'de' ? '“' : '”'}</p>
<p className="mt-2 text-[13.5px] text-muted-foreground">
{q.name}
{q.role && `, ${q.role}`}
</p>
</Reveal>
)}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* excerpt */
/* ------------------------------------------------------------------ */
function Excerpt({ book, excerpt, onSubmit, t }: BookLaunchProps & { t: Texts }) {
const page = useScrollProgress<HTMLDivElement>({ start: 0.15, end: 0.85 });
const [email, setEmail] = useState('');
const [state, setState] = useState<'idle' | 'error' | 'done'>('idle');
const id = useId();
const submit = (e: React.FormEvent) => {
e.preventDefault();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return setState('error');
onSubmit?.({ email });
setState('done');
};
if (!excerpt) return null;
const [first, ...rest] = excerpt.paragraphs;
return (
<div id="excerpt" className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,220px)_minmax(0,1fr)] lg:gap-16">
<aside className="lg:sticky lg:top-8 lg:self-start">
<div className="flex items-center gap-4 lg:block">
<div className="w-16 shrink-0 lg:w-36">
<Cover cover={book.cover} title={book.title} tilt={false} />
</div>
<div className="lg:mt-6">
<p className="text-[15px] font-semibold text-foreground">{book.title}</p>
<p className="mt-0.5 text-[13.5px] text-muted-foreground">{fill(t.by, { name: book.author })}</p>
{book.pages && <p className="mt-2 text-[13px] text-muted-foreground">{fill(t.pages, { n: book.pages })}</p>}
</div>
</div>
</aside>
<div ref={page} className="relative w-full max-w-[720px]">
<div aria-hidden className="sticky top-0 z-[1] -mx-1 mb-6 h-1 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]">
<div className="kitbook-anim h-full origin-left rounded-full bg-primary" style={{ transform: 'scaleX(var(--kit-progress, 1))' }} />
</div>
<article className="rounded-md bg-[color-mix(in_oklab,var(--card)_95%,var(--warning))] px-6 py-10 shadow-[var(--highlight),var(--shadow-md)] ring-1 ring-border sm:px-12 sm:py-14">
<p className="text-center text-[12px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{excerpt.chapter}</p>
<div className="mt-8 space-y-5 text-pretty text-[17px] leading-[1.75] text-foreground sm:text-[18px]">
<p className="first-letter:float-left first-letter:mr-1.5 first-letter:mt-1 first-letter:text-[3.6em] first-letter:font-semibold first-letter:leading-[0.8] first-letter:text-primary">{first}</p>
{rest.map((p, i) => (
<p key={i} className={cn(i === rest.length - 1 && 'relative')}>
{p}
</p>
))}
</div>
<div aria-hidden className="pointer-events-none relative -mt-24 h-24 bg-[linear-gradient(to_bottom,transparent,color-mix(in_oklab,var(--card)_95%,var(--warning)))]" />
<div className="mt-2 border-t border-border pt-8 text-center">
<p className="text-[18px] font-semibold text-foreground">{t.keepReading}</p>
<p className="mt-1.5 text-[14.5px] text-muted-foreground">{t.keepText}</p>
{state === 'done' ? (
<p role="status" className="mx-auto mt-6 inline-flex items-center gap-2 rounded-full bg-[color-mix(in_oklab,var(--success)_14%,transparent)] px-4 py-2 text-[14px] font-medium text-success-foreground [animation:kitbook-swap_.45s_var(--ease-kit)]">
<Icon name="check" size={16} />
{fill(t.sent, { e: email })}
</p>
) : (
<form onSubmit={submit} noValidate className="mx-auto mt-6 max-w-[440px] text-left">
<label htmlFor={id} className="sr-only">
{t.email}
</label>
<div className="flex flex-col gap-2 sm:flex-row">
<input id={id} type="email" value={email} onChange={(e) => { setEmail(e.target.value); if (state === 'error') setState('idle'); }} placeholder={t.emailPh} aria-invalid={state === 'error'} aria-describedby={state === 'error' ? `${id}-e` : undefined} className={cn('h-11 w-full min-w-0 rounded-lg border bg-background px-3 text-[15px] text-foreground placeholder:text-subtle-foreground sm:w-0 sm:flex-1', focusRing, state === 'error' ? 'border-destructive' : 'border-border-strong')} />
<Button type="submit" icon="arrow-right">
{t.send}
</Button>
</div>
{state === 'error' && (
<p id={`${id}-e`} className="mt-2 text-[13px] text-destructive">
{t.invalid}
</p>
)}
</form>
)}
</div>
</article>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* chapters */
/* ------------------------------------------------------------------ */
function Chapters({ book, chapters = [], t }: BookLaunchProps & { t: Texts }) {
const [open, setOpen] = useState<string | null>(chapters.find((c) => c.free)?.id ?? null);
const base = useId();
const total = book.pages ?? chapters[chapters.length - 1]?.page ?? 1;
return (
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] lg:gap-16">
<div className="hidden lg:block">
<div className="sticky top-8 mx-auto max-w-[260px]">
<Cover cover={book.cover} title={book.title} />
<p className="mt-6 text-center text-[13.5px] text-muted-foreground">{fill(t.pages, { n: total })}</p>
</div>
</div>
<div>
<p className="mb-3 text-[13px] font-medium text-muted-foreground">{t.contents}</p>
<Stagger as="ol" step={50} className="border-t border-border">
{chapters.map((c, i) => {
const on = open === c.id;
const next = chapters[i + 1]?.page ?? total;
const share = round(((next - c.page) / total) * 100, 2);
return (
<li key={c.id} className="border-b border-border">
<h3>
<button type="button" aria-expanded={on} aria-controls={`${base}-${c.id}`} onClick={() => setOpen(on ? null : c.id)} disabled={!c.summary} className={cn('group grid w-full grid-cols-[2.25rem_minmax(0,1fr)_auto] items-baseline gap-x-3 py-4 text-left sm:grid-cols-[3rem_minmax(0,1fr)_auto]', focusRing, 'focus-visible:ring-inset focus-visible:ring-offset-0')}>
<span className="text-[14px] tabular-nums text-muted-foreground">{String(i + 1).padStart(2, '0')}</span>
<span className="flex min-w-0 items-baseline gap-3">
<span className={cn('text-[16.5px] font-medium leading-snug transition-colors', on ? 'text-foreground' : 'text-foreground/85 group-hover:text-foreground')}>{c.title}</span>
{c.free && <span className="shrink-0 rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] px-2 py-0.5 text-[11.5px] font-semibold text-foreground">{t.free}</span>}
<span aria-hidden className="hidden h-px min-w-6 flex-1 translate-y-[-4px] border-b border-dotted border-border-strong sm:block" />
</span>
<span className="flex items-center gap-3 text-[13.5px] tabular-nums text-muted-foreground">
{fill(t.page, { n: c.page })}
{c.summary && <Icon name="chevron-down" size={16} className={cn('transition-transform duration-300', on && 'rotate-180')} />}
</span>
</button>
</h3>
<div id={`${base}-${c.id}`} hidden={!on} className="pb-5 pl-[3rem] sm:pl-[3.75rem]">
{on && (
<div className="[animation:kitbook-swap_.45s_var(--ease-kit)]">
<p className="max-w-[40rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{c.summary}</p>
<div className="mt-4 flex items-center gap-3">
<div aria-hidden className="h-1 w-full max-w-[200px] overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]">
<div className="h-full origin-left rounded-full bg-primary [animation:kitbook-grow_.8s_var(--ease-kit)]" style={{ width: `${Math.min(100, Math.max(4, share * 4))}%` }} />
</div>
<span className="text-[12.5px] tabular-nums text-muted-foreground">{fill(t.pages, { n: next - c.page })}</span>
</div>
</div>
)}
</div>
</li>
);
})}
</Stagger>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* preorder */
/* ------------------------------------------------------------------ */
function Preorder({ book, formats = [], bonuses = [], signedExtra, currency = 'EUR', t }: BookLaunchProps & { t: Texts }) {
const locale = useLocale();
const [fmt, setFmt] = useState(formats[0]?.id ?? '');
const [signed, setSigned] = useState(false);
const [name, setName] = useState('');
const base = useId();
const f = formats.find((x) => x.id === fmt) ?? formats[0];
const physical = f?.icon === 'book-open' || f?.id === 'hardcover' || f?.id === 'paperback';
const total = (f?.price ?? 0) + (signed && physical && signedExtra ? signedExtra : 0);
const [ref, inView] = useInView<HTMLUListElement>();
const left = useDaysLeft(book.release);
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)] lg:gap-12">
<div className="kit-card p-6 sm:p-8">
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-[15px] font-semibold text-foreground">{t.format}</p>
<ReleaseChip release={book.release} t={t} />
</div>
<div role="radiogroup" aria-label={t.format} className="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-3">
{formats.map((x) => {
const on = x.id === f?.id;
return (
<label key={x.id} className={cn('relative flex cursor-pointer flex-col rounded-xl border p-4 transition-[border-color,background-color,transform] duration-300 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring max-sm:flex-row max-sm:items-center max-sm:gap-3', on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))]' : 'border-border hover:-translate-y-0.5 hover:border-border-strong')}>
<input type="radio" name={`${base}-f`} checked={on} onChange={() => setFmt(x.id)} className="sr-only" />
<Icon name={x.icon ?? 'book-open'} size={20} className={on ? 'text-primary' : 'text-muted-foreground'} />
<span className="mt-3 block min-w-0 flex-1 max-sm:mt-0">
<span className="block text-[15px] font-semibold text-foreground">{x.label}</span>
{x.detail && <span className="block text-[12.5px] text-muted-foreground">{x.detail}</span>}
</span>
<span className="mt-3 text-[16px] font-semibold tabular-nums text-foreground max-sm:mt-0">{money(x.price, currency, locale)}</span>
</label>
);
})}
</div>
{signedExtra !== undefined && (
<div className={cn('mt-5 rounded-xl border border-border p-4 transition-opacity', !physical && 'opacity-50')}>
<label className="flex cursor-pointer items-center justify-between gap-4">
<span className="flex items-center gap-3">
<Icon name="edit" size={18} className="text-muted-foreground" />
<span className="text-[14.5px] font-medium text-foreground">{t.signed}</span>
</span>
<span className="flex items-center gap-3">
<span className="whitespace-nowrap text-[14px] tabular-nums text-muted-foreground">+{money(signedExtra, currency, locale)}</span>
<input type="checkbox" role="switch" checked={signed && physical} disabled={!physical} onChange={(e) => setSigned(e.target.checked)} className="peer sr-only" />
<span aria-hidden className={cn('relative h-6 w-10 rounded-full transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring', signed && physical ? 'bg-primary' : 'bg-border-strong')}>
<span className={cn('absolute left-0.5 top-0.5 size-5 rounded-full bg-white shadow transition-transform duration-300 ease-[var(--ease-kit)]', signed && physical && 'translate-x-4')} />
</span>
</span>
</label>
{signed && physical && (
<div className="mt-4 [animation:kitbook-swap_.4s_var(--ease-kit)]">
<label htmlFor={`${base}-d`} className="mb-2 block text-[13.5px] font-medium text-foreground">
{t.dedication}
</label>
<input id={`${base}-d`} value={name} onChange={(e) => setName(e.target.value)} placeholder={t.dedicationPh} className={cn('h-11 w-full rounded-lg border border-border-strong bg-background px-3 text-[15px] text-foreground placeholder:text-subtle-foreground', focusRing)} />
</div>
)}
</div>
)}
<div className="mt-6 flex flex-col gap-4 border-t border-border pt-6 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-[13px] text-muted-foreground">{t.total}</p>
<p key={total} className="text-[30px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground [animation:kitbook-swap_.4s_var(--ease-kit)]">
{money(total, currency, locale)}
</p>
</div>
<Button href={f?.href ?? '#'} icon="arrow-right" size="lg" className="max-sm:w-full">
{left !== null && left <= 0 ? t.buy : t.order}
</Button>
</div>
</div>
{bonuses.length > 0 && (
<div className="relative overflow-hidden rounded-xl border border-border bg-surface p-6 sm:p-8">
<p className="text-[15px] font-semibold text-foreground">{t.bonuses}</p>
<ul ref={ref} className="relative z-[1] mt-5 grid gap-3">
{bonuses.map((b, i) => (
<li key={b.label} className="flex items-center gap-4 rounded-xl border border-border bg-card p-4 transition-[transform,opacity] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: inView ? 'none' : `translateY(${16 + i * 10}px) scale(0.97)`, opacity: inView ? 1 : 0, transitionDelay: inView ? `${i * 120}ms` : '0ms' }}>
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-primary">
<Icon name={b.icon ?? 'gift'} size={19} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[14.5px] font-medium text-foreground">{b.label}</span>
{b.value && <span className="mt-0.5 block text-[13px] text-muted-foreground sm:hidden">{fill(t.worth, { v: b.value })}</span>}
</span>
{b.value && <span className="shrink-0 text-[13px] text-muted-foreground max-sm:hidden">{fill(t.worth, { v: b.value })}</span>}
</li>
))}
</ul>
<div aria-hidden data-clip-ok className="pointer-events-none absolute -bottom-24 -right-12 w-36 rotate-12 opacity-80 max-lg:hidden">
<Cover cover={book.cover} title={book.title} tilt={false} />
</div>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* reviews */
/* ------------------------------------------------------------------ */
function Reviews({ quotes = [], ratings = [], t }: BookLaunchProps & { t: Texts }) {
const locale = useLocale();
const big = quotes.filter((q) => q.rating === undefined || q.quote.length > 90);
const small = quotes.filter((q) => !big.includes(q));
const [i, setI] = useState(0);
const [paused, setPaused] = useState(false);
const count = ratings.reduce((a, b) => a + b, 0);
const avg = count ? ratings.reduce((a, n, k) => a + n * (5 - k), 0) / count : 0;
const [ref, inView] = useInView<HTMLDivElement>();
useEffect(() => {
if (paused || big.length < 2 || prefersReducedMotion()) return;
const id = window.setInterval(() => setI((v) => (v + 1) % big.length), 7000);
return () => window.clearInterval(id);
}, [paused, big.length]);
const q = big[i % Math.max(1, big.length)];
const nf = new Intl.NumberFormat(TAG[locale], { maximumFractionDigits: 1, minimumFractionDigits: 1 });
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)] lg:gap-8">
{q && (
<figure onPointerEnter={() => setPaused(true)} onPointerLeave={() => setPaused(false)} onFocus={() => setPaused(true)} onBlur={() => setPaused(false)} className="kit-card relative flex min-h-[300px] flex-col justify-between overflow-hidden p-8 sm:p-10">
<Icon name="quote" size={56} className="absolute right-8 top-8 text-[color-mix(in_oklab,var(--primary)_22%,transparent)]" />
<blockquote key={i} aria-live="polite" className="relative max-w-[40rem] text-balance pr-12 text-[22px] font-medium leading-[1.4] tracking-[-0.01em] text-foreground [animation:kitbook-quote_.6s_var(--ease-kit)] sm:text-[26px]">
{q.quote}
</blockquote>
<figcaption key={`c${i}`} className="relative mt-8 flex flex-wrap items-end justify-between gap-4 [animation:kitbook-in_.5s_var(--ease-kit)_both] [animation-delay:120ms]">
<span>
<span className="block text-[15px] font-semibold text-foreground">{q.name}</span>
{q.role && <span className="block text-[13.5px] text-muted-foreground">{q.role}</span>}
</span>
{big.length > 1 && (
<span className="flex items-center gap-3">
<span className="text-[13px] tabular-nums text-muted-foreground">{fill(t.quote, { n: i + 1, total: big.length })}</span>
<button type="button" aria-label={t.prev} onClick={() => setI((v) => (v - 1 + big.length) % big.length)} className={cn('flex size-11 items-center justify-center rounded-full border border-border text-foreground transition-colors hover:bg-accent', focusRing)}>
<Icon name="arrow-left" size={18} />
</button>
<button type="button" aria-label={t.next} onClick={() => setI((v) => (v + 1) % big.length)} className={cn('flex size-11 items-center justify-center rounded-full border border-border text-foreground transition-colors hover:bg-accent', focusRing)}>
<Icon name="arrow-right" size={18} />
</button>
</span>
)}
</figcaption>
{big.length > 1 && !paused && (
<span aria-hidden key={`p${i}`} className="absolute inset-x-0 bottom-0 h-0.5 origin-left bg-primary animate-kit-progress [--kit-duration:7s] motion-reduce:hidden" />
)}
</figure>
)}
{count > 0 && (
<div ref={ref} className="kit-card p-6">
<p className="text-[13px] font-medium text-muted-foreground">{t.readers}</p>
<div className="mt-2 flex items-end gap-3">
<span className="text-[44px] font-semibold leading-none tracking-[-0.03em] tabular-nums text-foreground">{nf.format(avg)}</span>
<span className="pb-1">
<Stars value={avg} label={fill(t.ratingOf, { r: nf.format(avg) })} />
<span className="block text-[12.5px] text-muted-foreground">{fill(t.based, { n: new Intl.NumberFormat(TAG[locale]).format(count) })}</span>
</span>
</div>
<ul className="mt-5 grid gap-2">
{ratings.map((n, k) => (
<li key={k} className="grid grid-cols-[2.5rem_minmax(0,1fr)_2.75rem] items-center gap-3 text-[13px] tabular-nums text-muted-foreground">
<span aria-label={fill(t.stars, { n: 5 - k })}>{5 - k} ★</span>
<span className="h-2 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]">
<span className="block h-full origin-left rounded-full bg-[color-mix(in_oklab,var(--warning)_85%,var(--foreground))] transition-transform duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? round(n / Math.max(...ratings), 3) : 0})`, transitionDelay: inView ? `${k * 90}ms` : '0ms' }} />
</span>
<span className="text-right">{Math.round((n / count) * 100)}{locale === 'de' ? ' %' : '%'}</span>
</li>
))}
</ul>
</div>
)}
{small.length > 0 && (
<div className="lg:col-span-2">
<p className="mb-3 text-[13px] font-medium text-muted-foreground">{t.press}</p>
<Stagger as="ul" step={80} className="grid grid-cols-1 gap-3 md:grid-cols-3">
{small.map((s) => (
<li key={s.name} className="rounded-xl border border-border p-4">
{s.rating !== undefined && <Stars value={s.rating} size={14} label={fill(t.ratingOf, { r: s.rating })} />}
<p className="mt-2 text-pretty text-[14.5px] leading-relaxed text-foreground">{s.quote}</p>
<p className="mt-2 text-[13px] font-medium text-muted-foreground">{s.name}</p>
</li>
))}
</Stagger>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* BookLaunch */
/* ------------------------------------------------------------------ */
/**
* Sections for a book launch.
* hero tilting cover, release date with days to go, formats and one quote
* excerpt the first pages with a reading line, the rest by email
* chapters the contents, chapters open with their summary
* preorder formats, signed copy, bonuses and the total
* reviews rotating quotes, reader ratings and press lines
*/
export function BookLaunch(props: BookLaunchProps) {
const { variant = 'hero', eyebrow, title, description, labels, className } = props;
const t = useTexts(TEXTS, labels);
const body = (() => {
switch (variant) {
case 'excerpt':
return <Excerpt {...props} t={t} />;
case 'chapters':
return <Chapters {...props} t={t} />;
case 'preorder':
return <Preorder {...props} t={t} />;
case 'reviews':
return <Reviews {...props} t={t} />;
default:
return <Hero {...props} t={t} />;
}
})();
return (
<Section className={cn('overflow-clip', className)} background={variant === 'hero' ? 'glow' : variant === 'excerpt' ? 'surface' : 'default'}>
<BookMotion />
{title && variant !== 'hero' ? <SectionHeader eyebrow={eyebrow} title={title} description={description} /> : null}
{body}
</Section>
);
}Häufige Fragen
Wie installiere ich Buchlaunch?
Mit aiw add @ai-website/book-launch. Der Befehl legt die Datei components/ui/book-launch.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, motion, reveal und section. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Buchlaunch?
5 Varianten, Launch-Hero, Leseprobe, Inhalt, Vorbestellung und Rezensionen. Du wählst sie mit der Prop variant, zum Beispiel variant="excerpt".
Kann ich die Texte von Buchlaunch übersetzen?
Ja. Buchlaunch bringt 34 eigene Texte auf Deutsch und Englisch mit, etwa „Jetzt erhältlich“, „Noch 1 Tag“ und „Jetzt vorbestellen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Buchlaunch einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Buchlaunch mit aiw_components und installieren sie mit aiw_add @ai-website/book-launch. Nenn im Prompt die Variante, etwa variant="hero", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Buchlaunch ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden BookLaunch-Code zum Kopieren.