Zum Inhalt springen
UI Kit
Accent

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

5 Variants

Variants

Schaufenster

Produkte nach Art hinter einem gleitenden Filter, Dateiformate und Bewertung auf jeder Karte. Licht folgt dem Zeiger, das Bild zoomt, der Warenkorb zählt mit.

Im Studio öffnen

Presets mit Vorher-Nachher

Ein Foto mit Regler zwischen vorher und nachher. Jeder Look darunter wird live angewendet, der Griff zeigt einmal, dass er sich bewegt. Mit Tastatur bedienbar.

Im Studio öffnen

Digitales Produkt

Ein Produkt mit seinem Inhalt als Dateibaum, der sich beim Scrollen aufbaut. Die Lizenz bestimmt den Preis, der Preis gleitet, Download direkt nach dem Bezahlen.

Im Studio öffnen

Paket-Baukasten

Produkte wählen, der Rabatt wächst mit jedem. Ein Balken mit Stufen zeigt den Weg zum nächsten Rabatt, die Summe gleitet nach unten.

Im Studio öffnen

Zahl, was du willst

Ein kostenloses Produkt mit freiwilligem Preis. Regler und schnelle Beträge, eine kurze Zeile antwortet auf die Wahl, der Download kommt per E-Mail. Das Bild rückt mit dem Preis näher.

Im Studio öffnen

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
Bringt mit
utilskitbuttoniconmotionsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 39 eigene Texte
In Sammlungen
Creator-Seite

Props

Die Schnittstelle CreatorShopProps aus components/ui/creator-shop.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    CreatorShopVariant

    storefrontpresetsproductbundlepay-what-you-want

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • productsoptional
    ShopProduct[]
  • productoptional
    ShopProduct & { description?: string; points?: string[] }

    presets, product, pay-what-you-want: the product shown

  • presetsoptional
    ShopPreset[]
  • photooptional
    React.ReactNode

    presets: the photo the looks are applied to

  • filesoptional
    ShopFile[]
  • licencesoptional
    ShopLicence[]
  • tiersoptional
    Record<number, number>

    bundle: discount in percent by number of products, e.g. { 2: 10, 3: 20, 4: 30 }

  • suggestedoptional
    number

    pay-what-you-want: suggested amounts and the top of the slider

  • maxoptional
    number
  • currencyoptional
    string
  • onBuyoptional
    (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
allEnglisch AllDeutsch Alle
filterEnglisch Filter by kindDeutsch Nach Art filtern
instantEnglisch Instant downloadDeutsch Sofort-Download
addEnglisch Add to bagDeutsch In den Korb
addedEnglisch In your bagDeutsch Im Warenkorb
beforeEnglisch BeforeDeutsch Vorher
afterEnglisch AfterDeutsch Nachher
compareEnglisch Drag to compare before and afterDeutsch Ziehen, um vorher und nachher zu vergleichen
looksEnglisch LooksDeutsch Looks
buyEnglisch Buy for {price}Deutsch Für {price} kaufen
buyShortEnglisch Buy forDeutsch Kaufen für
insideEnglisch What is insideDeutsch Was drin ist
filesEnglisch {n} filesDeutsch {n} Dateien
licenceEnglisch LicenceDeutsch Lizenz
secureEnglisch Paid securely, files arrive in secondsDeutsch Sicher bezahlt, Dateien kommen in Sekunden
lifetimeEnglisch Updates for lifeDeutsch Updates für immer
chooseEnglisch Choose productsDeutsch Produkte wählen
selectedEnglisch {n} selectedDeutsch {n} gewählt
bundleTotalEnglisch Bundle priceDeutsch Preis fürs Paket
saveEnglisch You save {v}Deutsch Du sparst {v}
nextTierEnglisch Add {n} more for {p}% offDeutsch Noch {n} dazu für {p} % Rabatt
topTierEnglisch Biggest discount reachedDeutsch Höchster Rabatt erreicht
getBundleEnglisch Get the bundleDeutsch Paket holen
offEnglisch {p}% offDeutsch {p} % Rabatt
pwywEnglisch Pay what you wantDeutsch Zahl, was du willst
freeEnglisch FreeDeutsch Kostenlos
nameEnglisch PriceDeutsch Preis
sliderLabelEnglisch Your priceDeutsch Dein Preis
thanksEnglisch Thank you, that helps me make the next one.Deutsch Danke, das hilft mir beim nächsten.
zeroEnglisch Free is fine. Enjoy it.Deutsch Kostenlos ist völlig in Ordnung. Viel Freude damit.
emailEnglisch Email for the downloadDeutsch E-Mail für den Download
emailPhEnglisch you@example.comDeutsch du@beispiel.de
downloadEnglisch Send me the downloadDeutsch Download schicken
downloadForEnglisch Pay {price} and downloadDeutsch {price} zahlen und herunterladen
invalidEnglisch Please enter an email address like name@example.com.Deutsch Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.
sentEnglisch The download link is on its way to {e}.Deutsch Der Download-Link ist unterwegs an {e}.
suggestedEnglisch SuggestedDeutsch Vorschlag
salesEnglisch {n} soldDeutsch {n} verkauft
ratingEnglisch {r} of 5 starsDeutsch {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
components/ui/creator-shop.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert