Zum Inhalt springen
UI Kit
Accent

Produktkonfigurator

Ein Produkt zusammenstellen, Bild und Preis folgen. Schritt für Schritt, nebeneinander mit Ansichten, mit Gravur oder nach Maß.

4 VariantsEine von 4 Components in Product Pages

Jede Wahl sofort sichtbar

Ein Konfigurator verkauft das Produkt, das Kunden im Kopf haben. Bild und Preis folgen jeder Wahl, Menschen sehen also, was sie zusammenstellen und was es kostet, bevor es in den Warenkorb geht.

„Schritt für Schritt“ bietet eine Wahl pro Schritt mit segmentiertem Balken, die Leuchte ändert mit jeder Wahl Farbe und Größe, Chips unter dem Bild springen zu einem Schritt zurück und der letzte Schritt listet alles mit Preis. „Nebeneinander“ hält links eine große Uhr mit drei Ansichten und rechts alle Optionen, unter dem Preis öffnet sich eine Aufschlüsselung. „Gravur“ setzt den Text beim Tippen auf die Flasche, nennt Zeichen, die sich nicht gravieren lassen, und zählt bis zum Limit. „Nach Maß“ stellt ein Regal nach Breite, Höhe und Tiefe ein, die Zeichnung folgt maßstäblich und breite Regale wechseln zur Fertigung auf Bestellung.

Nimm Schritte für Produkte mit fünf oder mehr Entscheidungen, „Nebeneinander“ für wenige Optionen mit starkem Bild. Zeig bei jeder Option die Preisänderung, dann fühlen sich Upgrades fair an. Setz für jede Wahl einen Standard, dann startet das Bild vollständig.

Variants

Schritt für SchrittNeu in 1.2

Eine Wahl pro Schritt mit segmentiertem Balken. Die Leuchte wechselt bei jeder Wahl Farbe und Größe, Chips unter dem Bild springen zurück zu einem Schritt und der letzte Schritt listet alles mit Preis.

NebeneinanderNeu in 1.2

Eine große Uhr mit drei Ansichten links, die stehen bleibt, alle Optionen rechts. Zifferblatt und Gehäuse färben das Bild um, der Durchmesser ändert die Größe und unter dem Preis öffnet sich die Aufstellung.

GravurNeu in 1.2

Text, der beim Tippen auf der Flasche steht. Zeichen, die nicht gravierbar sind, werden genannt, der Zähler wird über dem Limit rot, Stil und Seite ändern das Bild an Ort und Stelle.

Nach MaßNeu in 1.2

Ein Regal nach Breite, Höhe und Tiefe, je mit Regler und Zahlenfeld. Die Zeichnung folgt maßstäblich mit Maßlinien, der Preis zählt mit und breite Regale wechseln auf Fertigung nach Bestellung.

Details

Was Produktkonfigurator mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Product Pages
Bereich
E-Commerce
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/product-configurator.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 35 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle ProductConfiguratorProps aus components/ui/product-configurator.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    ProductConfiguratorVariant

    stepssplitengravingdimensions

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • basePriceoptional
    number

    Price before options

  • motifoptional
    ProductMotif

    Drawn motif for steps, split and engraving

  • optionsoptional
    ConfigOption[]
  • defaultValuesoptional
    Record<string, string | string[]>

    Picked choices on load, option id to choice id(s)

  • engravingoptional
    { maxLength?: number; price?: number; placeholder?: string }

    engraving

  • dimensionsoptional
    { width: [number, number]; height: [number, number]; depth?: number[]; pricePerM2?: number; /** Made to order from this width on, delivery takes longer */ customFrom?: number; }

    dimensions: ranges in cm and the price per square metre of shelf front

  • deliveryoptional
    [string, string?]

    Delivery lines, the second one for made to order

  • renderPreviewoptional
    (values: Record<string, string | string[]>) => React.ReactNode

    Your own picture for a configuration

  • onAddoptional
    (values: Record<string, string | string[] | number>, price: number) => void
  • currencyoptional
    string

Eingebaute Texte

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

Alle 35 Texte anzeigen
stepOfEnglisch Step {n} of {total}Deutsch Schritt {n} von {total}
nextEnglisch NextDeutsch Weiter
backEnglisch BackDeutsch Zurück
reviewEnglisch ReviewDeutsch Übersicht
summaryEnglisch Your configurationDeutsch Deine Konfiguration
editEnglisch ChangeDeutsch Ändern
baseEnglisch Base priceDeutsch Grundpreis
includedEnglisch IncludedDeutsch Inklusive
totalEnglisch TotalDeutsch Gesamt
addEnglisch Add to cartDeutsch In den Warenkorb
addedEnglisch In the cartDeutsch Im Warenkorb
viewEnglisch ViewDeutsch Ansicht
viewsEnglisch Front|Side|DetailDeutsch Vorne|Seite|Detail
priceBreakdownEnglisch Price breakdownDeutsch Preis im Detail
engravingEnglisch EngravingDeutsch Gravur
engravingHintEnglisch Letters, numbers, spaces and . & - · up to {n} charactersDeutsch Buchstaben, Ziffern, Leerzeichen und . & - · bis zu {n} Zeichen
engravingPlaceholderEnglisch Your textDeutsch Dein Text
leftEnglisch {n} leftDeutsch noch {n}
tooLongEnglisch {n} characters too many. Shorten the text to fit on one line.Deutsch {n} Zeichen zu viel. Kürze den Text, damit er auf eine Zeile passt.
badCharsEnglisch We cannot engrave {chars}. Use letters, numbers, space, . & or -.Deutsch {chars} können wir nicht gravieren. Nimm Buchstaben, Ziffern, Leerzeichen, . & oder -.
emptyEnglisch Type the text for the engraving, or turn engraving off.Deutsch Gib den Text für die Gravur ein oder schalte die Gravur aus.
styleEnglisch StyleDeutsch Stil
stylesEnglisch Capitals|As typed|SpacedDeutsch Großbuchstaben|Wie getippt|Gesperrt
placementEnglisch PlacementDeutsch Position
placementsEnglisch Front|BackDeutsch Vorne|Hinten
engraveOnEnglisch Add engravingDeutsch Gravur hinzufügen
widthEnglisch WidthDeutsch Breite
heightEnglisch HeightDeutsch Höhe
depthEnglisch DepthDeutsch Tiefe
materialEnglisch MaterialDeutsch Material
outOfRangeEnglisch Choose a value between {min} and {max} cm.Deutsch Wähl einen Wert zwischen {min} und {max} cm.
shelvesEnglisch {n} shelvesDeutsch {n} Böden
madeToOrderEnglisch Made to orderDeutsch Auf Bestellung gefertigt
perfectEnglisch Fits your configurationDeutsch Passt zu deiner Konfiguration
cmEnglisch cmDeutsch cm

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Produktkonfigurator reagiert selbst auf die Tasten Enter und Leertaste.
  • Setzt aria-checked, aria-describedby, aria-invalid, aria-label, aria-live und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen img, radio, radiogroup und switch.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

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

