Creator-Shop
Ein Shop für digitale Produkte. Schaufenster nach Art, Presets mit Live-Vorher-Nachher-Regler, Produktseite mit Dateien und Lizenzen, Paket-Baukasten und Zahl, was du willst.
Eine von 3 Components in Creator
Variants
Details
Was Creator-Shop mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Creator
- Bereich
- Industries
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/creator-shop.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 39 eigene Texte
- In Sammlungen
- Creator-Seite
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle CreatorShopProps aus components/ui/creator-shop.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalCreatorShopVariantstorefrontpresetsproductbundlepay-what-you-want
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeproductsoptionalShopProduct[]productoptionalShopProduct & { description?: string; points?: string[] }presets, product, pay-what-you-want: the product shown
presetsoptionalShopPreset[]photooptionalReact.ReactNodepresets: the photo the looks are applied to
filesoptionalShopFile[]licencesoptionalShopLicence[]tiersoptionalRecord<number, number>bundle: discount in percent by number of products, e.g. { 2: 10, 3: 20, 4: 30 }
suggestedoptionalnumberpay-what-you-want: suggested amounts and the top of the slider
maxoptionalnumbercurrencyoptionalstringonBuyoptional(data: Record<string, unknown>) => void
Eingebaute Texte
Diese Texte schreibt Creator-Shop selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 39 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| all | Englisch All | Deutsch Alle |
| filter | Englisch Filter by kind | Deutsch Nach Art filtern |
| instant | Englisch Instant download | Deutsch Sofort-Download |
| add | Englisch Add to bag | Deutsch In den Korb |
| added | Englisch In your bag | Deutsch Im Warenkorb |
| before | Englisch Before | Deutsch Vorher |
| after | Englisch After | Deutsch Nachher |
| compare | Englisch Drag to compare before and after | Deutsch Ziehen, um vorher und nachher zu vergleichen |
| looks | Englisch Looks | Deutsch Looks |
| buy | Englisch Buy for {price} | Deutsch Für {price} kaufen |
| buyShort | Englisch Buy for | Deutsch Kaufen für |
| inside | Englisch What is inside | Deutsch Was drin ist |
| files | Englisch {n} files | Deutsch {n} Dateien |
| licence | Englisch Licence | Deutsch Lizenz |
| secure | Englisch Paid securely, files arrive in seconds | Deutsch Sicher bezahlt, Dateien kommen in Sekunden |
| lifetime | Englisch Updates for life | Deutsch Updates für immer |
| choose | Englisch Choose products | Deutsch Produkte wählen |
| selected | Englisch {n} selected | Deutsch {n} gewählt |
| bundleTotal | Englisch Bundle price | Deutsch Preis fürs Paket |
| save | Englisch You save {v} | Deutsch Du sparst {v} |
| nextTier | Englisch Add {n} more for {p}% off | Deutsch Noch {n} dazu für {p} % Rabatt |
| topTier | Englisch Biggest discount reached | Deutsch Höchster Rabatt erreicht |
| getBundle | Englisch Get the bundle | Deutsch Paket holen |
| off | Englisch {p}% off | Deutsch {p} % Rabatt |
| pwyw | Englisch Pay what you want | Deutsch Zahl, was du willst |
| free | Englisch Free | Deutsch Kostenlos |
| name | Englisch Price | Deutsch Preis |
| sliderLabel | Englisch Your price | Deutsch Dein Preis |
| thanks | Englisch Thank you, that helps me make the next one. | Deutsch Danke, das hilft mir beim nächsten. |
| zero | Englisch Free is fine. Enjoy it. | Deutsch Kostenlos ist völlig in Ordnung. Viel Freude damit. |
| Englisch Email for the download | Deutsch E-Mail für den Download | |
| emailPh | Englisch you@example.com | Deutsch du@beispiel.de |
| download | Englisch Send me the download | Deutsch Download schicken |
| downloadFor | Englisch Pay {price} and download | Deutsch {price} zahlen und herunterladen |
| invalid | Englisch Please enter an email address like name@example.com. | Deutsch Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein. |
| sent | Englisch The download link is on its way to {e}. | Deutsch Der Download-Link ist unterwegs an {e}. |
| suggested | Englisch Suggested | Deutsch Vorschlag |
| sales | Englisch {n} sold | Deutsch {n} verkauft |
| rating | Englisch {r} of 5 stars | Deutsch {r} von 5 Sternen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Creator-Shop reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1 und Ende.
- Setzt aria-checked, aria-describedby, aria-invalid, aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen radio, radiogroup, status und tab.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 805 Zeilen
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useSpotlight, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Stagger, useScrollFade, Magnetic } from '@/components/ui/motion';
import { Section, SectionHeader } from '@/components/ui/section';
/*
* A shop for digital products a creator makes: presets, templates, ebooks,
* brushes, small courses. Everything is delivered as a download, so the
* sections talk about files, formats and licences instead of shipping.
*
* storefront products by kind with a filter, file formats on each card, a preview that zooms
* presets one photo, a slider between before and after, every preset applied live
* product one product: what is inside as a file list, licence choice with the price, instant download
* bundle pick products, the discount grows with every one, a bar shows the next step
* pay-what-you-want a free product with a slider for an optional price and the email for the download
*/
export type CreatorShopVariant = 'storefront' | 'presets' | 'product' | 'bundle' | 'pay-what-you-want';
export interface ShopProduct {
id: string;
title: string;
/** e.g. "Presets" or "Template" */
kind: string;
price: number;
was?: number;
/** File formats, e.g. ["XMP", "DNG"] */
formats?: string[];
/** e.g. "24 presets" */
count?: string;
badge?: string;
rating?: number;
sales?: number;
image?: React.ReactNode;
href?: string;
}
export interface ShopPreset {
id: string;
name: string;
/** A CSS filter that shows the look on the photo, e.g. "saturate(1.2) sepia(.2)" */
filter: string;
}
export interface ShopFile {
name: string;
size?: string;
/** Files inside a folder */
children?: ShopFile[];
}
export interface ShopLicence {
id: string;
label: string;
/** What it allows, one line */
detail: string;
price: number;
}
export interface CreatorShopProps {
variant?: CreatorShopVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
products?: ShopProduct[];
/** presets, product, pay-what-you-want: the product shown */
product?: ShopProduct & { description?: string; points?: string[] };
presets?: ShopPreset[];
/** presets: the photo the looks are applied to */
photo?: React.ReactNode;
files?: ShopFile[];
licences?: ShopLicence[];
/** bundle: discount in percent by number of products, e.g. { 2: 10, 3: 20, 4: 30 } */
tiers?: Record<number, number>;
/** pay-what-you-want: suggested amounts and the top of the slider */
suggested?: number;
max?: number;
currency?: string;
onBuy?: (data: Record<string, unknown>) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
all: 'All',
filter: 'Filter by kind',
instant: 'Instant download',
add: 'Add to bag',
added: 'In your bag',
before: 'Before',
after: 'After',
compare: 'Drag to compare before and after',
looks: 'Looks',
buy: 'Buy for {price}',
buyShort: 'Buy for',
inside: 'What is inside',
files: '{n} files',
licence: 'Licence',
secure: 'Paid securely, files arrive in seconds',
lifetime: 'Updates for life',
choose: 'Choose products',
selected: '{n} selected',
bundleTotal: 'Bundle price',
save: 'You save {v}',
nextTier: 'Add {n} more for {p}% off',
topTier: 'Biggest discount reached',
getBundle: 'Get the bundle',
off: '{p}% off',
pwyw: 'Pay what you want',
free: 'Free',
name: 'Price',
sliderLabel: 'Your price',
thanks: 'Thank you, that helps me make the next one.',
zero: 'Free is fine. Enjoy it.',
email: 'Email for the download',
emailPh: 'you@example.com',
download: 'Send me the download',
downloadFor: 'Pay {price} and download',
invalid: 'Please enter an email address like name@example.com.',
sent: 'The download link is on its way to {e}.',
suggested: 'Suggested',
sales: '{n} sold',
rating: '{r} of 5 stars',
},
de: {
all: 'Alle',
filter: 'Nach Art filtern',
instant: 'Sofort-Download',
add: 'In den Korb',
added: 'Im Warenkorb',
before: 'Vorher',
after: 'Nachher',
compare: 'Ziehen, um vorher und nachher zu vergleichen',
looks: 'Looks',
buy: 'Für {price} kaufen',
buyShort: 'Kaufen für',
inside: 'Was drin ist',
files: '{n} Dateien',
licence: 'Lizenz',
secure: 'Sicher bezahlt, Dateien kommen in Sekunden',
lifetime: 'Updates für immer',
choose: 'Produkte wählen',
selected: '{n} gewählt',
bundleTotal: 'Preis fürs Paket',
save: 'Du sparst {v}',
nextTier: 'Noch {n} dazu für {p} % Rabatt',
topTier: 'Höchster Rabatt erreicht',
getBundle: 'Paket holen',
off: '{p} % Rabatt',
pwyw: 'Zahl, was du willst',
free: 'Kostenlos',
name: 'Preis',
sliderLabel: 'Dein Preis',
thanks: 'Danke, das hilft mir beim nächsten.',
zero: 'Kostenlos ist völlig in Ordnung. Viel Freude damit.',
email: 'E-Mail für den Download',
emailPh: 'du@beispiel.de',
download: 'Download schicken',
downloadFor: '{price} zahlen und herunterladen',
invalid: 'Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.',
sent: 'Der Download-Link ist unterwegs an {e}.',
suggested: 'Vorschlag',
sales: '{n} verkauft',
rating: '{r} von 5 Sternen',
},
};
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);
}
/** Arrow keys, Home and End move the choice inside a radio group or a tab list (APG) */
function arrowKeys(e: React.KeyboardEvent<HTMLElement>) {
const keys = ['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End'];
if (!keys.includes(e.key)) return;
const items = [...e.currentTarget.querySelectorAll<HTMLButtonElement>('[role="radio"]:not(:disabled), [role="tab"]:not(:disabled)')];
const i = items.indexOf(document.activeElement as HTMLButtonElement);
if (i < 0) return;
e.preventDefault();
const step = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : -1;
const n = e.key === 'Home' ? 0 : e.key === 'End' ? items.length - 1 : (i + step + items.length) % items.length;
items[n].focus();
items[n].click();
}
const MOTION_CSS = `
@keyframes kitshop-in{from{opacity:0;transform:translateY(10px) scale(.98)}}
@keyframes kitshop-swap{from{opacity:0;filter:blur(4px);transform:translateY(6px)}}
@keyframes kitshop-pop{0%{transform:scale(.5);opacity:0}60%{transform:scale(1.12);opacity:1}100%{transform:scale(1)}}
@keyframes kitshop-hint{0%,100%{transform:translateX(0)}50%{transform:translateX(-14px)}}
@media (prefers-reduced-motion:reduce){[class*="kitshop-"],.kitshop-anim{animation:none!important}}`;
function ShopMotion() {
return (
<style href="kitshop-motion" precedence="default">
{MOTION_CSS}
</style>
);
}
/** A number that glides to its new value. Starts at the value, so server and browser agree. */
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 Money({ value, currency }: { value: number; currency: string }) {
const locale = useLocale();
const v = useTween(value);
return <>{money(Math.round(v * 100) / 100, currency, locale)}</>;
}
function Formats({ formats = [] }: { formats?: string[] }) {
if (!formats.length) return null;
return (
<span className="flex flex-wrap gap-1">
{formats.map((f) => (
<span key={f} className="rounded-[5px] border border-border px-1.5 py-px text-[11px] font-semibold tracking-[0.04em] text-muted-foreground">
{f}
</span>
))}
</span>
);
}
/** A row of choices with a marker that glides to the active one */
function Segmented({ options, value, onChange, label, className }: { options: { id: string; label: React.ReactNode }[]; value: string; onChange: (id: string) => void; label: string; className?: string }) {
const wrap = useRef<HTMLDivElement>(null);
const [box, setBox] = useState<{ x: number; w: number } | null>(null);
useEffect(() => {
const el = wrap.current;
if (!el) return;
const measure = () => {
const b = el.querySelector<HTMLElement>('[aria-checked="true"]');
if (b) setBox({ x: b.offsetLeft, w: b.offsetWidth });
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [value]);
const key = (e: React.KeyboardEvent) => {
const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
if (!d) return;
e.preventDefault();
const i = options.findIndex((o) => o.id === value);
const n = options[(i + d + options.length) % options.length];
onChange(n.id);
wrap.current?.querySelector<HTMLElement>(`[data-v="${n.id}"]`)?.focus();
};
return (
<div ref={wrap} role="radiogroup" aria-label={label} onKeyDown={key} className={cn('relative inline-flex max-w-full rounded-xl border border-border bg-muted p-1', className)}>
{box && <span aria-hidden className="absolute bottom-1 left-0 top-1 rounded-lg bg-card shadow-[var(--highlight),var(--shadow-sm)] ring-1 ring-border-strong transition-[transform,width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: box.w, transform: `translateX(${box.x}px)` }} />}
{options.map((o) => {
const on = o.id === value;
return (
<button key={o.id} type="button" role="radio" data-v={o.id} aria-checked={on} tabIndex={on ? 0 : -1} onClick={() => onChange(o.id)} className={cn('relative z-[1] inline-flex min-h-10 shrink-0 items-center justify-center whitespace-nowrap rounded-lg px-4 text-[14px] font-medium transition-colors max-sm:min-h-11', focusRing, on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', on && !box && 'bg-card shadow-[var(--shadow-sm)]')}>
{o.label}
</button>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ */
/* storefront */
/* ------------------------------------------------------------------ */
function ProductCard({ p, currency, t, bagged, onAdd }: { p: ShopProduct; currency: string; t: Texts; bagged: boolean; onAdd: () => void }) {
const locale = useLocale();
const spot = useSpotlight<HTMLElement>();
return (
<article onPointerMove={spot} className="kit-spotlight group relative flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow] duration-300 ease-[var(--ease-kit)] hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-md)]">
<a href={p.href ?? '#'} className={cn('relative block aspect-[4/3] overflow-hidden bg-muted', focusRing)} data-clip-ok aria-label={p.title}>
<span className={cn('absolute inset-0 block transition-transform duration-700 ease-[var(--ease-kit)] group-hover:scale-[1.05] motion-reduce:transition-none', fit)}>{p.image}</span>
{p.badge && <span className="absolute left-3 top-3 rounded-full bg-background/90 px-2.5 py-1 text-[12px] font-semibold text-foreground backdrop-blur">{p.badge}</span>}
</a>
<div className="flex flex-1 flex-col p-4">
<div className="flex items-center justify-between gap-3 text-[12.5px] text-muted-foreground">
<span>{p.kind}</span>
{p.count && <span>{p.count}</span>}
</div>
<h3 className="mt-1 text-[16px] font-semibold leading-snug text-foreground">
<a href={p.href ?? '#'} className="rounded-sm hover:underline hover:underline-offset-4">
{p.title}
</a>
</h3>
<div className="mt-2.5 flex items-center justify-between gap-3">
<Formats formats={p.formats} />
{p.rating !== undefined && (
<span className="inline-flex items-center gap-1 text-[12.5px] tabular-nums text-muted-foreground" aria-label={fill(t.rating, { r: p.rating })}>
<Icon name="star" size={13} className="fill-current text-[color-mix(in_oklab,var(--warning)_85%,var(--foreground))]" />
{new Intl.NumberFormat(TAG[locale], { minimumFractionDigits: 1 }).format(p.rating)}
</span>
)}
</div>
<div className="mt-auto flex items-center justify-between gap-3 pt-4">
<span className="flex items-baseline gap-2">
<span className="text-[17px] font-semibold tabular-nums text-foreground">{money(p.price, currency, locale)}</span>
{p.was && <s className="text-[13px] tabular-nums text-muted-foreground">{money(p.was, currency, locale)}</s>}
</span>
<button type="button" onClick={onAdd} aria-pressed={bagged} className={cn('inline-flex min-h-9 items-center gap-1.5 rounded-lg px-3 text-[13px] font-medium transition-[background-color,color] duration-300 max-sm:min-h-11', focusRing, bagged ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground' : 'bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]')}>
<span key={String(bagged)} className={cn('inline-flex', bagged && '[animation:kitshop-pop_.4s_var(--ease-kit)]')}>
<Icon name={bagged ? 'check' : 'bag'} size={15} />
</span>
{bagged ? t.added : t.add}
</button>
</div>
</div>
</article>
);
}
function Storefront({ products = [], currency = 'EUR', t }: CreatorShopProps & { t: Texts }) {
const kinds = [...new Set(products.map((p) => p.kind))];
const [kind, setKind] = useState('all');
const [bag, setBag] = useState<string[]>([]);
const shown = products.filter((p) => kind === 'all' || p.kind === kind);
const [row, fade] = useScrollFade<HTMLDivElement>();
return (
<div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div ref={row} className={cn('kit-no-scrollbar -mx-6 overflow-x-auto px-6 sm:mx-0 sm:px-0', fade)}>
<Segmented label={t.filter} value={kind} onChange={setKind} options={[{ id: 'all', label: t.all }, ...kinds.map((k) => ({ id: k, label: k }))]} />
</div>
<p className="inline-flex items-center gap-2 text-[13.5px] text-muted-foreground">
<Icon name="download" size={15} />
{t.instant}
{bag.length > 0 && (
<span key={bag.length} className="ml-2 inline-flex items-center gap-1.5 rounded-full bg-primary px-2.5 py-0.5 text-[12.5px] font-semibold text-primary-foreground [animation:kitshop-pop_.4s_var(--ease-kit)]" aria-live="polite">
<Icon name="bag" size={13} />
{bag.length}
</span>
)}
</p>
</div>
<ul key={kind} className="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6">
{shown.map((p, i) => (
<li key={p.id} className="[animation:kitshop-in_.5s_var(--ease-kit)_both]" style={{ animationDelay: `${i * 60}ms` }}>
<ProductCard p={p} currency={currency} t={t} bagged={bag.includes(p.id)} onAdd={() => setBag((b) => (b.includes(p.id) ? b.filter((x) => x !== p.id) : [...b, p.id]))} />
</li>
))}
</ul>
</div>
);
}
/* ------------------------------------------------------------------ */
/* presets */
/* ------------------------------------------------------------------ */
function Presets({ product, presets = [], photo, currency = 'EUR', t }: CreatorShopProps & { t: Texts }) {
const locale = useLocale();
const [look, setLook] = useState(presets[0]?.id ?? '');
const [pos, setPos] = useState(50);
const [ref, inView] = useInView<HTMLDivElement>();
const frame = useRef<HTMLDivElement>(null);
const drag = useRef(false);
const chosen = presets.find((p) => p.id === look) ?? presets[0];
const at = (clientX: number) => {
const r = frame.current?.getBoundingClientRect();
if (!r) return;
setPos(Math.min(100, Math.max(0, round(((clientX - r.left) / r.width) * 100, 1))));
};
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)] lg:gap-12">
<div ref={ref}>
<div
ref={frame}
onPointerDown={(e) => {
drag.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
at(e.clientX);
}}
onPointerMove={(e) => drag.current && at(e.clientX)}
onPointerUp={() => (drag.current = false)}
onPointerCancel={() => (drag.current = false)}
className="relative aspect-[4/3] cursor-ew-resize touch-pan-y select-none overflow-hidden rounded-xl border border-border bg-muted shadow-[var(--shadow-md)]"
data-clip-ok
>
<div className={cn('absolute inset-0', fit)}>{photo}</div>
<div key={chosen?.id} className={cn('absolute inset-0 [animation:kitshop-swap_.5s_var(--ease-kit)]', fit)} style={{ filter: chosen?.filter, clipPath: `inset(0 0 0 ${pos}%)` }}>
{photo}
</div>
<span className="absolute left-3 top-3 rounded-full bg-black/55 px-2.5 py-1 text-[12px] font-medium text-white backdrop-blur">{t.before}</span>
<span className="absolute right-3 top-3 rounded-full bg-black/55 px-2.5 py-1 text-[12px] font-medium text-white backdrop-blur">
{t.after} · {chosen?.name}
</span>
<div aria-hidden className="absolute inset-y-0 w-0.5 -translate-x-1/2 bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.2)]" style={{ left: `${pos}%` }}>
<span className={cn('absolute left-1/2 top-1/2 flex size-11 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-white text-black shadow-[var(--shadow-lg)]', inView && pos === 50 && '[animation:kitshop-hint_1.6s_var(--ease-kit)_.6s_2]')}>
<Icon name="arrows-left-right" size={18} />
</span>
</div>
<input type="range" min={0} max={100} step={1} value={pos} onChange={(e) => setPos(Number(e.target.value))} aria-label={t.compare} className="absolute inset-0 size-full cursor-ew-resize opacity-0 focus-visible:opacity-0" />
</div>
<div role="radiogroup" aria-label={t.looks} onKeyDown={arrowKeys} className="kit-no-scrollbar mt-4 flex gap-3 overflow-x-auto pb-1">
{presets.map((p) => {
const on = p.id === chosen?.id;
return (
<button key={p.id} type="button" role="radio" aria-checked={on} tabIndex={on ? 0 : -1} onClick={() => setLook(p.id)} className={cn('group w-[92px] shrink-0 text-left sm:w-[104px]', focusRing, 'rounded-lg')}>
<span className={cn('relative block aspect-square overflow-hidden rounded-lg border-2 transition-[border-color,transform] duration-300', on ? 'border-primary' : 'border-transparent group-hover:-translate-y-0.5')} data-clip-ok>
<span className={cn('absolute inset-0 block', fit)} style={{ filter: p.filter }}>
{photo}
</span>
</span>
<span className={cn('mt-1.5 block truncate text-[13px] font-medium', on ? 'text-foreground' : 'text-muted-foreground')}>{p.name}</span>
</button>
);
})}
</div>
</div>
{product && (
<div className="lg:self-center">
<p className="text-[13px] font-medium text-muted-foreground">
{product.kind}
{product.count && ` · ${product.count}`}
</p>
<h3 className="mt-2 text-balance text-[28px] font-semibold leading-tight tracking-[-0.02em] text-foreground">{product.title}</h3>
{product.description && <p className="mt-3 text-pretty text-[15.5px] leading-relaxed text-muted-foreground">{product.description}</p>}
{product.points?.length ? (
<ul className="mt-5 grid gap-2">
{product.points.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-6 flex items-center gap-3">
<Formats formats={product.formats} />
</div>
<div className="mt-7 flex flex-wrap items-center gap-4 border-t border-border pt-6">
<span className="flex items-baseline gap-2">
<span className="text-[30px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground">{money(product.price, currency, locale)}</span>
{product.was && <s className="text-[15px] tabular-nums text-muted-foreground">{money(product.was, currency, locale)}</s>}
</span>
<Magnetic className="max-sm:flex max-sm:w-full"><Button href={product.href ?? '#'} icon="arrow-right" className="max-sm:w-full">
{fill(t.buy, { price: money(product.price, currency, locale) })}
</Button></Magnetic>
</div>
<p className="mt-3 inline-flex items-center gap-2 text-[13px] text-muted-foreground">
<Icon name="download" size={14} />
{t.instant}
</p>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* product */
/* ------------------------------------------------------------------ */
function FileTree({ files, depth = 0 }: { files: ShopFile[]; depth?: number }) {
const [ref, inView] = useInView<HTMLUListElement>();
return (
<ul ref={ref} className={cn(depth > 0 && 'ml-[11px] border-l border-border pl-4')}>
{files.map((f, i) => (
<li key={f.name} className="transition-[opacity,transform] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ opacity: inView ? 1 : 0, transform: inView ? 'none' : 'translateX(-6px)', transitionDelay: inView ? `${depth * 120 + i * 60}ms` : '0ms' }}>
<div className="flex min-h-9 items-center gap-2.5 text-[14px]">
<Icon name={f.children ? 'folder-open' : 'file'} size={16} className={f.children ? 'text-primary' : 'text-muted-foreground'} />
<span className={cn('min-w-0 flex-1 truncate', f.children ? 'font-medium text-foreground' : 'text-foreground/85')}>{f.name}</span>
{f.size && <span className="shrink-0 text-[12.5px] tabular-nums text-muted-foreground">{f.size}</span>}
</div>
{f.children && <FileTree files={f.children} depth={depth + 1} />}
</li>
))}
</ul>
);
}
const countFiles = (files: ShopFile[]): number => files.reduce((a, f) => a + (f.children ? countFiles(f.children) : 1), 0);
function Product({ product, files = [], licences = [], currency = 'EUR', onBuy, t }: CreatorShopProps & { t: Texts }) {
const locale = useLocale();
const [lic, setLic] = useState(licences[0]?.id ?? '');
const l = licences.find((x) => x.id === lic) ?? licences[0];
const price = l?.price ?? product?.price ?? 0;
const base = useId();
if (!product) return null;
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] lg:gap-12">
<div className="grid content-start gap-6 max-lg:contents">
<div className="relative aspect-[16/10] overflow-hidden rounded-xl border border-border bg-muted shadow-[var(--shadow-md)] max-lg:order-1" data-clip-ok>
<div className={cn('absolute inset-0 kit-rise', fit)}>{product.image}</div>
</div>
{files.length > 0 && (
<div className="kit-card p-5 sm:p-6 max-lg:order-3">
<div className="mb-3 flex items-baseline justify-between gap-3">
<h4 className="text-[15px] font-semibold text-foreground">{t.inside}</h4>
<span className="text-[13px] tabular-nums text-muted-foreground">{fill(t.files, { n: countFiles(files) })}</span>
</div>
<FileTree files={files} />
</div>
)}
</div>
<div className="max-lg:order-2 lg:sticky lg:top-8 lg:self-start">
<p className="text-[13px] font-medium text-muted-foreground">{product.kind}</p>
<h3 className="mt-2 text-balance text-[28px] font-semibold leading-tight tracking-[-0.02em] text-foreground">{product.title}</h3>
{product.description && <p className="mt-3 text-pretty text-[15.5px] leading-relaxed text-muted-foreground">{product.description}</p>}
<div className="mt-4">
<Formats formats={product.formats} />
</div>
{licences.length > 0 && (
<fieldset className="mt-7">
<legend className="mb-3 text-[14px] font-medium text-foreground">{t.licence}</legend>
<div className="grid gap-2.5">
{licences.map((x) => {
const on = x.id === l?.id;
return (
<label key={x.id} className={cn('flex cursor-pointer items-start gap-3 rounded-xl border p-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)_7%,var(--card))]' : 'border-border hover:border-border-strong')}>
<input type="radio" name={`${base}-l`} checked={on} onChange={() => setLic(x.id)} className="sr-only" />
<span aria-hidden className={cn('mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-full border-2 transition-colors', on ? 'border-primary' : 'border-border-strong')}>
<span className={cn('size-2 rounded-full bg-primary transition-transform duration-300', on ? 'scale-100' : 'scale-0')} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[15px] font-semibold text-foreground">{x.label}</span>
<span className="mt-0.5 block text-[13.5px] leading-snug text-muted-foreground">{x.detail}</span>
</span>
<span className="text-[15px] font-semibold tabular-nums text-foreground">{money(x.price, currency, locale)}</span>
</label>
);
})}
</div>
</fieldset>
)}
<Button type="button" icon="arrow-right" size="lg" className="mt-6 w-full" onClick={() => onBuy?.({ product: product.id, licence: l?.id, price })}>
{t.buyShort} <span className="tabular-nums">
<Money value={price} currency={currency} />
</span>
</Button>
<ul className="mt-4 grid gap-1.5 text-[13px] text-muted-foreground">
<li className="flex items-center gap-2">
<Icon name="lock" size={14} />
{t.secure}
</li>
<li className="flex items-center gap-2">
<Icon name="refresh" size={14} />
{t.lifetime}
</li>
</ul>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* bundle */
/* ------------------------------------------------------------------ */
function Bundle({ products = [], tiers = { 2: 10, 3: 20, 4: 30 }, currency = 'EUR', onBuy, t }: CreatorShopProps & { t: Texts }) {
const locale = useLocale();
const [picked, setPicked] = useState<string[]>(products.slice(0, 2).map((p) => p.id));
const steps = Object.keys(tiers).map(Number).sort((a, b) => a - b);
const n = picked.length;
const pct = steps.filter((s) => s <= n).reduce((a, s) => tiers[s], 0);
const next = steps.find((s) => s > n);
const sum = products.filter((p) => picked.includes(p.id)).reduce((a, p) => a + p.price, 0);
const total = Math.round(sum * (1 - pct / 100) * 100) / 100;
const top = steps[steps.length - 1] ?? 1;
const toggle = (id: string) => setPicked((v) => (v.includes(id) ? v.filter((x) => x !== id) : [...v, id]));
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)] lg:gap-12">
<fieldset>
<legend className="mb-4 flex w-full items-baseline justify-between text-[14px] font-medium text-foreground">
{t.choose}
<span className="text-[13px] tabular-nums text-muted-foreground">{fill(t.selected, { n })}</span>
</legend>
<Stagger as="ul" step={60} className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{products.map((p) => {
const on = picked.includes(p.id);
return (
<li key={p.id}>
<label className={cn('group flex cursor-pointer items-center gap-4 rounded-xl border p-3 transition-[border-color,background-color,transform] duration-300 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring', on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))]' : 'border-border bg-card hover:-translate-y-0.5 hover:border-border-strong')}>
<input type="checkbox" checked={on} onChange={() => toggle(p.id)} className="sr-only" />
<span className="relative size-16 shrink-0 overflow-hidden rounded-lg bg-muted" data-clip-ok>
<span className={cn('absolute inset-0 block', fit)}>{p.image}</span>
</span>
<span className="min-w-0 flex-1">
<span className="block text-[12.5px] text-muted-foreground">{p.kind}</span>
<span className="line-clamp-2 block text-[15px] font-semibold leading-snug text-foreground">{p.title}</span>
<span className="block text-[13.5px] tabular-nums text-muted-foreground">{money(p.price, currency, locale)}</span>
</span>
<span aria-hidden className={cn('flex size-6 shrink-0 items-center justify-center rounded-md border-2 transition-colors', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong')}>
{on && <Icon name="check" size={14} strokeWidth={2.4} className="[animation:kitshop-pop_.35s_var(--ease-kit)]" />}
</span>
</label>
</li>
);
})}
</Stagger>
</fieldset>
<aside className="lg:sticky lg:top-8 lg:self-start">
<div className="kit-card overflow-hidden">
<div className="p-6">
<div className="relative h-2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]" aria-hidden>
<div className="absolute inset-y-0 left-0 rounded-full bg-primary transition-[width] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: `${round(Math.min(1, n / top) * 100, 2)}%` }} />
{steps.map((s) => (
<span key={s} className={cn('absolute top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 transition-colors duration-500', s <= n ? 'border-primary bg-primary' : 'border-border-strong bg-card')} style={{ left: `${round((s / top) * 100, 2)}%` }} />
))}
</div>
<div className="relative mt-3 h-4 text-[11.5px] tabular-nums text-muted-foreground" aria-hidden>
{steps.map((s) => (
<span key={s} className={cn('absolute -translate-x-1/2 whitespace-nowrap', s === top && '-translate-x-full')} style={{ left: `${round((s / top) * 100, 2)}%` }}>
{locale === 'de' ? `${tiers[s]} %` : `${tiers[s]}%`}
</span>
))}
</div>
<p key={`${n}`} aria-live="polite" className="mt-4 text-[14px] font-medium text-foreground [animation:kitshop-swap_.4s_var(--ease-kit)]">
{next ? fill(t.nextTier, { n: next - n, p: tiers[next] }) : t.topTier}
</p>
</div>
<div className="border-t border-border bg-surface p-6">
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-[13px] text-muted-foreground">{t.bundleTotal}</p>
<p className="mt-1 text-[32px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground">
<Money value={total} currency={currency} />
</p>
</div>
{pct > 0 && (
<span key={pct} className="rounded-full bg-[color-mix(in_oklab,var(--success)_14%,transparent)] px-2.5 py-1 text-[13px] font-semibold text-success-foreground [animation:kitshop-pop_.4s_var(--ease-kit)]">
{fill(t.off, { p: pct })}
</span>
)}
</div>
{pct > 0 && (
<p className="mt-2 text-[13px] text-muted-foreground">
<s className="tabular-nums">{money(sum, currency, locale)}</s> · {fill(t.save, { v: money(Math.round((sum - total) * 100) / 100, currency, locale) })}
</p>
)}
<Button type="button" icon="arrow-right" className="mt-5 w-full" disabled={!n} onClick={() => onBuy?.({ products: picked, total })}>
{t.getBundle}
</Button>
</div>
</div>
</aside>
</div>
);
}
/* ------------------------------------------------------------------ */
/* pay-what-you-want */
/* ------------------------------------------------------------------ */
function PayWhatYouWant({ product, suggested = 8, max = 30, currency = 'EUR', onBuy, t }: CreatorShopProps & { t: Texts }) {
const locale = useLocale();
const [amount, setAmount] = useState(suggested);
const [email, setEmail] = useState('');
const [state, setState] = useState<'idle' | 'error' | 'done'>('idle');
const id = useId();
const pct = round((amount / max) * 100, 2);
const submit = (e: React.FormEvent) => {
e.preventDefault();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return setState('error');
onBuy?.({ product: product?.id, amount, email });
setState('done');
};
if (!product) return null;
return (
<div className="kit-card mx-auto grid max-w-[960px] grid-cols-1 overflow-hidden md:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
<div className="relative min-h-[260px] overflow-hidden bg-muted" data-clip-ok>
<div className={cn('absolute inset-0 transition-transform duration-[1200ms] ease-[var(--ease-kit)]', fit)} style={{ transform: `scale(${round(1 + amount / max / 12, 3)})` }}>
{product.image}
</div>
<span className="absolute left-4 top-4 rounded-full bg-background/90 px-3 py-1 text-[12.5px] font-semibold text-foreground backdrop-blur">{t.pwyw}</span>
</div>
<div className="p-6 sm:p-8">
<p className="text-[13px] font-medium text-muted-foreground">
{product.kind}
{product.count && ` · ${product.count}`}
</p>
<h3 className="mt-2 text-balance text-[24px] font-semibold leading-tight tracking-[-0.02em] text-foreground">{product.title}</h3>
{product.description && <p className="mt-2.5 text-pretty text-[15px] leading-relaxed text-muted-foreground">{product.description}</p>}
{state === 'done' ? (
<p role="status" className="mt-8 flex items-start gap-2.5 rounded-xl bg-[color-mix(in_oklab,var(--success)_12%,transparent)] p-4 text-[14.5px] font-medium text-success-foreground [animation:kitshop-swap_.45s_var(--ease-kit)]">
<Icon name="check-circle" size={18} className="mt-px" />
{fill(t.sent, { e: email })}
</p>
) : (
<form onSubmit={submit} noValidate className="mt-7">
<div className="flex items-end justify-between gap-4">
<label htmlFor={`${id}-a`} className="text-[14px] font-medium text-foreground">
{t.sliderLabel}
</label>
<span className="text-[34px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground" aria-live="polite">
{amount === 0 ? t.free : money(amount, currency, locale)}
</span>
</div>
<div className="relative mt-4">
<div aria-hidden className="pointer-events-none absolute inset-x-3 top-1/2 h-2 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]">
<div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
</div>
<input id={`${id}-a`} type="range" min={0} max={max} step={1} value={amount} onChange={(e) => setAmount(Number(e.target.value))} className="relative z-[1] h-11 w-full cursor-pointer appearance-none bg-transparent [&::-moz-range-thumb]:size-6 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:shadow-[0_0_0_2px_var(--primary),var(--shadow-md)] [&::-webkit-slider-thumb]:size-6 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_var(--primary),var(--shadow-md)] [&::-webkit-slider-thumb]:transition-transform active:[&::-webkit-slider-thumb]:scale-110 focus-visible:outline-none focus-visible:[&::-webkit-slider-thumb]:ring-4 focus-visible:[&::-webkit-slider-thumb]:ring-ring" />
</div>
<div className="mt-2 grid grid-cols-4 gap-2">
{[0, Math.round(suggested / 2), suggested, suggested * 2].filter((v, i, a) => a.indexOf(v) === i && v <= max).map((v) => (
<button key={v} type="button" onClick={() => setAmount(v)} aria-pressed={amount === v} aria-label={v === suggested ? `${money(v, currency, locale)}, ${t.suggested}` : undefined} className={cn('relative inline-flex min-h-10 items-center justify-center gap-1 rounded-lg border px-2 text-[13px] font-medium tabular-nums transition-colors max-sm:min-h-11', focusRing, amount === v ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] font-semibold text-foreground' : 'border-border bg-card text-muted-foreground hover:border-border-strong hover:text-foreground')}>
{v === 0 ? t.free : money(v, currency, locale)}
{v === suggested && <Icon name="star" size={12} className="fill-current opacity-80" />}
</button>
))}
</div>
<p className="mt-2 flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
<Icon name="star" size={12} className="fill-current" />
{t.suggested}
</p>
<p key={amount === 0 ? 'z' : 'p'} className="mt-4 min-h-5 text-[13.5px] text-muted-foreground [animation:kitshop-swap_.35s_var(--ease-kit)]">
{amount === 0 ? t.zero : t.thanks}
</p>
<label htmlFor={`${id}-e`} className="mb-2 mt-6 block text-[14px] font-medium text-foreground">
{t.email}
</label>
<input id={`${id}-e`} type="email" autoComplete="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}-err` : undefined} className={cn('h-11 w-full rounded-lg border bg-background px-3 text-[15px] text-foreground placeholder:text-subtle-foreground', focusRing, state === 'error' ? 'border-destructive' : 'border-border-strong')} />
{state === 'error' && (
<p id={`${id}-err`} className="mt-1.5 text-[13px] text-destructive">
{t.invalid}
</p>
)}
<Button type="submit" icon="arrow-right" className="mt-5 w-full">
{amount === 0 ? t.download : fill(t.downloadFor, { price: money(amount, currency, locale) })}
</Button>
</form>
)}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* CreatorShop */
/* ------------------------------------------------------------------ */
/**
* A shop for digital products by one creator.
* storefront products by kind, file formats, add to bag
* presets before and after slider, every look applied live
* product files inside, licence choice, instant download
* bundle pick products, the discount grows
* pay-what-you-want a slider for an optional price, the download by email
*/
export function CreatorShop(props: CreatorShopProps) {
const { variant = 'storefront', eyebrow, title, description, labels, className } = props;
const t = useTexts(TEXTS, labels);
const body = (() => {
switch (variant) {
case 'presets':
return <Presets {...props} t={t} />;
case 'product':
return <Product {...props} t={t} />;
case 'bundle':
return <Bundle {...props} t={t} />;
case 'pay-what-you-want':
return <PayWhatYouWant {...props} t={t} />;
default:
return <Storefront {...props} t={t} />;
}
})();
return (
<Section className={className} background={variant === 'pay-what-you-want' ? 'glow' : variant === 'bundle' ? 'surface' : 'default'}>
<ShopMotion />
{title ? <SectionHeader eyebrow={eyebrow} title={title} description={description} align={variant === 'pay-what-you-want' ? 'center' : 'left'} /> : null}
{body}
</Section>
);
}Häufige Fragen
Wie installiere ich Creator-Shop?
Mit aiw add @ai-website/creator-shop. Der Befehl legt die Datei components/ui/creator-shop.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 Creator-Shop?
5 Varianten, Schaufenster, Presets mit Vorher-Nachher, Digitales Produkt, Paket-Baukasten und Zahl, was du willst. Du wählst sie mit der Prop variant, zum Beispiel variant="presets".
Kann ich die Texte von Creator-Shop übersetzen?
Ja. Creator-Shop bringt 39 eigene Texte auf Deutsch und Englisch mit, etwa „Alle“, „Nach Art filtern“ und „Sofort-Download“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Creator-Shop einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Creator-Shop mit aiw_components und installieren sie mit aiw_add @ai-website/creator-shop. Nenn im Prompt die Variante, etwa variant="storefront", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Creator-Shop ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden CreatorShop-Code zum Kopieren.