Product configurator
Configure a product, picture and price follow. Step by step, side by side with views, with an engraving or made to measure.
Every choice visible at once
A configurator sells the product the customer has in mind. Picture and price follow every choice, so people see what they put together and what it costs before it goes into the cart.
Step by step offers one choice per step with a segmented bar, the lamp changes color and size with every pick, chips under the picture jump back to a step and the last step lists everything with its price. Side by side keeps a large watch with three views on the left and all options on the right, and a price breakdown opens under the price. Engraving puts the text on the bottle while it is typed, names characters that cannot be engraved and counts to the limit. Made to measure sizes a shelf by width, height and depth, the drawing follows to scale and wide shelves switch to made to order.
Use steps for products with five or more decisions, side by side for fewer options with a strong picture. Show the price change on every option, then upgrades feel fair. Set a default for every choice, then the picture starts complete.
Variants
Details
What Product configurator brings along, read from the registry and the source.
- Category
- Product pages
- Area
- E-Commerce
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/product-configurator.tsx
- Brings along
- utilskitbuttoniconproduct-imageinput
- npm packages
- None
- Languages
- English and German, 35 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ProductConfiguratorProps interface from components/ui/product-configurator.tsx, with the comments from the code.
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
Built-in texts
Product configurator writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 35 texts
| Key | English | German |
|---|---|---|
| stepOf | English Step {n} of {total} | German Schritt {n} von {total} |
| next | English Next | German Weiter |
| back | English Back | German Zurück |
| review | English Review | German Übersicht |
| summary | English Your configuration | German Deine Konfiguration |
| edit | English Change | German Ändern |
| base | English Base price | German Grundpreis |
| included | English Included | German Inklusive |
| total | English Total | German Gesamt |
| add | English Add to cart | German In den Warenkorb |
| added | English In the cart | German Im Warenkorb |
| view | English View | German Ansicht |
| views | English Front|Side|Detail | German Vorne|Seite|Detail |
| priceBreakdown | English Price breakdown | German Preis im Detail |
| engraving | English Engraving | German Gravur |
| engravingHint | English Letters, numbers, spaces and . & - · up to {n} characters | German Buchstaben, Ziffern, Leerzeichen und . & - · bis zu {n} Zeichen |
| engravingPlaceholder | English Your text | German Dein Text |
| left | English {n} left | German noch {n} |
| tooLong | English {n} characters too many. Shorten the text to fit on one line. | German {n} Zeichen zu viel. Kürze den Text, damit er auf eine Zeile passt. |
| badChars | English We cannot engrave {chars}. Use letters, numbers, space, . & or -. | German {chars} können wir nicht gravieren. Nimm Buchstaben, Ziffern, Leerzeichen, . & oder -. |
| empty | English Type the text for the engraving, or turn engraving off. | German Gib den Text für die Gravur ein oder schalte die Gravur aus. |
| style | English Style | German Stil |
| styles | English Capitals|As typed|Spaced | German Großbuchstaben|Wie getippt|Gesperrt |
| placement | English Placement | German Position |
| placements | English Front|Back | German Vorne|Hinten |
| engraveOn | English Add engraving | German Gravur hinzufügen |
| width | English Width | German Breite |
| height | English Height | German Höhe |
| depth | English Depth | German Tiefe |
| material | English Material | German Material |
| outOfRange | English Choose a value between {min} and {max} cm. | German Wähl einen Wert zwischen {min} und {max} cm. |
| shelves | English {n} shelves | German {n} Böden |
| madeToOrder | English Made to order | German Auf Bestellung gefertigt |
| perfect | English Fits your configuration | German Passt zu deiner Konfiguration |
| cm | English cm | German cm |
Accessibility
Read from the source of the component.
- Product configurator handles the keys Enter and Space itself.
- Sets aria-checked, aria-describedby, aria-invalid, aria-label, aria-live and aria-valuetext, so screen readers announce purpose and state.
- Uses the roles img, radio, radiogroup and switch.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 844 lines
'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} />;
}Frequent questions
How do I install Product configurator?
With aiw add @ai-website/product-configurator. The command puts components/ui/product-configurator.tsx into your project, the code is yours from then on. It brings along button, icon, product-image and input. It needs no further npm packages.
Which variants does Product configurator have?
4 variants, Step by step, Side by side, Engraving and Made to measure. You pick one with the variant prop, for example variant="split".
Can I translate the texts of Product configurator?
Yes. Product configurator carries 35 texts of its own in English and German, such as “Next”, “Back” and “Review”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Product configurator?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Product configurator with aiw_components and install it with aiw_add @ai-website/product-configurator. Name the variant in your prompt, for example variant="steps", and the assistant writes your content straight into the props.