Quellcode

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

Datei anzeigen, 844 Zeilen
components/ui/product-configurator.tsx
'use client';

import { useId, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { useShake } from '@/components/ui/input';
import { FlyUp, PRODUCT_TONES, ProductImage, ShopMotion, Swap, TweenPrice, formatPrice, type ProductMotif, type ProductTone } from '@/components/ui/product-image';

/*
 * Configure a product and watch it change.
 *
 *   steps       one choice per step with a stepper, the picture and the price follow every pick
 *   split       a large preview with three views on the left, all options and the price breakdown on the right
 *   engraving   text on the product, checked as you type, shown on the picture in place
 *   dimensions  a shelf by width, height and depth, drawn to scale with measure lines and a price per size
 *
 * Options carry a price difference. The preview uses the drawn product motifs,
 * pass your own pictures through `renderPreview` for real products.
 */

export type ProductConfiguratorVariant = 'steps' | 'split' | 'engraving' | 'dimensions';

export interface ConfigChoice {
  id: string;
  label: string;
  /** Extra price, 0 for included */
  price?: number;
  /** Swatch color, any CSS color or a PRODUCT_TONES key */
  color?: string;
  /** Tone for the drawn preview */
  tone?: ProductTone;
  note?: string;
  soldOut?: boolean;
}

export interface ConfigOption {
  id: string;
  label: string;
  /** swatch: color dots, chips: text boxes, cards: boxes with a note */
  kind?: 'swatch' | 'chips' | 'cards';
  /** What this option changes in the drawn preview */
  drives?: 'tone' | 'detail' | 'zoom' | 'none';
  choices: ConfigChoice[];
  /** Several picks allowed (extras) */
  multiple?: boolean;
}

export interface ProductConfiguratorProps {
  variant?: ProductConfiguratorVariant;
  eyebrow?: string;
  title?: string;
  description?: string;
  /** Price before options */
  basePrice?: number;
  /** Drawn motif for steps, split and engraving */
  motif?: ProductMotif;
  options?: ConfigOption[];
  /** Picked choices on load, option id to choice id(s) */
  defaultValues?: Record<string, string | string[]>;
  /** engraving */
  engraving?: { maxLength?: number; price?: number; placeholder?: string };
  /** dimensions: ranges in cm and the price per square metre of shelf front */
  dimensions?: {
    width: [number, number];
    height: [number, number];
    depth?: number[];
    pricePerM2?: number;
    /** Made to order from this width on, delivery takes longer */
    customFrom?: number;
  };
  /** Delivery lines, the second one for made to order */
  delivery?: [string, string?];
  /** Your own picture for a configuration */
  renderPreview?: (values: Record<string, string | string[]>) => React.ReactNode;
  onAdd?: (values: Record<string, string | string[] | number>, price: number) => void;
  currency?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    stepOf: 'Step {n} of {total}',
    next: 'Next',
    back: 'Back',
    review: 'Review',
    summary: 'Your configuration',
    edit: 'Change',
    base: 'Base price',
    included: 'Included',
    total: 'Total',
    add: 'Add to cart',
    added: 'In the cart',
    view: 'View',
    views: 'Front|Side|Detail',
    priceBreakdown: 'Price breakdown',
    engraving: 'Engraving',
    engravingHint: 'Letters, numbers, spaces and . & - · up to {n} characters',
    engravingPlaceholder: 'Your text',
    left: '{n} left',
    tooLong: '{n} characters too many. Shorten the text to fit on one line.',
    badChars: 'We cannot engrave {chars}. Use letters, numbers, space, . & or -.',
    empty: 'Type the text for the engraving, or turn engraving off.',
    style: 'Style',
    styles: 'Capitals|As typed|Spaced',
    placement: 'Placement',
    placements: 'Front|Back',
    engraveOn: 'Add engraving',
    width: 'Width',
    height: 'Height',
    depth: 'Depth',
    material: 'Material',
    outOfRange: 'Choose a value between {min} and {max} cm.',
    shelves: '{n} shelves',
    madeToOrder: 'Made to order',
    perfect: 'Fits your configuration',
    cm: 'cm',
  },
  de: {
    stepOf: 'Schritt {n} von {total}',
    next: 'Weiter',
    back: 'Zurück',
    review: 'Übersicht',
    summary: 'Deine Konfiguration',
    edit: 'Ändern',
    base: 'Grundpreis',
    included: 'Inklusive',
    total: 'Gesamt',
    add: 'In den Warenkorb',
    added: 'Im Warenkorb',
    view: 'Ansicht',
    views: 'Vorne|Seite|Detail',
    priceBreakdown: 'Preis im Detail',
    engraving: 'Gravur',
    engravingHint: 'Buchstaben, Ziffern, Leerzeichen und . & - · bis zu {n} Zeichen',
    engravingPlaceholder: 'Dein Text',
    left: 'noch {n}',
    tooLong: '{n} Zeichen zu viel. Kürze den Text, damit er auf eine Zeile passt.',
    badChars: '{chars} können wir nicht gravieren. Nimm Buchstaben, Ziffern, Leerzeichen, . & oder -.',
    empty: 'Gib den Text für die Gravur ein oder schalte die Gravur aus.',
    style: 'Stil',
    styles: 'Großbuchstaben|Wie getippt|Gesperrt',
    placement: 'Position',
    placements: 'Vorne|Hinten',
    engraveOn: 'Gravur hinzufügen',
    width: 'Breite',
    height: 'Höhe',
    depth: 'Tiefe',
    material: 'Material',
    outOfRange: 'Wähl einen Wert zwischen {min} und {max} cm.',
    shelves: '{n} Böden',
    madeToOrder: 'Auf Bestellung gefertigt',
    perfect: 'Passt zu deiner Konfiguration',
    cm: 'cm',
  },
};

type Texts = (typeof TEXTS)['en'];
type Values = Record<string, string | string[]>;

