Zum Inhalt springen
UI Kit
Accent

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

4 Variants

Variants

Fragen und Antworten

Suche über Fragen und Antworten, die Suchwörter leuchten auf. Jede Frage hat eine „gleiche Frage“-Stimme, die Antwort vom Shop liegt auf getönter Fläche, weitere Antworten öffnen mit weicher Höhe und „Hilfreich“ zählt mit.

Im Studio öffnen

Häufigste Fragen

Die fünf häufigsten Fragen als Akkordeon neben einer Überschrift. Die Zeilen steigen nacheinander auf, das Plus wird zum Minus, die Antwort öffnet mit weicher Höhe und nennt, wer geantwortet hat.

Im Studio öffnen

Frage stellen

Das Frageformular. Beim Tippen erscheinen schon beantwortete Fragen mit den passenden Wörtern markiert und öffnen sich direkt. Fehlender Text oder E-Mail wird markiert und fokussiert, nach dem Senden wiederholt die Bestätigung die Frage.

Im Studio öffnen

Nach Themen

Fragen nach Themen mit Anzahl. Auf dem Desktop eine Themenleiste neben den Karten, auf dem Telefon eine Chip-Reihe, die scrollt. Ein Thema lässt die Karten neu einlaufen.

Im Studio öffnen

Details

Was Fragen zum Produkt mitbringt, aus Registry und Quellcode gelesen.

Bereich
E-Commerce
Variants
4
Version
1.0.1 · seit 1.1.0
Datei
components/ui/product-qa.tsx
Bringt mit
utilskitbuttoniconrevealmotionsectionproduct-image
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 28 eigene Texte

Props

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

  • variantoptional
    ProductQaVariant

    listcompactaskby-topic

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • questionsoptional
    QaQuestion[]
  • 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

  • answerTimeoptional
    string

    ask: answer time promise, e.g. "within one business day"

  • defaultQuestionoptional
    string

    ask: text already in the field, e.g. from a search without results

  • onAskoptional
    (question: string, email: string) => void
  • linksoptional
    { 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
searchEnglisch Search questionsDeutsch Fragen durchsuchen
searchPlaceholderEnglisch Search questions and answersDeutsch Fragen und Antworten durchsuchen
questionsEnglisch questionsDeutsch Fragen
questionEnglisch questionDeutsch Frage
answersEnglisch answersDeutsch Antworten
answerEnglisch answerDeutsch Antwort
fromShopEnglisch Answer from the shopDeutsch Antwort vom Shop
buyerEnglisch Verified buyerDeutsch Verifizierter Kauf
helpfulEnglisch HelpfulDeutsch Hilfreich
showMoreEnglisch Show more answersDeutsch Weitere Antworten zeigen
showLessEnglisch Show fewer answersDeutsch Weniger Antworten zeigen
noMatchEnglisch 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.
askEnglisch Ask a questionDeutsch Frage stellen
askLabelEnglisch Your questionDeutsch Deine Frage
askPlaceholderEnglisch e.g. Is it safe in the dishwasher?Deutsch z. B. Darf er in die Spülmaschine?
similarEnglisch Maybe already answeredDeutsch Vielleicht schon beantwortet
emailEnglisch Email for the answerDeutsch E-Mail für die Antwort
emailHintEnglisch We send the answer here. Your email is never shown.Deutsch Wir schicken die Antwort hierhin. Deine E-Mail wird nie gezeigt.
sendEnglisch Send questionDeutsch Frage senden
sendingEnglisch SendingDeutsch Wird gesendet
thanksEnglisch Question sentDeutsch Frage gesendet
thanksTextEnglisch We answerDeutsch Wir antworten
askAnotherEnglisch Ask another questionDeutsch Weitere Frage stellen
sameQuestionEnglisch Same questionDeutsch Gleiche Frage
allTopicsEnglisch All topicsDeutsch Alle Themen
askedEnglisch askedDeutsch gefragt
tooShortEnglisch Write your question in a few more words.Deutsch Schreib deine Frage in ein paar Worten mehr.
emailErrorEnglisch 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
components/ui/product-qa.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert