Produktkonfigurator
Ein Produkt zusammenstellen, Bild und Preis folgen. Schritt für Schritt, nebeneinander mit Ansichten, mit Gravur oder nach Maß.
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
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
- Bringt mit
- utilskitbuttoniconproduct-imageinput
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 35 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ProductConfiguratorProps aus components/ui/product-configurator.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalProductConfiguratorVariantstepssplitengravingdimensions
eyebrowoptionalstringtitleoptionalstringdescriptionoptionalstringbasePriceoptionalnumberPrice before options
motifoptionalProductMotifDrawn motif for steps, split and engraving
optionsoptionalConfigOption[]defaultValuesoptionalRecord<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.ReactNodeYour own picture for a configuration
onAddoptional(values: Record<string, string | string[] | number>, price: number) => voidcurrencyoptionalstring
Eingebaute Texte
Diese Texte schreibt Produktkonfigurator selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 35 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| stepOf | Englisch Step {n} of {total} | Deutsch Schritt {n} von {total} |
| next | Englisch Next | Deutsch Weiter |
| back | Englisch Back | Deutsch Zurück |
| review | Englisch Review | Deutsch Übersicht |
| summary | Englisch Your configuration | Deutsch Deine Konfiguration |
| edit | Englisch Change | Deutsch Ändern |
| base | Englisch Base price | Deutsch Grundpreis |
| included | Englisch Included | Deutsch Inklusive |
| total | Englisch Total | Deutsch Gesamt |
| add | Englisch Add to cart | Deutsch In den Warenkorb |
| added | Englisch In the cart | Deutsch Im Warenkorb |
| view | Englisch View | Deutsch Ansicht |
| views | Englisch Front|Side|Detail | Deutsch Vorne|Seite|Detail |
| priceBreakdown | Englisch Price breakdown | Deutsch Preis im Detail |
| engraving | Englisch Engraving | Deutsch Gravur |
| engravingHint | Englisch Letters, numbers, spaces and . & - · up to {n} characters | Deutsch Buchstaben, Ziffern, Leerzeichen und . & - · bis zu {n} Zeichen |
| engravingPlaceholder | Englisch Your text | Deutsch Dein Text |
| left | Englisch {n} left | Deutsch noch {n} |
| tooLong | Englisch {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. |
| badChars | Englisch We cannot engrave {chars}. Use letters, numbers, space, . & or -. | Deutsch {chars} können wir nicht gravieren. Nimm Buchstaben, Ziffern, Leerzeichen, . & oder -. |
| empty | Englisch Type the text for the engraving, or turn engraving off. | Deutsch Gib den Text für die Gravur ein oder schalte die Gravur aus. |
| style | Englisch Style | Deutsch Stil |
| styles | Englisch Capitals|As typed|Spaced | Deutsch Großbuchstaben|Wie getippt|Gesperrt |
| placement | Englisch Placement | Deutsch Position |
| placements | Englisch Front|Back | Deutsch Vorne|Hinten |
| engraveOn | Englisch Add engraving | Deutsch Gravur hinzufügen |
| width | Englisch Width | Deutsch Breite |
| height | Englisch Height | Deutsch Höhe |
| depth | Englisch Depth | Deutsch Tiefe |
| material | Englisch Material | Deutsch Material |
| outOfRange | Englisch Choose a value between {min} and {max} cm. | Deutsch Wähl einen Wert zwischen {min} und {max} cm. |
| shelves | Englisch {n} shelves | Deutsch {n} Böden |
| madeToOrder | Englisch Made to order | Deutsch Auf Bestellung gefertigt |
| perfect | Englisch Fits your configuration | Deutsch Passt zu deiner Konfiguration |
| cm | Englisch cm | Deutsch 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
'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.