const fill = (s: string, vars: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''));
const currencyOf = (locale: Locale, currency?: string) => currency ?? (locale === 'de' ? 'EUR' : 'USD');
const colorOf = (c: ConfigChoice) => c.color ?? (c.tone ? PRODUCT_TONES[c.tone] : undefined);
const asList = (v?: string | string[]) => (Array.isArray(v) ? v : v ? [v] : []);

function startValues(options: ConfigOption[], defaults?: Values): Values {
  const v: Values = {};
  for (const o of options) v[o.id] = defaults?.[o.id] ?? (o.multiple ? [] : (o.choices.find((c) => !c.soldOut)?.id ?? ''));
  return v;
}

function priceOf(base: number, options: ConfigOption[], values: Values) {
  return round(base + options.reduce((a, o) => a + asList(values[o.id]).reduce((b, id) => b + (o.choices.find((c) => c.id === id)?.price ?? 0), 0), 0));
}

/** The drawn product for the current values */
function Preview({ motif = 'lamp', options, values, view = 'front', zoomBase = 1, text }: { motif?: ProductMotif; options: ConfigOption[]; values: Values; view?: 'front' | 'side' | 'close'; zoomBase?: number; text?: string }) {
  let tone: ProductTone | undefined;
  let detail: ProductTone | undefined;
  let zoom = zoomBase;
  for (const o of options) {
    const c = o.choices.find((x) => x.id === values[o.id]);
    if (!c) continue;
    if (o.drives === 'tone') tone = c.tone;
    if (o.drives === 'detail') detail = c.tone;
    if (o.drives === 'zoom') {
      const i = o.choices.indexOf(c);
      zoom = zoomBase * (0.8 + (i / Math.max(1, o.choices.length - 1)) * 0.25);
    }
  }
  return (
    <div key={`${tone}-${detail}-${view}`} className="mx-auto aspect-square h-full max-w-full animate-[kitshop-fade_0.45s_var(--ease-kit)]">
      <ProductImage motif={motif} tone={tone} detail={detail} view={view} zoom={round(zoom, 3)} text={text} backdrop="plain" />
    </div>
  );
}

