Fragen zum Produkt
Fragen und Antworten auf einer Produktseite. Eine durchsuchbare Liste mit Antworten vom Shop und von Käuferinnen und Käufern, die häufigsten als Akkordeon, das Frageformular mit ähnlichen Fragen und Fragen nach Themen.
Eine von 2 Components in Bewertungen und Fragen
Variants
Details
Was Fragen zum Produkt mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Bewertungen und Fragen
- Bereich
- E-Commerce
- Variants
- 4
- Version
- 1.0.1 · seit 1.1.0
- Datei
- components/ui/product-qa.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 28 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ProductQaProps aus components/ui/product-qa.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalProductQaVariantlistcompactaskby-topic
eyebrowoptionalstringtitleoptionalstringdescriptionoptionalstringquestionsoptionalQaQuestion[]topicsoptional{ id: string; label: string }[]by-topic: the topics in order, with an icon name per topic
productoptional{ name: string; image?: React.ReactNode }ask: the product the question is about
actionoptional{ label: string; href: string }compact: link to all questions
answerTimeoptionalstringask: answer time promise, e.g. "within one business day"
defaultQuestionoptionalstringask: text already in the field, e.g. from a search without results
onAskoptional(question: string, email: string) => voidlinksoptional{ ask?: string }Where "Ask a question" goes, the ask form on the same page by default: #ask
Eingebaute Texte
Diese Texte schreibt Fragen zum Produkt selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 28 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| search | Englisch Search questions | Deutsch Fragen durchsuchen |
| searchPlaceholder | Englisch Search questions and answers | Deutsch Fragen und Antworten durchsuchen |
| questions | Englisch questions | Deutsch Fragen |
| question | Englisch question | Deutsch Frage |
| answers | Englisch answers | Deutsch Antworten |
| answer | Englisch answer | Deutsch Antwort |
| fromShop | Englisch Answer from the shop | Deutsch Antwort vom Shop |
| buyer | Englisch Verified buyer | Deutsch Verifizierter Kauf |
| helpful | Englisch Helpful | Deutsch Hilfreich |
| showMore | Englisch Show more answers | Deutsch Weitere Antworten zeigen |
| showLess | Englisch Show fewer answers | Deutsch Weniger Antworten zeigen |
| noMatch | Englisch No question matches yet. Ask it, we answer within a day. | Deutsch Dazu gibt es noch keine Frage. Stell sie, wir antworten innerhalb eines Tages. |
| ask | Englisch Ask a question | Deutsch Frage stellen |
| askLabel | Englisch Your question | Deutsch Deine Frage |
| askPlaceholder | Englisch e.g. Is it safe in the dishwasher? | Deutsch z. B. Darf er in die Spülmaschine? |
| similar | Englisch Maybe already answered | Deutsch Vielleicht schon beantwortet |
| Englisch Email for the answer | Deutsch E-Mail für die Antwort | |
| emailHint | Englisch We send the answer here. Your email is never shown. | Deutsch Wir schicken die Antwort hierhin. Deine E-Mail wird nie gezeigt. |
| send | Englisch Send question | Deutsch Frage senden |
| sending | Englisch Sending | Deutsch Wird gesendet |
| thanks | Englisch Question sent | Deutsch Frage gesendet |
| thanksText | Englisch We answer | Deutsch Wir antworten |
| askAnother | Englisch Ask another question | Deutsch Weitere Frage stellen |
| sameQuestion | Englisch Same question | Deutsch Gleiche Frage |
| allTopics | Englisch All topics | Deutsch Alle Themen |
| asked | Englisch asked | Deutsch gefragt |
| tooShort | Englisch Write your question in a few more words. | Deutsch Schreib deine Frage in ein paar Worten mehr. |
| emailError | Englisch Enter your email so the answer can reach you. | Deutsch Gib deine E-Mail ein, damit die Antwort ankommt. |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-controls, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen alert, region und status.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 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, 612 Zeilen
'use client';
import { useId, useMemo, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
import { Eyebrow, Section } from '@/components/ui/section';
import { ShopMotion, Swap, formatNumber } from '@/components/ui/product-image';
/*
* Questions and answers on a product page: what people asked, answered by
* the shop or by other customers.
*
* list search over the questions, answers with votes, the shop answer marked
* compact the five most asked as an accordion, with a link to all
* ask ask a question, with similar questions shown while typing
* by-topic questions sorted into topics, a topic rail on desktop and chips on phones
*/
export type ProductQaVariant = 'list' | 'compact' | 'ask' | 'by-topic';
export interface QaAnswer {
author: string;
/** shop: from the shop team, buyer: a verified buyer */
role?: 'shop' | 'buyer';
/** e.g. "Product team" */
title?: string;
date: string;
body: string;
helpful?: number;
}
export interface QaQuestion {
id: string;
question: string;
author?: string;
date?: string;
topic?: string;
answers: QaAnswer[];
/** How many people asked the same */
votes?: number;
}
export interface ProductQaProps {
variant?: ProductQaVariant;
eyebrow?: string;
title?: string;
description?: string;
questions?: QaQuestion[];
/** by-topic: the topics in order, with an icon name per topic */
topics?: { id: string; label: string }[];
/** ask: the product the question is about */
product?: { name: string; image?: React.ReactNode };
/** compact: link to all questions */
action?: { label: string; href: string };
/** ask: answer time promise, e.g. "within one business day" */
answerTime?: string;
/** ask: text already in the field, e.g. from a search without results */
defaultQuestion?: string;
onAsk?: (question: string, email: string) => void;
/** Where "Ask a question" goes, the ask form on the same page by default: #ask */
links?: { ask?: string };
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
search: 'Search questions',
searchPlaceholder: 'Search questions and answers',
questions: 'questions',
question: 'question',
answers: 'answers',
answer: 'answer',
fromShop: 'Answer from the shop',
buyer: 'Verified buyer',
helpful: 'Helpful',
showMore: 'Show more answers',
showLess: 'Show fewer answers',
noMatch: 'No question matches yet. Ask it, we answer within a day.',
ask: 'Ask a question',
askLabel: 'Your question',
askPlaceholder: 'e.g. Is it safe in the dishwasher?',
similar: 'Maybe already answered',
email: 'Email for the answer',
emailHint: 'We send the answer here. Your email is never shown.',
send: 'Send question',
sending: 'Sending',
thanks: 'Question sent',
thanksText: 'We answer',
askAnother: 'Ask another question',
sameQuestion: 'Same question',
allTopics: 'All topics',
asked: 'asked',
tooShort: 'Write your question in a few more words.',
emailError: 'Enter your email so the answer can reach you.',
},
de: {
search: 'Fragen durchsuchen',
searchPlaceholder: 'Fragen und Antworten durchsuchen',
questions: 'Fragen',
question: 'Frage',
answers: 'Antworten',
answer: 'Antwort',
fromShop: 'Antwort vom Shop',
buyer: 'Verifizierter Kauf',
helpful: 'Hilfreich',
showMore: 'Weitere Antworten zeigen',
showLess: 'Weniger Antworten zeigen',
noMatch: 'Dazu gibt es noch keine Frage. Stell sie, wir antworten innerhalb eines Tages.',
ask: 'Frage stellen',
askLabel: 'Deine Frage',
askPlaceholder: 'z. B. Darf er in die Spülmaschine?',
similar: 'Vielleicht schon beantwortet',
email: 'E-Mail für die Antwort',
emailHint: 'Wir schicken die Antwort hierhin. Deine E-Mail wird nie gezeigt.',
send: 'Frage senden',
sending: 'Wird gesendet',
thanks: 'Frage gesendet',
thanksText: 'Wir antworten',
askAnother: 'Weitere Frage stellen',
sameQuestion: 'Gleiche Frage',
allTopics: 'Alle Themen',
asked: 'gefragt',
tooShort: 'Schreib deine Frage in ein paar Worten mehr.',
emailError: 'Gib deine E-Mail ein, damit die Antwort ankommt.',
},
};
type Texts = (typeof TEXTS)['en'];
const FOCUS = 'outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]';
const CARD = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const FIELD = 'w-full rounded-lg border border-input bg-background px-3.5 text-[15px] text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground hover:border-border-strong focus:border-border-strong focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_22%,transparent)]';
const ACCENT_TEXT = 'text-[color-mix(in_oklab,var(--primary)_78%,var(--foreground))]';
const MARK = 'rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_24%,transparent)] text-foreground [box-shadow:0_0_0_2px_color-mix(in_oklab,var(--primary)_24%,transparent)]';
function Head({ eyebrow, title, description, aside }: { eyebrow?: string; title?: string; description?: string; aside?: React.ReactNode }) {
if (!title) return null;
return (
<div className="mb-10 flex flex-col gap-6 sm:mb-12 sm:flex-row sm:items-end sm:justify-between">
<div className="max-w-[40rem]">
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && (
<p className="kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
{description}
</p>
)}
</div>
{aside}
</div>
);
}
function highlight(text: string, q: string, min = 3): React.ReactNode {
const words = q.trim().split(/[\s?!.,]+/).filter((w) => w.length >= min);
if (!words.length) return text;
const re = new RegExp(`(${words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'gi');
return text.split(re).map((p, i) => (i % 2 ? <mark key={i} className={MARK}>{p}</mark> : p));
}
/** One answer: shop answers get a tinted surface and a badge, a helpful toggle with a count */
function Answer({ a, t, query = '' }: { a: QaAnswer; t: Texts; query?: string }) {
const locale = useLocale();
const [on, setOn] = useState(false);
const shop = a.role === 'shop';
return (
<div className={cn('rounded-xl p-4', shop ? 'border border-[color-mix(in_oklab,var(--primary)_25%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]' : 'bg-surface')}>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[12.5px]">
<span className="font-medium text-foreground">{a.author}</span>
{shop ? (
<span className={cn('inline-flex items-center gap-1 rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] px-2 py-0.5 font-medium', ACCENT_TEXT)}>
<Icon name="check-circle" size={12} />
{a.title ?? t.fromShop}
</span>
) : a.role === 'buyer' ? (
<span className="inline-flex items-center gap-1 text-success-foreground">
<Icon name="check" size={12} />
{t.buyer}
</span>
) : null}
<span className="text-muted-foreground">· {a.date}</span>
</div>
<p className="mt-2 text-pretty text-[14.5px] leading-relaxed text-foreground">{highlight(a.body, query)}</p>
<button
type="button"
aria-pressed={on}
onClick={() => setOn(!on)}
className={cn('relative mt-3 inline-flex h-9 items-center after:absolute after:-inset-1 after:content-[""] gap-1.5 rounded-md px-2 -ms-2 text-[12.5px] transition-colors', on ? 'text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground', FOCUS)}
>
<Icon name="thumbs-up" size={14} className={cn(on && 'animate-[kitshop-pop_0.4s_var(--ease-kit)] text-primary')} />
{t.helpful}
{(a.helpful ?? 0) + (on ? 1 : 0) > 0 && (
<Swap value={(a.helpful ?? 0) + (on ? 1 : 0)}>
<span className="tabular-nums">({formatNumber(locale, (a.helpful ?? 0) + (on ? 1 : 0))})</span>
</Swap>
)}
</button>
</div>
);
}
/** "Same question" with a count: a vote that the question matters */
function SameVote({ q, t }: { q: QaQuestion; t: Texts }) {
const locale = useLocale();
const [on, setOn] = useState(false);
const n = (q.votes ?? 0) + (on ? 1 : 0);
return (
<button
type="button"
aria-pressed={on}
onClick={() => setOn(!on)}
className={cn('flex w-11 shrink-0 flex-col items-center self-start rounded-xl border py-2 transition-colors sm:w-14', on ? 'border-foreground bg-foreground text-background' : 'border-border-strong text-foreground hover:bg-accent', FOCUS)}
aria-label={`${t.sameQuestion}, ${formatNumber(locale, n)}`}
>
<Icon name="chevron-up" size={16} className={cn('transition-transform duration-300', on && '-translate-y-0.5')} />
<Swap value={n}>
<span className="text-[14px] font-semibold tabular-nums">{formatNumber(locale, n)}</span>
</Swap>
</button>
);
}
/* ------------------------------------------------------------------ */
/* list */
/* ------------------------------------------------------------------ */
function List(p: ProductQaProps & { t: Texts }) {
const { t } = p;
const locale = useLocale();
const [query, setQuery] = useState('');
const [open, setOpen] = useState<Record<string, boolean>>({});
const id = useId();
const q = query.trim().toLowerCase();
const all = p.questions ?? [];
const shown = useMemo(() => (q ? all.filter((x) => `${x.question} ${x.answers.map((a) => a.body).join(' ')}`.toLowerCase().includes(q)) : all), [all, q]);
return (
<Section className={p.className}>
<ShopMotion />
<Head
eyebrow={p.eyebrow}
title={p.title}
description={p.description}
aside={
<Button href={p.links?.ask ?? '#ask'} variant="outline" iconLeft="help-circle">
{t.ask}
</Button>
}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<label htmlFor={id} className="relative block sm:w-[26rem]">
<span className="sr-only">{t.search}</span>
<Icon name="search" size={16} className="pointer-events-none absolute start-3.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
<input id={id} type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t.searchPlaceholder} className={cn(FIELD, 'h-11 ps-10')} />
</label>
<p className="text-[13.5px] text-muted-foreground" aria-live="polite">
<span className="font-medium tabular-nums text-foreground">{formatNumber(locale, shown.length)}</span> {shown.length === 1 ? t.question : t.questions}
</p>
</div>
{shown.length ? (
<ul key={q} className="mt-6 divide-y divide-border border-y border-border">
{shown.map((x, i) => {
const more = x.answers.length > 1;
const isOpen = !!open[x.id];
return (
<li key={x.id} className="flex gap-4 py-7 animate-[kitshop-in_0.45s_var(--ease-kit)_both] sm:gap-6" style={{ animationDelay: `${Math.min(i, 6) * 60}ms` }}>
<SameVote q={x} t={t} />
<div className="min-w-0 flex-1">
<h3 className="text-pretty text-[16px] font-semibold leading-snug text-foreground">{highlight(x.question, query)}</h3>
{(x.author || x.date) && (
<p className="mt-1 text-[12.5px] text-muted-foreground">
{[x.author, x.date].filter(Boolean).join(' · ')} · {formatNumber(locale, x.answers.length)} {x.answers.length === 1 ? t.answer : t.answers}
</p>
)}
<div className="mt-4">
{x.answers[0] && <Answer a={x.answers[0]} t={t} query={query} />}
{more && (
<div className={cn('grid transition-[grid-template-rows,opacity,visibility] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', isOpen ? 'grid-rows-[1fr] opacity-100' : 'invisible grid-rows-[0fr] opacity-0')}>
<div className="space-y-3 overflow-hidden pt-3" inert={!isOpen}>
{x.answers.slice(1).map((a, k) => (
<Answer key={k} a={a} t={t} query={query} />
))}
</div>
</div>
)}
</div>
{more && (
<button type="button" aria-expanded={isOpen} onClick={() => setOpen((o) => ({ ...o, [x.id]: !isOpen }))} className={cn('mt-1 inline-flex min-h-11 items-center gap-1.5 rounded-md text-[13.5px] font-medium text-foreground', FOCUS)}>
{isOpen ? t.showLess : `${t.showMore} (${formatNumber(locale, x.answers.length - 1)})`}
<Icon name="chevron-down" size={15} className={cn('transition-transform duration-300', isOpen && 'rotate-180')} />
</button>
)}
</div>
</li>
);
})}
</ul>
) : (
<div className="mt-6 flex flex-col items-center gap-4 rounded-2xl border border-dashed border-border-strong p-10 text-center">
<p className="max-w-[26rem] text-pretty text-[14px] text-muted-foreground">{t.noMatch}</p>
<Button href={p.links?.ask ?? '#ask'} variant="outline" iconLeft="help-circle">
{t.ask}
</Button>
</div>
)}
</Section>
);
}
/* ------------------------------------------------------------------ */
/* compact */
/* ------------------------------------------------------------------ */
function Compact(p: ProductQaProps & { t: Texts }) {
const { t } = p;
const [open, setOpen] = useState<string | null>((p.questions ?? [])[0]?.id ?? null);
const id = useId();
return (
<Section className={p.className}>
<ShopMotion />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:gap-16">
<div>
{p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{p.title}</h2>
{p.description && <p className="kit-rise mt-4 text-pretty text-lead text-muted-foreground">{p.description}</p>}
{p.action && (
<a href={p.action.href} className={cn('group/all mt-6 inline-flex min-h-11 items-center gap-2 text-[14.5px] font-medium text-foreground max-lg:hidden', FOCUS)}>
{p.action.label}
<Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover/all:translate-x-1" />
</a>
)}
</div>
<div>
<Stagger step={60} as="ul" className="divide-y divide-border border-y border-border">
{(p.questions ?? []).map((x) => {
const on = open === x.id;
const a = x.answers[0];
return (
<li key={x.id}>
<h3>
<button
type="button"
id={`${id}-${x.id}`}
aria-expanded={on}
aria-controls={`${id}-${x.id}-p`}
onClick={() => setOpen(on ? null : x.id)}
className={cn('group/q flex w-full items-start gap-4 py-5 text-left', FOCUS)}
>
<span className={cn('mt-0.5 grid size-7 shrink-0 place-items-center rounded-full text-[12.5px] font-semibold transition-colors duration-300', on ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground group-hover/q:text-foreground')} aria-hidden>
?
</span>
<span className="flex-1 text-pretty text-[15.5px] font-medium leading-snug text-foreground">{x.question}</span>
<span className="relative mt-1 size-4 shrink-0" aria-hidden>
<span className="absolute left-0 top-1/2 h-[1.5px] w-4 -translate-y-1/2 rounded-full bg-foreground" />
<span className={cn('absolute left-1/2 top-0 h-4 w-[1.5px] -translate-x-1/2 rounded-full bg-foreground transition-transform duration-300 ease-[var(--ease-kit)]', on && 'scale-y-0')} />
</span>
</button>
</h3>
<div id={`${id}-${x.id}-p`} role="region" aria-labelledby={`${id}-${x.id}`} className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', on ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
<div className="overflow-hidden" inert={!on}>
{a && (
<div className="pb-5 ps-11">
<p className="text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{a.body}</p>
<p className="mt-2 text-[12.5px] text-subtle-foreground">
{a.author}
{a.role === 'shop' && <span className={cn('ms-1.5 font-medium', ACCENT_TEXT)}>· {a.title ?? t.fromShop}</span>}
</p>
</div>
)}
</div>
</div>
</li>
);
})}
</Stagger>
{p.action && (
<a href={p.action.href} className={cn('group/all mt-5 inline-flex min-h-11 items-center gap-2 text-[14.5px] font-medium text-foreground lg:hidden', FOCUS)}>
{p.action.label}
<Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover/all:translate-x-1" />
</a>
)}
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* ask */
/* ------------------------------------------------------------------ */
function Ask(p: ProductQaProps & { t: Texts }) {
const { t } = p;
const [text, setText] = useState(p.defaultQuestion ?? '');
const [email, setEmail] = useState('');
const [state, setState] = useState<'idle' | 'sending' | 'done'>('idle');
const [tried, setTried] = useState(false);
const [peek, setPeek] = useState<string | null>(null);
const id = useId();
// Only longer words count, so "does" or "with" never make a question look similar
const words = text.toLowerCase().split(/[\s?!.,]+/).filter((w) => w.length >= 5);
const similar = (p.questions ?? [])
.map((x) => ({ x, score: words.filter((w) => x.question.toLowerCase().includes(w)).length }))
.filter((s) => s.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 3)
.map((s) => s.x);
const shortText = text.trim().length < 12;
const badEmail = !/^\S+@\S+\.\S+$/.test(email);
const submit = (e: React.FormEvent) => {
e.preventDefault();
setTried(true);
if (shortText || badEmail) {
document.getElementById(shortText ? `${id}-q` : `${id}-e`)?.focus();
return;
}
setState('sending');
p.onAsk?.(text, email);
window.setTimeout(() => setState('done'), 800);
};
return (
<Section className={p.className}>
<ShopMotion />
<div id="ask" className="mx-auto max-w-[44rem]">
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
{state === 'done' ? (
<div className={cn(CARD, 'animate-[kitshop-zoom_0.45s_var(--ease-kit)] p-6 text-center sm:p-8')} role="status">
<span className="mx-auto grid size-14 animate-[kitshop-pop_0.5s_var(--ease-kit)] place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-success-foreground">
<Icon name="send" size={22} />
</span>
<h3 className="mt-5 text-[20px] font-semibold text-foreground">{t.thanks}</h3>
<p className="mx-auto mt-2 max-w-[28rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground">
{t.thanksText} {p.answerTime}. „{text.trim()}“
</p>
<Button variant="outline" className="mt-6" onClick={() => { setState('idle'); setText(''); setTried(false); }}>
{t.askAnother}
</Button>
</div>
) : (
<form noValidate onSubmit={submit} className={cn(CARD, 'p-5 sm:p-7')}>
{p.product && (
<div className="mb-6 flex items-center gap-3 border-b border-border pb-5">
{p.product.image && <span className="size-12 shrink-0 overflow-hidden rounded-lg bg-surface-2 [&>*]:size-full">{p.product.image}</span>}
<p className="text-[15px] font-medium text-foreground">{p.product.name}</p>
</div>
)}
<label htmlFor={`${id}-q`} className="mb-2 block text-[14px] font-medium text-foreground">
{t.askLabel}
</label>
<textarea
id={`${id}-q`}
value={text}
onChange={(e) => setText(e.target.value.slice(0, 300))}
rows={3}
placeholder={t.askPlaceholder}
aria-invalid={(tried && shortText) || undefined}
className={cn(FIELD, 'resize-none py-3 leading-relaxed', tried && shortText && 'border-destructive')}
/>
{tried && shortText && (
<p role="alert" className="mt-1.5 text-[13px] text-destructive">
{t.tooShort}
</p>
)}
{similar.length > 0 && (
<div className="mt-4 animate-[kitshop-in_0.35s_var(--ease-kit)] rounded-xl border border-border bg-surface p-3" aria-live="polite">
<p className="flex items-center gap-1.5 px-1 text-[12.5px] font-medium text-muted-foreground">
<Icon name="lightbulb" size={14} />
{t.similar}
</p>
<ul className="mt-2 space-y-1">
{similar.map((x) => {
const on = peek === x.id;
return (
<li key={x.id}>
<button type="button" aria-expanded={on} onClick={() => setPeek(on ? null : x.id)} className={cn('flex min-h-11 w-full items-center justify-between gap-3 rounded-lg px-2 py-2 text-left text-[14px] text-foreground transition-colors hover:bg-accent', FOCUS)}>
<span>{highlight(x.question, text, 5)}</span>
<Icon name="chevron-down" size={15} className={cn('shrink-0 text-muted-foreground transition-transform duration-300', on && 'rotate-180')} />
</button>
{on && x.answers[0] && <p className="animate-[kitshop-in_0.3s_var(--ease-kit)] px-2 pb-2 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{x.answers[0].body}</p>}
</li>
);
})}
</ul>
</div>
)}
<label htmlFor={`${id}-e`} className="mb-2 mt-5 block text-[14px] font-medium text-foreground">
{t.email}
</label>
<input
id={`${id}-e`}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
aria-invalid={(tried && badEmail) || undefined}
aria-describedby={`${id}-eh`}
className={cn(FIELD, 'h-11', tried && badEmail && 'border-destructive')}
/>
<p id={`${id}-eh`} className={cn('mt-1.5 text-[12.5px]', tried && badEmail ? 'text-destructive' : 'text-muted-foreground')}>
{tried && badEmail ? t.emailError : t.emailHint}
</p>
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{p.answerTime && (
<p className="flex items-center gap-1.5 text-[13px] text-muted-foreground">
<Icon name="clock" size={14} />
{t.thanksText} {p.answerTime}
</p>
)}
<Button type="submit" iconLeft={state === 'sending' ? 'loader' : 'send'} disabled={state === 'sending'}>
<Swap value={state === 'sending'}>{state === 'sending' ? t.sending : t.send}</Swap>
</Button>
</div>
</form>
)}
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* by-topic */
/* ------------------------------------------------------------------ */
function ByTopic(p: ProductQaProps & { t: Texts }) {
const { t } = p;
const locale = useLocale();
const topics = p.topics ?? [];
const [topic, setTopic] = useState<string | null>(null);
const all = p.questions ?? [];
const shown = topic ? all.filter((x) => x.topic === topic) : all;
const count = (id: string | null) => (id ? all.filter((x) => x.topic === id).length : all.length);
const items = [{ id: null as string | null, label: t.allTopics }, ...topics.map((x) => ({ id: x.id as string | null, label: x.label }))];
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-12">
<nav aria-label={t.allTopics} className="lg:sticky lg:top-8 lg:self-start">
<ul className="kit-no-scrollbar kit-fade-end -mx-6 flex gap-2 overflow-x-auto px-6 py-1 lg:mx-0 lg:flex-col lg:gap-0.5 lg:overflow-visible lg:px-0 lg:[mask-image:none]">
{items.map((x) => {
const on = topic === x.id;
return (
<li key={x.id ?? 'all'} className="shrink-0">
<button
type="button"
aria-pressed={on}
onClick={() => setTopic(x.id)}
className={cn(
'flex h-11 w-full items-center justify-between gap-3 whitespace-nowrap rounded-full border px-4 text-[14px] font-medium transition-colors lg:rounded-lg lg:border-0 lg:px-3',
on ? 'border-foreground bg-foreground text-background lg:bg-muted lg:text-foreground' : 'border-border-strong text-muted-foreground hover:text-foreground lg:hover:bg-surface',
FOCUS,
)}
>
{x.label}
<span className={cn('text-[12.5px] tabular-nums', on ? 'opacity-70 lg:opacity-100 lg:text-muted-foreground' : 'text-subtle-foreground')}>{formatNumber(locale, count(x.id))}</span>
</button>
</li>
);
})}
</ul>
</nav>
<ul key={topic ?? 'all'} className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{shown.map((x, i) => {
const a = x.answers[0];
return (
<li key={x.id} className="animate-[kitshop-in_0.45s_var(--ease-kit)_both]" style={{ animationDelay: `${Math.min(i, 6) * 60}ms` }}>
<article className={cn(CARD, 'flex h-full flex-col p-5 sm:p-6')}>
{!topic && x.topic && <p className="text-[12px] font-medium text-subtle-foreground">{topics.find((tp) => tp.id === x.topic)?.label}</p>}
<h3 className="mt-1 text-pretty text-[16px] font-semibold leading-snug text-foreground">{x.question}</h3>
{a && <p className="mt-3 text-pretty text-[14px] leading-relaxed text-muted-foreground">{a.body}</p>}
{a && (
<p className="mt-auto flex flex-wrap items-center gap-x-2 pt-4 text-[12.5px] text-muted-foreground">
<span className="font-medium text-foreground">{a.author}</span>
{a.role === 'shop' && <span className={ACCENT_TEXT}>· {a.title ?? t.fromShop}</span>}
{x.votes !== undefined && (
<span className="ms-auto inline-flex items-center gap-1 tabular-nums">
<Icon name="users" size={13} />
{formatNumber(locale, x.votes)} {t.asked}
</span>
)}
</p>
)}
</article>
</li>
);
})}
</ul>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* The block */
/* ------------------------------------------------------------------ */
/**
* Product questions in four layouts. list: search, answers and votes.
* compact: the most asked as an accordion. ask: the question form with
* similar questions. by-topic: questions sorted into topics.
*/
export function ProductQa(props: ProductQaProps) {
const { variant = 'list' } = props;
const t = useTexts(TEXTS, props.labels);
if (variant === 'compact') return <Compact {...props} t={t} />;
if (variant === 'ask') return <Ask {...props} t={t} />;
if (variant === 'by-topic') return <ByTopic {...props} t={t} />;
return <List {...props} t={t} />;
}Häufige Fragen
Wie installiere ich Fragen zum Produkt?
Mit aiw add @ai-website/product-qa. Der Befehl legt die Datei components/ui/product-qa.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, motion, section und product-image. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Fragen zum Produkt?
4 Varianten, Fragen und Antworten, Häufigste Fragen, Frage stellen und Nach Themen. Du wählst sie mit der Prop variant, zum Beispiel variant="compact".
Kann ich die Texte von Fragen zum Produkt übersetzen?
Ja. Fragen zum Produkt bringt 28 eigene Texte auf Deutsch und Englisch mit, etwa „Fragen durchsuchen“, „Fragen und Antworten durchsuchen“ und „Fragen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Fragen zum Produkt einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Fragen zum Produkt mit aiw_components und installieren sie mit aiw_add @ai-website/product-qa. Nenn im Prompt die Variante, etwa variant="list", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Fragen zum Produkt ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden ProductQa-Code zum Kopieren.