/** One option as swatches, chips or cards, a radio group or a checkbox list */
function OptionField({ o, values, setValue, t, currency, compact, bare }: { o: ConfigOption; values: Values; setValue: (id: string, v: string | string[]) => void; t: Texts; currency: string; compact?: boolean; bare?: boolean }) {
  const locale = useLocale();
  const name = useId();
  const kind = o.kind ?? 'chips';
  const picked = asList(values[o.id]);
  const current = o.choices.find((c) => c.id === picked[0]);
  const price = (p?: number) => (p ? `+ ${formatPrice(locale, p, currency)}` : t.included);
  const on = (c: ConfigChoice) => (o.multiple ? setValue(o.id, picked.includes(c.id) ? picked.filter((x) => x !== c.id) : [...picked, c.id]) : setValue(o.id, c.id));
  return (
    <fieldset className="min-w-0">
      <legend data-clip-ok={bare || undefined} className={bare ? 'sr-only' : 'mb-3 flex w-full items-baseline justify-between gap-3 text-[14px] font-medium text-foreground'}>
        <span>
          {o.label}
          {kind === 'swatch' && current && <span className="ml-2 font-normal text-muted-foreground">{current.label}</span>}
        </span>
      </legend>
      <div className={cn(kind === 'swatch' ? 'flex flex-wrap gap-1' : kind === 'cards' ? cn('grid grid-cols-1 gap-2.5', !compact && 'sm:grid-cols-2') : 'flex flex-wrap gap-2')}>
        {o.choices.map((c) => {
          const checked = picked.includes(c.id);
          const input = <input type={o.multiple ? 'checkbox' : 'radio'} name={name} checked={checked} disabled={c.soldOut} onChange={() => on(c)} className="peer sr-only" />;
          if (kind === 'swatch')
            return (
              <label key={c.id} title={c.label} className={cn('group/sw relative grid size-11 place-items-center rounded-full', c.soldOut ? 'cursor-not-allowed opacity-40' : 'cursor-pointer')}>
                {input}
                <span className="sr-only">
                  {c.label}
                  {c.price ? `, ${price(c.price)}` : ''}
                </span>
                <span aria-hidden className={cn('absolute inset-0.5 rounded-full border-2 transition-[border-color,transform] duration-300 ease-[var(--ease-kit)] peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--ring)] peer-focus-visible:outline-solid', checked ? 'scale-100 border-foreground' : 'scale-90 border-transparent group-hover/sw:border-border-strong')} />
                <span aria-hidden className="size-8 rounded-full shadow-[inset_0_0_0_1px_rgb(0_0_0/0.12),inset_0_2px_3px_rgb(255_255_255/0.25)]" style={{ background: colorOf(c) }} />
              </label>
            );
          if (kind === 'cards')
            return (
              <label key={c.id} className={cn('flex cursor-pointer items-start gap-3 rounded-xl border p-3.5 transition-[border-color,background-color] duration-300 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid', checked ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]' : 'border-border-strong bg-card hover:border-foreground/40', c.soldOut && 'cursor-not-allowed opacity-50')}>
                {input}
                <span aria-hidden className={cn('mt-0.5 grid size-5 shrink-0 place-items-center border-2 transition-colors', o.multiple ? 'rounded-[6px]' : 'rounded-full', checked ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong')}>
                  {checked && (o.multiple ? <Icon name="check" size={12} strokeWidth={2.8} className="animate-[kitshop-pop_0.3s_var(--ease-kit)]" /> : <span className="size-2 animate-[kitshop-pop_0.3s_var(--ease-kit)] rounded-full bg-primary-foreground" />)}
                </span>
                <span className="min-w-0 flex-1">
                  <span className="flex flex-wrap items-baseline justify-between gap-x-2">
                    <span className="text-[14px] font-medium text-foreground">{c.label}</span>
                    <span className="text-[13px] tabular-nums text-muted-foreground">{price(c.price)}</span>
                  </span>
                  {c.note && <span className="mt-0.5 block text-[12px] text-muted-foreground">{c.note}</span>}
                </span>
              </label>
            );
          return (
            <label key={c.id} className={cn('relative flex min-h-11 cursor-pointer flex-col items-center justify-center rounded-lg border px-4 py-1.5 text-center transition-[border-color,background-color,color] duration-200 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid', checked ? 'border-foreground bg-foreground text-background' : 'border-border-strong bg-card text-foreground hover:border-foreground/50', c.soldOut && 'cursor-not-allowed border-dashed opacity-50')}>
              {input}
              <span className="text-[14px] font-medium">{c.label}</span>
              {c.price ? <span className={cn('text-[11px] tabular-nums', checked ? 'text-background/75' : 'text-muted-foreground')}>+ {formatPrice(locale, c.price, currency)}</span> : null}
            </label>
          );
        })}
      </div>
      {bare && kind === 'swatch' && current && (
        <p key={current.id} className="mt-3 animate-[kitshop-fade_0.3s_var(--ease-kit)] text-[14px] text-muted-foreground">
          {current.label}
          {current.price ? ` · + ${formatPrice(locale, current.price, currency)}` : ''}
        </p>
      )}
    </fieldset>
  );
}

function AddButton({ price, currency, t, onAdd, className }: { price: number; currency: string; t: Texts; onAdd: () => void; className?: string }) {
  const [added, setAdded] = useState(false);
  const [fly, setFly] = useState(0);
  return (
    <span className={cn('relative', className)}>
      <Button size="lg" onClick={() => { onAdd(); setAdded(true); setFly((f) => f + 1); }} className="w-full overflow-hidden">
        <Swap value={added} className="gap-2">
          <Icon name={added ? 'check' : 'bag'} size={17} />
          {added ? t.added : t.add}
          {!added && (
            <>
              <span aria-hidden className="h-4 w-px bg-primary-foreground/35" />
              <TweenPrice value={price} currency={currency} />
            </>
          )}
        </Swap>
      </Button>
      <FlyUp count={fly} className="-top-5" />
    </span>
  );
}

function Head({ props }: { props: ProductConfiguratorProps }) {
  if (!props.title && !props.eyebrow) return null;
  return (
    <div className="mb-8 max-w-[40rem] sm:mb-10">
      {props.eyebrow && <p className="mb-3 text-sm font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{props.eyebrow}</p>}
      {props.title && <h2 className="text-balance text-h2 font-semibold text-foreground">{props.title}</h2>}
      {props.description && <p className="mt-3 text-pretty text-[16px] text-muted-foreground">{props.description}</p>}
    </div>
  );
}

function Stage({ children, className }: { children: React.ReactNode; className?: string }) {
  return (
    <div data-clip-ok className={cn('relative overflow-hidden rounded-2xl bg-surface-2', className)}>
      <div aria-hidden className="absolute inset-0 bg-[radial-gradient(70%_60%_at_35%_20%,rgb(255_255_255/0.75),transparent_70%)] dark:bg-[radial-gradient(70%_60%_at_35%_20%,rgb(255_255_255/0.08),transparent_70%)]" />
      <div className="relative size-full">{children}</div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* steps                                                               */
/* ------------------------------------------------------------------ */

function Steps(props: ProductConfiguratorProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const options = props.options ?? [];
  const [values, setValues] = useState<Values>(() => startValues(options, props.defaultValues));
  const [step, setStep] = useState(0);
  const [dir, setDir] = useState<1 | -1>(1);
  const heading = useRef<HTMLHeadingElement>(null);
  const total = options.length + 1;
  const price = priceOf(props.basePrice ?? 0, options, values);
  const go = (i: number) => {
    setDir(i > step ? 1 : -1);
    setStep(i);
    requestAnimationFrame(() => heading.current?.focus());
  };
  const set = (id: string, v: string | string[]) => setValues((x) => ({ ...x, [id]: v }));
  const o = options[step];
  return (
    <section className={cn('py-14 sm:py-20 lg:py-24', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <Head props={props} />
        <div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-12">
          <div className="lg:sticky lg:top-8 lg:self-start">
            <Stage className="aspect-[4/3] sm:aspect-[16/10] lg:aspect-square">
              {props.renderPreview ? props.renderPreview(values) : <Preview motif={props.motif} options={options} values={values} />}
            </Stage>
            {/* The picks so far as chips under the picture */}
            <ul className="mt-3 flex flex-wrap gap-1.5" aria-label={t.summary}>
              {options.map((x, i) =>
                asList(values[x.id]).map((id) => {
                  const c = x.choices.find((y) => y.id === id);
                  return c ? (
                    <li key={x.id + id}>
                      <button type="button" onClick={() => go(i)} className="inline-flex h-8 items-center gap-1.5 rounded-full border border-border bg-card px-3 text-[12px] text-foreground transition-colors hover:border-border-strong max-sm:h-11">
                        {colorOf(c) && <span aria-hidden className="size-3 rounded-full" style={{ background: colorOf(c) }} />}
                        <span className="text-muted-foreground">{x.label}</span> {c.label}
                      </button>
                    </li>
                  ) : null;
                }),
              )}
            </ul>
          </div>
          <div className="min-w-0">
            <div className="mb-6">
              <p className="text-[13px] font-medium text-muted-foreground">{fill(t.stepOf, { n: step + 1, total })}</p>
              <div className="mt-2 flex gap-1.5" aria-hidden>
                {Array.from({ length: total }, (_, i) => (
                  <span key={i} className="h-1 flex-1 overflow-hidden rounded-full bg-track">
                    <span className={cn('block h-full origin-left rounded-full bg-primary transition-transform duration-500 ease-[var(--ease-kit)]', i <= step ? 'scale-x-100' : 'scale-x-0')} />
                  </span>
                ))}
              </div>
            </div>
            <div key={step} className={cn('min-h-[14rem]', dir === 1 ? 'animate-[kitshop-next_0.4s_var(--ease-kit)]' : 'animate-[kitshop-prev_0.4s_var(--ease-kit)]')}>
              <h3 ref={heading} tabIndex={-1} className="mb-5 text-[22px] font-semibold tracking-[-0.02em] text-foreground outline-none">
                {o ? o.label : t.summary}
              </h3>
              {o ? (
                <OptionField o={o} values={values} setValue={set} t={t} currency={currency} bare />
              ) : (
                <dl className="divide-y divide-border rounded-xl border border-border bg-card">
                  <div className="flex items-center justify-between gap-3 px-4 py-3 text-[14px]">
                    <dt className="text-muted-foreground">{t.base}</dt>
                    <dd className="tabular-nums text-foreground">{formatPrice(locale, props.basePrice ?? 0, currency)}</dd>
                  </div>
                  {options.map((x, i) => {
                    const cs = asList(values[x.id]).map((id) => x.choices.find((c) => c.id === id)).filter(Boolean) as ConfigChoice[];
                    return (
                      <div key={x.id} className="flex items-center justify-between gap-3 px-4 py-2 text-[14px]">
                        <dt className="min-w-0">
                          <span className="block text-[12px] text-muted-foreground">{x.label}</span>
                          <span className="block truncate text-foreground">{cs.length ? cs.map((c) => c.label).join(', ') : '·'}</span>
                        </dt>
                        <dd className="flex shrink-0 items-center gap-2">
                          <span className="text-[13px] tabular-nums text-muted-foreground">{cs.reduce((a, c) => a + (c.price ?? 0), 0) ? `+ ${formatPrice(locale, cs.reduce((a, c) => a + (c.price ?? 0), 0), currency)}` : t.included}</span>
                          <button type="button" onClick={() => go(i)} aria-label={`${t.edit}, ${x.label}`} className="grid size-9 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground max-sm:size-11">
                            <Icon name="edit" size={14} />
                          </button>
                        </dd>
                      </div>
                    );
                  })}
                </dl>
              )}
            </div>
            <div className="mt-8 flex items-center gap-3 border-t border-border pt-6">
              {step > 0 && (
                <Button variant="outline" onClick={() => go(step - 1)} aria-label={t.back} className="w-11 shrink-0 px-0 sm:w-auto sm:px-5">
                  <Icon name="arrow-left" size={17} />
                  <span className="max-sm:sr-only">{t.back}</span>
                </Button>
              )}
              {step < total - 1 ? (
                <>
                  <div className="ml-auto text-right">
                    <p className="text-[11px] text-muted-foreground">{t.total}</p>
                    <TweenPrice value={price} currency={currency} className="text-[18px] font-semibold text-foreground" />
                  </div>
                  <Button onClick={() => go(step + 1)} icon="arrow-right">
                    {step === total - 2 ? t.review : t.next}
                  </Button>
                </>
              ) : (
                <AddButton price={price} currency={currency} t={t} onAdd={() => props.onAdd?.(values, price)} className="flex-1" />
              )}
            </div>
            {props.delivery?.[0] && <p className="mt-3 flex items-center gap-2 text-[13px] text-muted-foreground"><Icon name="truck" size={15} />{props.delivery[0]}</p>}
          </div>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* split                                                               */
/* ------------------------------------------------------------------ */

function Split(props: ProductConfiguratorProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const options = props.options ?? [];
  const [values, setValues] = useState<Values>(() => startValues(options, props.defaultValues));
  const [view, setView] = useState<'front' | 'side' | 'close'>('front');
  const price = priceOf(props.basePrice ?? 0, options, values);
  const set = (id: string, v: string | string[]) => setValues((x) => ({ ...x, [id]: v }));
  const views = t.views.split('|');
  const extras = options.flatMap((o) => asList(values[o.id]).map((id) => ({ o, c: o.choices.find((c) => c.id === id)! })).filter((x) => x.c && x.c.price));
  return (
    <section className={cn('py-14 sm:py-20 lg:py-24', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)] lg:gap-14">
          <div className="lg:sticky lg:top-8 lg:self-start">
            <Stage className="aspect-square max-sm:aspect-[5/4]">
              {props.renderPreview ? props.renderPreview(values) : <Preview motif={props.motif} options={options} values={values} view={view} zoomBase={view === 'close' ? 1 : 0.95} />}
              <div role="radiogroup" aria-label={t.view} className="absolute bottom-3 left-1/2 flex -translate-x-1/2 gap-1 rounded-full border border-border bg-background/85 p-1 shadow-[var(--shadow-md)] backdrop-blur">
                {(['front', 'side', 'close'] as const).map((v, i) => (
                  <button key={v} type="button" role="radio" aria-checked={view === v} onClick={() => setView(v)} className={cn('h-9 rounded-full px-3.5 text-[13px] font-medium transition-colors max-sm:h-11', view === v ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground')}>
                    {views[i]}
                  </button>
                ))}
              </div>
            </Stage>
          </div>
          <div className="min-w-0">
            {props.eyebrow && <p className="mb-2 text-sm font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{props.eyebrow}</p>}
            {props.title && <h2 className="text-balance text-h2 font-semibold text-foreground">{props.title}</h2>}
            <TweenPrice value={price} currency={currency} className="mt-3 block text-[24px] font-semibold tracking-[-0.02em] text-foreground" />
            {props.description && <p className="mt-3 text-pretty text-[15px] text-muted-foreground">{props.description}</p>}
            <div className="mt-8 space-y-7 border-t border-border pt-7">
              {options.map((o) => (
                <OptionField key={o.id} o={o} values={values} setValue={set} t={t} currency={currency} compact />
              ))}
            </div>
            <details className="group/pb mt-8 rounded-xl border border-border bg-surface">
              <summary className="flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 px-4 text-[14px] font-medium text-foreground [&::-webkit-details-marker]:hidden">
                {t.priceBreakdown}
                <Icon name="chevron-down" size={15} className="text-muted-foreground transition-transform group-open/pb:rotate-180 motion-reduce:transition-none" />
              </summary>
              <dl className="space-y-2 border-t border-border px-4 py-3 text-[13px]">
                <div className="flex justify-between gap-3">
                  <dt className="text-muted-foreground">{t.base}</dt>
                  <dd className="tabular-nums text-foreground">{formatPrice(locale, props.basePrice ?? 0, currency)}</dd>
                </div>
                {extras.map(({ o, c }) => (
                  <div key={o.id + c.id} className="flex animate-[kitshop-in_0.3s_var(--ease-kit)] justify-between gap-3">
                    <dt className="text-muted-foreground">
                      {o.label} · {c.label}
                    </dt>
                    <dd className="tabular-nums text-foreground">+ {formatPrice(locale, c.price!, currency)}</dd>
                  </div>
                ))}
                <div className="flex justify-between gap-3 border-t border-border pt-2 font-semibold">
                  <dt className="text-foreground">{t.total}</dt>
                  <dd className="tabular-nums text-foreground">{formatPrice(locale, price, currency)}</dd>
                </div>
              </dl>
            </details>
            <AddButton price={price} currency={currency} t={t} onAdd={() => props.onAdd?.(values, price)} className="mt-6 block" />
            {props.delivery?.[0] && <p className="mt-3 flex items-center justify-center gap-2 text-[13px] text-muted-foreground"><Icon name="truck" size={15} />{props.delivery[0]}</p>}
          </div>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* engraving                                                           */
/* ------------------------------------------------------------------ */

const ALLOWED = /[A-Za-z0-9ÄÖÜäöüß .&-]/;

function Engraving(props: ProductConfiguratorProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const options = props.options ?? [];
  const max = props.engraving?.maxLength ?? 14;
  const fee = props.engraving?.price ?? 0;
  const [values, setValues] = useState<Values>(() => startValues(options, props.defaultValues));
  const [on, setOn] = useState(true);
  const [text, setText] = useState(props.engraving?.placeholder ?? '');
  const [style, setStyle] = useState(0);
  const [place, setPlace] = useState(0);
  const [tried, setTried] = useState(false);
  const field = useRef<HTMLDivElement>(null);
  const id = useId();
  const bad = [...new Set([...text].filter((ch) => !ALLOWED.test(ch)))];
  const over = text.length - max;
  const error = !on ? '' : bad.length ? fill(t.badChars, { chars: bad.join(' ') }) : over > 0 ? fill(t.tooLong, { n: over }) : tried && !text.trim() ? t.empty : '';
  useShake(field, !!error);
  const price = round(priceOf(props.basePrice ?? 0, options, values) + (on ? fee : 0));
  const shown = style === 0 ? text.toUpperCase() : text;
  const set = (k: string, v: string | string[]) => setValues((x) => ({ ...x, [k]: v }));
  const styles = t.styles.split('|');
  const places = t.placements.split('|');
  const tone = options.find((o) => o.drives === 'tone');
  const toneChoice = tone?.choices.find((c) => c.id === values[tone.id]);
  return (
    <section className={cn('py-14 sm:py-20 lg:py-24', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <Head props={props} />
        <div className="grid grid-cols-1 gap-8 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-14">
          <div className="md:sticky md:top-8 md:self-start">
            <Stage className="aspect-square max-sm:aspect-[5/4]">
              <div className="relative mx-auto aspect-square h-full max-w-full [container-type:inline-size]">
                <Preview motif={props.motif ?? 'bottle'} options={options} values={values} view={place === 1 ? 'side' : 'front'} />
                {on && shown && !bad.length && (
                  <span
                    aria-hidden
                    key={`${style}-${place}`}
                    className="absolute left-[51.8%] top-[58%] origin-center -translate-x-1/2 -translate-y-1/2 -rotate-90 whitespace-nowrap font-semibold animate-[kitshop-fade_0.4s_var(--ease-kit)]"
                    style={{
                      fontSize: 'clamp(9px, 2.6cqw, 20px)',
                      letterSpacing: style === 2 ? '0.32em' : '0.08em',
                      color: 'color-mix(in oklab, white 78%, transparent)',
                      textShadow: '0 -1px 0 rgb(0 0 0 / 0.35), 0 1px 0 rgb(255 255 255 / 0.25)',
                      left: place === 1 ? '48.2%' : undefined,
                    }}
                  >
                    {shown.slice(0, max)}
                  </span>
                )}
              </div>
            </Stage>
            <p className="mt-3 text-center text-[13px] text-muted-foreground">
              {toneChoice?.label} · {places[place]}
            </p>
          </div>
          <div className="min-w-0 space-y-7">
            {options.map((o) => (
              <OptionField key={o.id} o={o} values={values} setValue={set} t={t} currency={currency} compact />
            ))}
            <div className="border-t border-border pt-7">
              <label className="flex cursor-pointer items-center justify-between gap-4">
                <span>
                  <span className="block text-[15px] font-semibold text-foreground">{t.engraveOn}</span>
                  <span className="block text-[13px] text-muted-foreground">{fee ? `+ ${formatPrice(locale, fee, currency)}` : t.included}</span>
                </span>
                <input type="checkbox" role="switch" checked={on} onChange={(e) => setOn(e.target.checked)} className="peer sr-only" />
                <span aria-hidden className={cn('relative h-7 w-12 shrink-0 rounded-full transition-colors duration-300 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--ring)] peer-focus-visible:outline-solid', on ? 'bg-primary' : 'bg-track')}>
                  <span className={cn('absolute left-0.5 top-0.5 size-6 rounded-full bg-white shadow-[var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)]', on && 'translate-x-5')} />
                </span>
              </label>
              <div className={cn('grid transition-[grid-template-rows,opacity] duration-400 ease-[var(--ease-kit)] motion-reduce:transition-none', on ? 'grid-rows-[1fr] opacity-100' : 'invisible grid-rows-[0fr] opacity-0')}>
                <div className="min-h-0 overflow-hidden px-0.5">
                  <div className="space-y-6 pb-1 pt-5">
                    <div ref={field}>
                      <div className="mb-1.5 flex items-baseline justify-between gap-3">
                        <label htmlFor={id} className="text-[13px] font-medium text-foreground">
                          {t.engraving}
                        </label>
                        <span className={cn('text-[12px] tabular-nums', over > 0 ? 'text-destructive' : 'text-muted-foreground')} aria-hidden>
                          {fill(t.left, { n: Math.max(0, max - text.length) })}
                        </span>
                      </div>
                      <input
                        id={id}
                        value={text}
                        onChange={(e) => setText(e.target.value)}
                        onBlur={() => setTried(true)}
                        placeholder={t.engravingPlaceholder}
                        autoComplete="off"
                        aria-invalid={!!error || undefined}
                        aria-describedby={`${id}-h`}
                        className="h-12 w-full rounded-lg border border-input bg-background px-4 text-base tracking-[0.04em] text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] aria-[invalid=true]:border-[color-mix(in_oklab,var(--destructive)_75%,var(--border))] sm:text-[15px]"
                      />
                      <p id={`${id}-h`} aria-live="polite" className={cn('mt-1.5 text-[12px]', error ? 'text-destructive' : 'text-muted-foreground')}>
                        {error || fill(t.engravingHint, { n: max })}
                      </p>
                    </div>
                    <div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
                      <fieldset>
                        <legend className="mb-2.5 text-[13px] font-medium text-foreground">{t.style}</legend>
                        <div className="flex flex-wrap gap-2">
                          {styles.map((s, i) => (
                            <label key={s} className={cn('flex h-11 cursor-pointer items-center rounded-lg border px-3.5 text-[13px] font-medium transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid', style === i ? 'border-foreground bg-foreground text-background' : 'border-border-strong text-foreground hover:border-foreground/50')}>
                              <input type="radio" name={`${id}-s`} checked={style === i} onChange={() => setStyle(i)} className="sr-only" />
                              {s}
                            </label>
                          ))}
                        </div>
                      </fieldset>
                      <fieldset>
                        <legend className="mb-2.5 text-[13px] font-medium text-foreground">{t.placement}</legend>
                        <div className="flex flex-wrap gap-2">
                          {places.map((s, i) => (
                            <label key={s} className={cn('flex h-11 cursor-pointer items-center rounded-lg border px-3.5 text-[13px] font-medium transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid', place === i ? 'border-foreground bg-foreground text-background' : 'border-border-strong text-foreground hover:border-foreground/50')}>
                              <input type="radio" name={`${id}-p`} checked={place === i} onChange={() => setPlace(i)} className="sr-only" />
                              {s}
                            </label>
                          ))}
                        </div>
                      </fieldset>
                    </div>
                  </div>
                </div>
              </div>
            </div>
            <AddButton
              price={price}
              currency={currency}
              t={t}
              onAdd={() => {
                setTried(true);
                if (on && (!text.trim() || error)) return;
                props.onAdd?.({ ...values, engraving: on ? shown : '' }, price);
              }}
              className="block"
            />
          </div>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* dimensions                                                          */
/* ------------------------------------------------------------------ */

function NumberRange({ label, value, min, max, step, onChange, t }: { label: string; value: number; min: number; max: number; step: number; onChange: (v: number) => void; t: Texts }) {
  const id = useId();
  const [draft, setDraft] = useState<string | null>(null);
  const shownDraft = draft ?? String(value);
  const n = Number(shownDraft.replace(',', '.'));
  const bad = draft !== null && (!Number.isFinite(n) || n < min || n > max);
  const wrap = useRef<HTMLDivElement>(null);
  useShake(wrap, bad);
  const commit = () => {
    if (draft === null) return;
    if (!bad) {
      onChange(Math.round(n / step) * step);
      setDraft(null);
    }
  };
  return (
    <div ref={wrap}>
      <div className="mb-2 flex items-center justify-between gap-3">
        <label htmlFor={id} className="text-[14px] font-medium text-foreground">
          {label}
        </label>
        <span className="relative">
          <input
            aria-label={`${label}, ${t.cm}`}
            inputMode="numeric"
            value={shownDraft}
            onChange={(e) => setDraft(e.target.value)}
            onBlur={commit}
            onKeyDown={(e) => e.key === 'Enter' && commit()}
            aria-invalid={bad || undefined}
            aria-describedby={bad ? `${id}-e` : undefined}
            className="h-10 w-[5.5rem] rounded-lg border border-input bg-background pl-3 pr-9 text-right text-base tabular-nums text-foreground outline-none transition-[border-color,box-shadow] focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] aria-[invalid=true]:border-destructive max-sm:h-11 sm:text-[14px]"
          />
          <span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-[13px] text-muted-foreground">{t.cm}</span>
        </span>
      </div>
      <input id={id} type="range" min={min} max={max} step={step} value={value} onChange={(e) => { setDraft(null); onChange(Number(e.target.value)); }} className="h-11 w-full cursor-pointer accent-[var(--primary)]" aria-valuetext={`${value} ${t.cm}`} />
      <div className="flex justify-between text-[11px] tabular-nums text-subtle-foreground" aria-hidden>
        <span>{min}</span>
        <span>{max}</span>
      </div>
      {bad && <p id={`${id}-e`} className="mt-1 animate-[kitshop-in_0.3s_var(--ease-kit)] text-[12px] text-destructive">{fill(t.outOfRange, { min, max })}</p>}
    </div>
  );
}

const WOODS: Record<string, { face: string; edge: string }> = {
  oak: { face: 'oklch(0.78 0.06 75)', edge: 'oklch(0.68 0.07 70)' },
  walnut: { face: 'oklch(0.5 0.06 50)', edge: 'oklch(0.42 0.06 45)' },
  white: { face: 'oklch(0.95 0.005 90)', edge: 'oklch(0.86 0.01 90)' },
  black: { face: 'oklch(0.32 0.01 260)', edge: 'oklch(0.25 0.01 260)' },
};

function Shelf({ width, height, depth, wood, t }: { width: number; height: number; depth: number; wood: string; t: Texts }) {
  const c = WOODS[wood] ?? WOODS.oak;
  const wall = 'w' + useId().replace(/[^a-zA-Z0-9]/g, '');
  // Drawn to scale: 1.45 units per cm, room for the measure labels left and on top
  const S = 1.45;
  const w = round(width * S);
  const h = round(height * S);
  const d = round(depth * S * 0.35);
  const x = round(60 + (390 - w - d) / 2);
  const y = round(380 - h);
  const rows = Math.max(2, Math.round(height / 38));
  const cols = Math.max(1, Math.round(width / 60));
  const board = 4;
  return (
    <svg viewBox="0 0 480 440" className="size-full" role="img" aria-label={`${t.width} ${width} ${t.cm}, ${t.height} ${height} ${t.cm}, ${t.depth} ${depth} ${t.cm}`}>
      <defs>
        <pattern id={wall} width="24" height="24" patternUnits="userSpaceOnUse">
          <path d="M24 0H0V24" fill="none" stroke="var(--border)" strokeWidth="0.6" />
        </pattern>
      </defs>
      <rect width="480" height="440" fill={`url(#${wall})`} opacity="0.5" />
      <path d="M0 380H480" stroke="var(--border-strong)" />
      <g>
        <path d={`M${x + w} ${y}l${d} ${-d}v${h}l${-d} ${d}Z`} style={{ fill: c.edge }} />
        <path d={`M${x} ${y}l${d} ${-d}h${w}l${-d} ${d}Z`} style={{ fill: c.face, filter: 'brightness(1.06)' }} />
        <rect x={x} y={y} width={w} height={h} style={{ fill: 'color-mix(in oklab, var(--foreground) 10%, var(--surface-2))' }} />
        {Array.from({ length: rows + 1 }, (_, i) => (
          <rect key={`r${i}`} x={x} y={round(y + (i * (h - board)) / rows)} width={w} height={board} style={{ fill: c.face }} />
        ))}
        {Array.from({ length: cols + 1 }, (_, i) => (
          <rect key={`c${i}`} x={round(x + (i * (w - board)) / cols)} y={y} width={board} height={h} style={{ fill: c.edge }} />
        ))}
      </g>
      {/* Measure lines */}
      <g stroke="var(--muted-foreground)" strokeWidth="1" fill="none">
        <path d={`M${x} ${y - 24}H${x + w}M${x} ${y - 30}v12M${x + w} ${y - 30}v12`} />
        <path d={`M${x - 20} ${y}V${y + h}M${x - 26} ${y}h12M${x - 26} ${y + h}h12`} />
      </g>
      <g fontSize="15" fontWeight="600" textAnchor="middle" style={{ fill: 'var(--foreground)' }}>
        <rect x={round(x + w / 2 - 36)} y={y - 37} width="72" height="26" rx="6" style={{ fill: 'var(--background)' }} />
        <text x={round(x + w / 2)} y={y - 19}>{width} cm</text>
        <rect x={x - 56} y={round(y + h / 2 - 13)} width="72" height="26" rx="6" style={{ fill: 'var(--background)' }} />
        <text x={x - 20} y={round(y + h / 2 + 5)}>{height} cm</text>
      </g>
    </svg>
  );
}

function Dimensions(props: ProductConfiguratorProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const dim = props.dimensions ?? { width: [60, 240], height: [80, 220], depth: [30, 40], pricePerM2: 290, customFrom: 200 };
  const options = props.options ?? [];
  const [values, setValues] = useState<Values>(() => startValues(options, props.defaultValues));
  const [width, setWidth] = useState(Math.round((dim.width[0] + dim.width[1]) / 2 / 10) * 10);
  const [height, setHeight] = useState(Math.round((dim.height[0] + dim.height[1]) / 2 / 10) * 10);
  const depths = dim.depth ?? [30, 40];
  const [depth, setDepth] = useState(depths[0]);
  const id = useId();
  const area = (width / 100) * (height / 100);
  const base = round(area * (dim.pricePerM2 ?? 290) * (depth > depths[0] ? 1.15 : 1));
  const price = priceOf(base, options, values);
  const custom = dim.customFrom !== undefined && width >= dim.customFrom;
  const material = options.find((o) => o.id === 'material' || o.drives === 'tone');
  const wood = (material && (values[material.id] as string)) || 'oak';
  const rows = Math.max(2, Math.round(height / 38));
  const set = (k: string, v: string | string[]) => setValues((x) => ({ ...x, [k]: v }));
  return (
    <section className={cn('py-14 sm:py-20 lg:py-24', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <Head props={props} />
        <div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] lg:gap-14">
          <div className="lg:sticky lg:top-8 lg:self-start">
            <div data-clip-ok className="aspect-[12/11] overflow-hidden rounded-2xl border border-border bg-surface-2">
              <Shelf width={width} height={height} depth={depth} wood={wood} t={t} />
            </div>
            <p className="mt-3 text-center text-[13px] tabular-nums text-muted-foreground">
              {width} × {height} × {depth} {t.cm} · {fill(t.shelves, { n: rows + 1 })}
            </p>
          </div>
          <div className="min-w-0 space-y-7">
            <NumberRange label={t.width} value={width} min={dim.width[0]} max={dim.width[1]} step={10} onChange={setWidth} t={t} />
            <NumberRange label={t.height} value={height} min={dim.height[0]} max={dim.height[1]} step={10} onChange={setHeight} t={t} />
            <fieldset>
              <legend className="mb-3 text-[14px] font-medium text-foreground">{t.depth}</legend>
              <div className="flex gap-2">
                {depths.map((d) => (
                  <label key={d} className={cn('flex h-11 cursor-pointer items-center rounded-lg border px-4 text-[14px] font-medium tabular-nums transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid', depth === d ? 'border-foreground bg-foreground text-background' : 'border-border-strong text-foreground hover:border-foreground/50')}>
                    <input type="radio" name={`${id}-d`} checked={depth === d} onChange={() => setDepth(d)} className="sr-only" />
                    {d} {t.cm}
                  </label>
                ))}
              </div>
            </fieldset>
            {options.map((o) => (
              <OptionField key={o.id} o={o} values={values} setValue={set} t={t} currency={currency} compact />
            ))}
            <div className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-md)]">
              <div className="flex items-baseline justify-between gap-3">
                <span className="text-[14px] text-muted-foreground">{t.total}</span>
                <TweenPrice value={price} currency={currency} className="text-[24px] font-semibold tracking-[-0.02em] text-foreground" />
              </div>
              <p className="mt-2 flex items-center gap-2 text-[13px]" aria-live="polite">
                <Icon name={custom ? 'hourglass' : 'truck'} size={15} className={custom ? 'text-muted-foreground' : 'text-success'} />
                <span key={String(custom)} className="animate-[kitshop-fade_0.35s_var(--ease-kit)] text-foreground">
                  {custom ? `${t.madeToOrder} · ${props.delivery?.[1] ?? ''}` : props.delivery?.[0]}
                </span>
              </p>
              <AddButton price={price} currency={currency} t={t} onAdd={() => props.onAdd?.({ ...values, width, height, depth }, price)} className="mt-5 block" />
              <p className="mt-2 text-center text-[12px] text-muted-foreground">{formatPrice(locale, round(price / Math.max(0.01, area)), currency)} / m²</p>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

/**
 * A product configurator: step by step, side by side with three views,
 * with an engraving shown in place or by dimensions drawn to scale.
 * The price follows every choice.
 */
export function ProductConfigurator(props: ProductConfiguratorProps) {
  const locale = useLocale();
  const t = useTexts(TEXTS, props.labels);
  const currency = currencyOf(locale, props.currency);
  const { variant = 'steps' } = props;
  if (variant === 'split') return <Split {...props} t={t} currency={currency} />;
  if (variant === 'engraving') return <Engraving {...props} t={t} currency={currency} />;
  if (variant === 'dimensions') return <Dimensions {...props} t={t} currency={currency} />;
  return <Steps {...props} t={t} currency={currency} />;
}

Häufige Fragen

Wie installiere ich Produktkonfigurator?

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

Welche Varianten hat Produktkonfigurator?

4 Varianten, Schritt für Schritt, Nebeneinander, Gravur und Nach Maß. Du wählst sie mit der Prop variant, zum Beispiel variant="split".

Kann ich die Texte von Produktkonfigurator übersetzen?

Ja. Produktkonfigurator bringt 35 eigene Texte auf Deutsch und Englisch mit, etwa „Weiter“, „Zurück“ und „Übersicht“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Produktkonfigurator einbauen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert