Zum Inhalt springen
UI Kit
Accent

Testbericht

Testberichte, mit denen man entscheiden kann. Das Urteil mit Wertungsring und Pro und Contra, die Wertung mit eigener Gewichtung, das Datenblatt versprochen gegen gemessen, die Sieger eines Vergleichs und der Dauertest als Tagebuch.

Eine von 3 Components in Redaktion

5 Variants

Variants

Urteil

Das Ergebnis zuerst. Ein Ring füllt sich bis zur Wertung, das Urteil in einem Satz, Pro und Contra, wer es kaufen sollte und wer nicht.

Im Studio öffnen

Wertung mit Gewichtung

Die Wertung je Kriterium, Balken füllen sich beim Hereinscrollen. Lesende verschieben die Gewichte nach eigenen Prioritäten, der Ring folgt, ein Druck führt zurück.

Im Studio öffnen

Datenblatt

Was der Hersteller verspricht neben dem, was wir gemessen haben, jeder Wert als besser, darunter oder wie versprochen markiert. Ein Schalter zeigt nur die Abweichungen.

Im Studio öffnen

Vergleichssieger

Die Sieger eines Vergleichstests als Karten, jede mit Auszeichnung, Wertung, zwei Fakten und Preis. Der Testsieger trägt ein umlaufendes Licht.

Im Studio öffnen

Dauertest

Der Dauertest als Tagebuch. Eine Linie zeichnet die Wertung über die Monate, sobald sie hereinscrollt, jeder Eintrag mit Strecke, Wertung und Erlebtem.

Im Studio öffnen

Details

Was Testbericht mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Redaktion
Bereich
Content
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/review-article.tsx
Bringt mit
utilskitbuttoniconsliderrevealmotionsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 21 eigene Texte

Props

Die Schnittstelle ReviewArticleProps aus components/ui/review-article.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    ReviewArticleVariant

    verdictscoresspecsrounduptest-log

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • productoptional
    ReviewProduct
  • productsoptional
    ReviewProduct[]

    roundup

  • verdictoptional
    string

    verdict: the sentence

  • prosoptional
    string[]
  • consoptional
    string[]
  • goodForoptional
    string

    verdict: who it is for and who should skip

  • skipIfoptional
    string
  • criteriaoptional
    ReviewCriterion[]
  • specsoptional
    ReviewSpec[]
  • logoptional
    ReviewLogEntry[]
  • scaleoptional
    number

    Highest possible score, default 10

  • actionoptional
    { label: string; href: string }

    Main action, e.g. to the shop

Eingebaute Texte

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

Alle 21 Texte anzeigen
scoreEnglisch ScoreDeutsch Wertung
ofEnglisch out of {m}Deutsch von {m}
prosEnglisch What we likedDeutsch Was uns gefallen hat
consEnglisch What could be betterDeutsch Was besser sein könnte
goodForEnglisch Buy it ifDeutsch Kauf es, wenn
skipIfEnglisch Skip it ifDeutsch Lass es, wenn
verdictEnglisch Our verdictDeutsch Unser Urteil
criteriaEnglisch CriteriaDeutsch Kriterien
weightEnglisch WeightDeutsch Gewicht
yourScoreEnglisch Your scoreDeutsch Deine Wertung
ourScoreEnglisch Our weightingDeutsch Unsere Gewichtung
resetEnglisch Back to our weightingDeutsch Zurück zu unserer Gewichtung
claimedEnglisch Maker saysDeutsch Laut Hersteller
measuredEnglisch We measuredDeutsch Gemessen
betterEnglisch better than promisedDeutsch besser als versprochen
worseEnglisch below the promiseDeutsch unter dem Versprechen
sameEnglisch as promisedDeutsch wie versprochen
onlyDiffEnglisch Only deviationsDeutsch Nur Abweichungen
allEnglisch All valuesDeutsch Alle Werte
logEnglisch Test diaryDeutsch Testtagebuch
sinceEnglisch since the startDeutsch seit Beginn

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen group und img.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • 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).
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 649 Zeilen
components/ui/review-article.tsx
'use client';

import { useEffect, useMemo, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Magnetic, Parallax, Stagger } from '@/components/ui/motion';
import { Reveal } from '@/components/ui/reveal';
import { Slider } from '@/components/ui/slider';
import { Eyebrow } from '@/components/ui/section';

/*
 * Reviews and tests, written so a reader can decide. Scores are numbers,
 * so every variant can draw, weigh and compare them.
 *
 *   verdict   the result first: score ring, the verdict in one sentence,
 *             pros and cons, who should buy it and who should not
 *   scores    the score by criterion; readers move the weights to their own
 *             priorities and the total follows
 *   specs     the data sheet, promised against measured, deviations marked
 *   roundup   the winners of a comparison test, each with its award
 *   test-log  the long test as a diary, the score changing over the months
 */

export type ReviewArticleVariant = 'verdict' | 'scores' | 'specs' | 'roundup' | 'test-log';

export interface ReviewProduct {
  name: string;
  media?: React.ReactNode;
  /** Total score */
  score?: number;
  /** As shown, e.g. €3,490 */
  price?: string;
  href?: string;
  /** roundup: e.g. Best overall */
  award?: string;
  /** roundup: one sentence why */
  reason?: string;
  /** roundup: two or three facts */
  facts?: string[];
}

export interface ReviewCriterion {
  label: string;
  score: number;
  /** Weight in percent, the weights add up to 100 */
  weight: number;
  note?: string;
}

export interface ReviewSpec {
  group?: string;
  label: string;
  /** What the maker says */
  claimed?: string;
  /** What we measured */
  measured: string;
  /** Mark the measured value: better, worse or same */
  verdict?: 'better' | 'worse' | 'same';
}

export interface ReviewLogEntry {
  /** e.g. Week 1 */
  when: string;
  title: string;
  text: string;
  score?: number;
  /** km, hours, uses, as shown */
  mileage?: string;
}

export interface ReviewArticleProps {
  variant?: ReviewArticleVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  product?: ReviewProduct;
  /** roundup */
  products?: ReviewProduct[];
  /** verdict: the sentence */
  verdict?: string;
  pros?: string[];
  cons?: string[];
  /** verdict: who it is for and who should skip */
  goodFor?: string;
  skipIf?: string;
  criteria?: ReviewCriterion[];
  specs?: ReviewSpec[];
  log?: ReviewLogEntry[];
  /** Highest possible score, default 10 */
  scale?: number;
  /** Main action, e.g. to the shop */
  action?: { label: string; href: string };
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    score: 'Score',
    of: 'out of {m}',
    pros: 'What we liked',
    cons: 'What could be better',
    goodFor: 'Buy it if',
    skipIf: 'Skip it if',
    verdict: 'Our verdict',
    criteria: 'Criteria',
    weight: 'Weight',
    yourScore: 'Your score',
    ourScore: 'Our weighting',
    reset: 'Back to our weighting',
    claimed: 'Maker says',
    measured: 'We measured',
    better: 'better than promised',
    worse: 'below the promise',
    same: 'as promised',
    onlyDiff: 'Only deviations',
    all: 'All values',
    log: 'Test diary',
    since: 'since the start',
  },
  de: {
    score: 'Wertung',
    of: 'von {m}',
    pros: 'Was uns gefallen hat',
    cons: 'Was besser sein könnte',
    goodFor: 'Kauf es, wenn',
    skipIf: 'Lass es, wenn',
    verdict: 'Unser Urteil',
    criteria: 'Kriterien',
    weight: 'Gewicht',
    yourScore: 'Deine Wertung',
    ourScore: 'Unsere Gewichtung',
    reset: 'Zurück zu unserer Gewichtung',
    claimed: 'Laut Hersteller',
    measured: 'Gemessen',
    better: 'besser als versprochen',
    worse: 'unter dem Versprechen',
    same: 'wie versprochen',
    onlyDiff: 'Nur Abweichungen',
    all: 'Alle Werte',
    log: 'Testtagebuch',
    since: 'seit Beginn',
  },
};

type Texts = (typeof TEXTS)['en'];

/* ------------------------------------------------------------------ */
/* Shared pieces                                                       */
/* ------------------------------------------------------------------ */

function useScore() {
  const locale = useLocale();
  const nf = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
  return (n: number) => nf.format(n);
}

/** A ring that fills to the score once it scrolls into view */
function ScoreRing({ score, scale, size = 120, label }: { score: number; scale: number; size?: number; label: string }) {
  const fmt = useScore();
  const ref = useRef<HTMLDivElement>(null);
  const [on, setOn] = useState(true);
  useEffect(() => {
    const el = ref.current;
    if (!el || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    setOn(false);
    const io = new IntersectionObserver(([e]) => {
      if (e.isIntersecting) {
        setOn(true);
        io.disconnect();
      }
    }, { threshold: 0.4 });
    io.observe(el);
    return () => io.disconnect();
  }, []);
  const R = 44;
  const C = 2 * Math.PI * R;
  return (
    <div ref={ref} className="relative grid shrink-0 place-items-center" style={{ width: size, height: size }} role="img" aria-label={label}>
      <svg viewBox="0 0 100 100" className="absolute inset-0 size-full -rotate-90" aria-hidden>
        <circle cx="50" cy="50" r={R} fill="none" stroke="var(--border)" strokeWidth="6" />
        <circle cx="50" cy="50" r={R} fill="none" stroke="var(--primary)" strokeWidth="6" strokeLinecap="round" strokeDasharray={round(C)} strokeDashoffset={round(C * (1 - (on ? score / scale : 0)))} className="transition-[stroke-dashoffset] duration-[1400ms] ease-[var(--ease-kit)] motion-reduce:transition-none" />
      </svg>
      <span className="text-center leading-none" aria-hidden>
        <span className="block font-semibold tabular-nums tracking-[-0.03em] text-foreground" style={{ fontSize: round(size * 0.3) }}>
          {fmt(score)}
        </span>
        <span className="mt-1 block text-[12px] text-muted-foreground">/ {scale}</span>
      </span>
    </div>
  );
}

function Picture({ media, aspect = '4 / 3', className, parallax }: { media?: React.ReactNode; aspect?: string; className?: string; parallax?: boolean }) {
  if (!media) return null;
  return (
    <div data-clip-ok className={cn('relative overflow-hidden rounded-2xl bg-muted', className)} style={{ aspectRatio: aspect }}>
      {parallax ? (
        <Parallax speed={36} className="size-full scale-[1.1] [&>*]:size-full">
          {media}
        </Parallax>
      ) : (
        <div className="size-full [&>*]:size-full">{media}</div>
      )}
    </div>
  );
}

function Head({ eyebrow, title, description, aside }: { eyebrow?: React.ReactNode; title?: React.ReactNode; description?: React.ReactNode; aside?: React.ReactNode }) {
  if (!title && !eyebrow) return null;
  return (
    <div className="mb-10 flex flex-col gap-6 sm:mb-12 lg:flex-row lg:items-end lg:justify-between">
      <div className="max-w-[42rem]">
        {eyebrow && <Eyebrow className="kit-rise">{eyebrow}</Eyebrow>}
        {title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{title}</h2>}
        {description && <p className="kit-rise mt-4 text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{description}</p>}
      </div>
      {aside}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* verdict                                                             */
/* ------------------------------------------------------------------ */

function Verdict(p: ReviewArticleProps & { t: Texts }) {
  const { t } = p;
  const scale = p.scale ?? 10;
  const fmt = useScore();
  const pr = p.product;
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
        <Reveal effect="rise" className="kit-card overflow-hidden">
          <div className="grid grid-cols-[minmax(0,1fr)] lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
            <div className="relative border-b border-border lg:border-b-0 lg:border-r">
              <div data-clip-ok className="relative aspect-[4/3] overflow-hidden bg-muted lg:aspect-auto lg:h-full">
                <Parallax speed={36} className="absolute inset-0 scale-[1.1] [&>*]:size-full">
                  {pr?.media}
                </Parallax>
              </div>
            </div>
            <div className="flex flex-col p-6 sm:p-8">
              <div className="flex items-start gap-5 sm:gap-6">
                {pr?.score !== undefined && <ScoreRing score={pr.score} scale={scale} size={112} label={`${t.score} ${fmt(pr.score)} ${t.of.replace('{m}', String(scale))}`} />}
                <div className="min-w-0">
                  <p className="text-[13px] font-semibold uppercase tracking-[0.08em] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{t.verdict}</p>
                  <h3 className="mt-1.5 text-balance text-[22px] font-semibold leading-[1.25] tracking-[-0.012em] text-foreground sm:text-[24px]">{pr?.name}</h3>
                  {pr?.price && <p className="mt-1 text-[15px] tabular-nums text-muted-foreground">{pr.price}</p>}
                </div>
              </div>
              {p.verdict && <p className="mt-6 text-pretty text-[18px] font-medium leading-[1.55] text-foreground sm:text-[19px]">{p.verdict}</p>}
              <div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 sm:grid-cols-2">
                {[
                  { list: p.pros ?? [], head: t.pros, icon: 'plus' as const, tone: 'text-success-foreground bg-[color-mix(in_oklab,var(--success)_14%,transparent)]' },
                  { list: p.cons ?? [], head: t.cons, icon: 'minus' as const, tone: 'text-destructive bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)]' },
                ].map((b) => (
                  <div key={b.head}>
                    <p className="text-[13.5px] font-semibold text-foreground">{b.head}</p>
                    <Stagger as="ul" step={70} className="mt-3 space-y-2.5">
                      {b.list.map((x, i) => (
                        <li key={i} className="flex gap-2.5 text-[14.5px] leading-[1.5] text-foreground">
                          <span className={cn('mt-px grid size-5 shrink-0 place-items-center rounded-full', b.tone)} aria-hidden>
                            <Icon name={b.icon} size={12} strokeWidth={2.4} />
                          </span>
                          <span className="text-pretty">{x}</span>
                        </li>
                      ))}
                    </Stagger>
                  </div>
                ))}
              </div>
              {(p.goodFor || p.skipIf || p.action) && (
                <div className="mt-auto pt-7">
                  <dl className="grid grid-cols-[minmax(0,1fr)] gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-2">
                    {p.goodFor && (
                      <div className="bg-surface p-4">
                        <dt className="text-[12.5px] font-semibold text-foreground">{t.goodFor}</dt>
                        <dd className="mt-1 text-pretty text-[14px] leading-[1.5] text-muted-foreground">{p.goodFor}</dd>
                      </div>
                    )}
                    {p.skipIf && (
                      <div className="bg-surface p-4">
                        <dt className="text-[12.5px] font-semibold text-foreground">{t.skipIf}</dt>
                        <dd className="mt-1 text-pretty text-[14px] leading-[1.5] text-muted-foreground">{p.skipIf}</dd>
                      </div>
                    )}
                  </dl>
                  {p.action && (
                    <Magnetic className="mt-5 flex w-full sm:inline-flex sm:w-auto">
                      <Button href={p.action.href} icon="arrow-up-right" className="w-full sm:w-auto">
                        {p.action.label}
                      </Button>
                    </Magnetic>
                  )}
                </div>
              )}
            </div>
          </div>
        </Reveal>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* scores                                                              */
/* ------------------------------------------------------------------ */

function Scores(p: ReviewArticleProps & { t: Texts }) {
  const { t } = p;
  const locale = useLocale();
  const scale = p.scale ?? 10;
  const fmt = useScore();
  const criteria = p.criteria ?? [];
  const base = useMemo(() => criteria.map((c) => c.weight), [criteria]);
  const [w, setW] = useState<number[]>(base);
  useEffect(() => setW(base), [base]);
  const sum = w.reduce((a, b) => a + b, 0) || 1;
  const total = criteria.reduce((a, c, i) => a + c.score * (w[i] ?? 0), 0) / sum;
  const ours = criteria.reduce((a, c, i) => a + c.score * base[i], 0) / (base.reduce((a, b) => a + b, 0) || 1);
  const changed = w.some((x, i) => x !== base[i]);
  const [ref, inView] = useSeen<HTMLUListElement>();
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
        <div className="grid grid-cols-[minmax(0,1fr)] gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,20rem)] lg:gap-14">
          <ul ref={ref} className="divide-y divide-border border-y border-border">
            {criteria.map((c, i) => (
              <li key={c.label} className="py-5">
                <div className="flex items-center justify-between gap-4">
                  <h3 className="min-w-0 text-[16.5px] font-semibold text-foreground">{c.label}</h3>
                  <span className="flex shrink-0 items-center gap-3">
                    <span className="h-2.5 w-24 overflow-hidden rounded-full bg-track sm:w-40" aria-hidden>
                      <span className="block h-full origin-left rounded-full bg-[color-mix(in_oklab,var(--foreground)_80%,var(--background))] transition-transform duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? round(c.score / scale, 3) : 0})`, transitionDelay: `${i * 80}ms` }} />
                    </span>
                    <span className="w-9 text-right text-[18px] font-semibold tabular-nums text-foreground">{fmt(c.score)}</span>
                  </span>
                </div>
                {c.note && <p className="mt-2.5 text-pretty text-[14.5px] leading-[1.55] text-muted-foreground">{c.note}</p>}
                <Slider
                  className="mt-3"
                  label={t.weight}
                  min={0}
                  max={50}
                  step={5}
                  value={w[i] ?? 0}
                  onValueChange={(v: number) => setW((ws) => ws.map((x, k) => (k === i ? v : x)))}
                  format={() => `${round(((w[i] ?? 0) / sum) * 100, 0)}${locale === 'de' ? ' %' : '%'}`}
                />
              </li>
            ))}
          </ul>
          {/* Phones and tablets: the total rides along at the bottom while the weights move */}
          <div className="sticky bottom-3 z-10 flex items-center gap-4 rounded-2xl border border-border bg-[color-mix(in_oklab,var(--card)_92%,transparent)] p-3 pr-4 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-md lg:hidden">
            <ScoreRing score={round(total, 1)} scale={scale} size={64} label={`${t.score} ${fmt(total)} ${t.of.replace('{m}', String(scale))}`} />
            <p className="min-w-0 flex-1 text-[14px] leading-snug">
              <span className="block font-semibold text-foreground">{changed ? t.yourScore : t.ourScore}</span>
              {changed && (
                <span className="text-muted-foreground">
                  {t.ourScore} <span className="tabular-nums">{fmt(ours)}</span>
                </span>
              )}
            </p>
            {changed && (
              <button type="button" onClick={() => setW(base)} aria-label={t.reset} className="grid size-11 shrink-0 place-items-center rounded-lg border border-border text-foreground transition-colors hover:bg-accent">
                <Icon name="rotate-ccw" size={16} />
              </button>
            )}
          </div>
          <div className="hidden lg:sticky lg:top-10 lg:block lg:self-start">
            <div className="kit-card flex flex-col items-center p-6 text-center sm:p-8">
              <p className="text-[13px] font-semibold text-muted-foreground">{changed ? t.yourScore : t.ourScore}</p>
              <div className="mt-4">
                <ScoreRing score={round(total, 1)} scale={scale} size={150} label={`${t.score} ${fmt(total)} ${t.of.replace('{m}', String(scale))}`} />
              </div>
              <p className="mt-4 text-[14px] text-muted-foreground" aria-live="polite">
                {changed ? (
                  <>
                    {t.ourScore} <span className="font-semibold tabular-nums text-foreground">{fmt(ours)}</span>
                  </>
                ) : (
                  p.product?.name
                )}
              </p>
              <button type="button" onClick={() => setW(base)} disabled={!changed} className="mt-4 inline-flex min-h-11 items-center gap-2 rounded-lg px-3 text-[14px] font-medium text-foreground transition-[background-color,opacity] hover:bg-accent disabled:pointer-events-none disabled:opacity-0">
                <Icon name="rotate-ccw" size={15} />
                {t.reset}
              </button>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

function useSeen<T extends Element>() {
  const ref = useRef<T>(null);
  const [v, setV] = useState(true);
  useEffect(() => {
    const el = ref.current;
    if (!el || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    if (el.getBoundingClientRect().top < window.innerHeight * 0.85) return;
    setV(false);
    const io = new IntersectionObserver(([e]) => {
      if (e.isIntersecting) {
        setV(true);
        io.disconnect();
      }
    }, { rootMargin: '0px 0px -15% 0px' });
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return [ref, v] as const;
}

/* ------------------------------------------------------------------ */
/* specs                                                               */
/* ------------------------------------------------------------------ */

function Specs(p: ReviewArticleProps & { t: Texts }) {
  const { t } = p;
  const specs = p.specs ?? [];
  const [only, setOnly] = useState(false);
  const groups = useMemo(() => {
    const m = new Map<string, ReviewSpec[]>();
    for (const s of specs) m.set(s.group ?? '', [...(m.get(s.group ?? '') ?? []), s]);
    return [...m.entries()];
  }, [specs]);
  const mark = (v?: ReviewSpec['verdict']) =>
    v === 'better' ? { icon: 'arrow-up' as const, cls: 'text-success-foreground bg-[color-mix(in_oklab,var(--success)_14%,transparent)]', text: t.better } : v === 'worse' ? { icon: 'arrow-down' as const, cls: 'text-destructive bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)]', text: t.worse } : { icon: 'check' as const, cls: 'text-muted-foreground bg-muted', text: t.same };
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <Head
          eyebrow={p.eyebrow}
          title={p.title}
          description={p.description}
          aside={
            <div role="group" className="inline-flex self-start rounded-xl border border-border bg-surface p-1 lg:self-end">
              {[false, true].map((v) => (
                <button key={String(v)} type="button" aria-pressed={only === v} onClick={() => setOnly(v)} className={cn('min-h-10 rounded-lg px-3.5 text-[14px] font-medium transition-[background-color,color] duration-300 max-sm:min-h-11', only === v ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}>
                  {v ? t.onlyDiff : t.all}
                </button>
              ))}
            </div>
          }
        />
        <div className="overflow-hidden rounded-2xl border border-border">
          <div className="hidden grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1fr)] gap-4 border-b border-border bg-surface px-5 py-3 text-[12.5px] font-semibold uppercase tracking-[0.06em] text-muted-foreground sm:grid">
            <span />
            <span>{t.claimed}</span>
            <span>{t.measured}</span>
          </div>
          {groups.map(([g, rows]) => {
            const visible = rows.filter((r) => !only || (r.verdict && r.verdict !== 'same'));
            if (!visible.length) return null;
            return (
              <div key={g}>
                {g && <p className="border-b border-border bg-[color-mix(in_oklab,var(--surface)_50%,var(--background))] px-5 py-2.5 text-[13px] font-semibold text-foreground">{g}</p>}
                <dl>
                  {visible.map((r) => {
                    const m = mark(r.verdict);
                    return (
                      <div key={r.label} className="kit-rise grid grid-cols-2 gap-x-4 gap-y-1.5 border-b border-border px-5 py-4 last:border-b-0 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1fr)] sm:items-center" style={{ animationDuration: '.5s' } as React.CSSProperties}>
                        <dt className="col-span-2 text-[15px] font-medium text-foreground sm:col-span-1">{r.label}</dt>
                        <dd className="flex flex-wrap items-center gap-2 text-[14.5px] tabular-nums text-muted-foreground">
                          <span className="block w-full text-[12px] sm:hidden">{t.claimed}</span>
                          {r.claimed ?? '·'}
                        </dd>
                        <dd className="flex flex-wrap items-center gap-2 text-[14.5px] font-semibold tabular-nums text-foreground">
                          <span className="block w-full text-[12px] font-normal text-muted-foreground sm:hidden">{t.measured}</span>
                          {r.measured}
                          {r.verdict && (
                            <span className={cn('inline-flex h-5 items-center gap-1 rounded-md px-1.5 text-[12px] font-medium', m.cls)}>
                              <Icon name={m.icon} size={12} strokeWidth={2.2} />
                              <span className="sr-only lg:not-sr-only">{m.text}</span>
                            </span>
                          )}
                        </dd>
                      </div>
                    );
                  })}
                </dl>
              </div>
            );
          })}
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* roundup                                                             */
/* ------------------------------------------------------------------ */

function Roundup(p: ReviewArticleProps & { t: Texts }) {
  const { t } = p;
  const scale = p.scale ?? 10;
  const fmt = useScore();
  const products = p.products ?? [];
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
        <Stagger step={90} className="grid grid-cols-[minmax(0,1fr)] gap-4 md:grid-cols-2 lg:grid-cols-4">
          {products.map((x, i) => (
            <article key={x.name} className={cn('group/p relative flex flex-col overflow-hidden rounded-2xl border bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow,border-color] duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-md)]', i === 0 ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))]' : 'border-border hover:border-border-strong')}>
              {i === 0 && <span className="kit-beam pointer-events-none absolute inset-0 rounded-[inherit]" aria-hidden />}
              <div className="relative overflow-hidden">
                <div className="transition-transform duration-700 ease-[var(--ease-kit)] group-hover/p:scale-[1.04]">
                  <Picture media={x.media} aspect="4 / 3" className="rounded-none" />
                </div>
                {x.award && (
                  <span className={cn('absolute left-3 top-3 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12.5px] font-semibold shadow-[var(--shadow-md)]', i === 0 ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--background)_88%,transparent)] text-foreground backdrop-blur')}>
                    <Icon name={i === 0 ? 'trophy' : 'award'} size={14} />
                    {x.award}
                  </span>
                )}
              </div>
              <div className="flex flex-1 flex-col p-5">
                <div className="flex items-start justify-between gap-3">
                  <h3 className="text-pretty text-[18px] font-semibold leading-[1.3] text-foreground">{x.name}</h3>
                  {x.score !== undefined && (
                    <span className="shrink-0 rounded-lg bg-muted px-2 py-1 text-[15px] font-semibold tabular-nums text-foreground" aria-label={`${t.score} ${fmt(x.score)} ${t.of.replace('{m}', String(scale))}`}>
                      {fmt(x.score)}
                    </span>
                  )}
                </div>
                {x.reason && <p className="mt-2 text-pretty text-[14.5px] leading-[1.55] text-muted-foreground">{x.reason}</p>}
                {x.facts && (
                  <ul className="mt-4 space-y-1.5 border-t border-border pt-4">
                    {x.facts.map((f) => (
                      <li key={f} className="flex items-center gap-2 text-[13.5px] text-foreground">
                        <Icon name="check" size={14} className="text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" />
                        {f}
                      </li>
                    ))}
                  </ul>
                )}
                <div className="mt-auto flex items-center justify-between gap-3 pt-5">
                  <span className="text-[16px] font-semibold tabular-nums text-foreground">{x.price}</span>
                  <a href={x.href ?? '#'} className="inline-flex min-h-11 items-center gap-1.5 text-[14px] font-medium text-foreground transition-colors hover:text-primary">
                    {p.action?.label}
                    <Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover/p:translate-x-0.5" />
                  </a>
                </div>
              </div>
            </article>
          ))}
        </Stagger>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* test-log                                                            */
/* ------------------------------------------------------------------ */

function TestLog(p: ReviewArticleProps & { t: Texts }) {
  const { t } = p;
  const scale = p.scale ?? 10;
  const fmt = useScore();
  const log = p.log ?? [];
  const scores = log.map((l) => l.score).filter((x): x is number => x !== undefined);
  const lo = Math.min(...scores, scale) - 0.5;
  const hi = Math.max(...scores, 0) + 0.3;
  const W = 600;
  const H = 120;
  const pts = log.map((l, i) => ({ x: round((i / Math.max(1, log.length - 1)) * W), y: l.score === undefined ? null : round(H - 14 - ((l.score - lo) / Math.max(0.1, hi - lo)) * (H - 28)) }));
  const path = pts.filter((q) => q.y !== null).map((q, i) => `${i ? 'L' : 'M'}${q.x} ${q.y}`).join(' ');
  const [ref, inView] = useSeen<HTMLDivElement>();
  const delta = scores.length > 1 ? scores[scores.length - 1] - scores[0] : 0;
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <div className="mx-auto max-w-[48rem]">
          <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
          <div ref={ref} className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] sm:p-6">
            <div className="flex items-baseline justify-between gap-4">
              <p className="text-[13px] font-semibold text-foreground">{t.log}</p>
              {scores.length > 1 && (
                <p className={cn('text-[13px] font-medium tabular-nums', delta >= 0 ? 'text-success-foreground' : 'text-destructive')}>
                  {delta >= 0 ? '+' : ''}
                  {fmt(delta)} {t.since}
                </p>
              )}
            </div>
            {/* The line is drawn in a stretched SVG, points and labels sit in HTML so they keep their size on every width */}
            <div className="relative mx-3 mt-8 h-28 sm:mx-4">
              <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="absolute inset-0 size-full overflow-visible transition-[clip-path] duration-[1600ms] ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ clipPath: inView ? 'inset(-10px -10px -10px -10px)' : 'inset(-10px 100% -10px -10px)' }} aria-hidden>
                <path d={path} fill="none" stroke="var(--primary)" strokeWidth="2.5" vectorEffect="non-scaling-stroke" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
              {pts.map((q, i) =>
                q.y === null ? null : (
                  <span key={i} aria-hidden className="absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center transition-opacity duration-500" style={{ left: `${round((q.x / W) * 100, 3)}%`, top: `${round((q.y / H) * 100, 3)}%`, opacity: inView ? 1 : 0, transitionDelay: `${300 + i * 200}ms` }}>
                    <span className="absolute bottom-[calc(100%+4px)] text-[13px] font-semibold tabular-nums text-foreground">{fmt(log[i].score!)}</span>
                    <span className="size-3 rounded-full border-[2.5px] border-primary bg-card" />
                  </span>
                ),
              )}
            </div>
            <div className="relative mx-3 mt-3 h-4 text-[12px] text-muted-foreground sm:mx-4" aria-hidden>
              {log.map((l, i) => (
                <span key={l.when} className={cn('absolute whitespace-nowrap', i === 0 ? '' : i === log.length - 1 ? '-translate-x-full' : '-translate-x-1/2')} style={{ left: `${round((pts[i].x / W) * 100, 3)}%` }}>
                  {l.when}
                </span>
              ))}
            </div>
          </div>
          <ol className="relative mt-12 space-y-10 pl-8 sm:pl-10">
            <span aria-hidden className="absolute bottom-2 left-[5px] top-2 w-px bg-border" />
            {log.map((l, i) => (
              <Reveal as="li" key={i} effect="rise" className="relative">
                <span aria-hidden className="absolute -left-8 top-[5px] size-[11px] rounded-full border-2 border-primary bg-background sm:-left-10" />
                <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[13.5px] text-muted-foreground">
                  <span className="font-semibold text-foreground">{l.when}</span>
                  {l.mileage && <span className="tabular-nums">{l.mileage}</span>}
                  {l.score !== undefined && <span className="rounded-md bg-muted px-1.5 py-0.5 text-[12.5px] font-semibold tabular-nums text-foreground">{fmt(l.score)}</span>}
                </p>
                <h3 className="mt-2 text-pretty text-[20px] font-semibold leading-[1.3] tracking-[-0.01em] text-foreground">{l.title}</h3>
                <p className="mt-2 text-pretty text-[16px] leading-[1.7] text-muted-foreground">{l.text}</p>
              </Reveal>
            ))}
          </ol>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */

export function ReviewArticle(props: ReviewArticleProps) {
  const t = useTexts(TEXTS, props.labels);
  const { variant = 'verdict' } = props;
  if (variant === 'scores') return <Scores {...props} t={t} />;
  if (variant === 'specs') return <Specs {...props} t={t} />;
  if (variant === 'roundup') return <Roundup {...props} t={t} />;
  if (variant === 'test-log') return <TestLog {...props} t={t} />;
  return <Verdict {...props} t={t} />;
}

Häufige Fragen

Wie installiere ich Testbericht?

Mit aiw add @ai-website/review-article. Der Befehl legt die Datei components/ui/review-article.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, slider, reveal, motion und section. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Testbericht?

5 Varianten, Urteil, Wertung mit Gewichtung, Datenblatt, Vergleichssieger und Dauertest. Du wählst sie mit der Prop variant, zum Beispiel variant="scores".

Kann ich die Texte von Testbericht übersetzen?

Ja. Testbericht bringt 21 eigene Texte auf Deutsch und Englisch mit, etwa „Wertung“, „Was uns gefallen hat“ und „Was besser sein könnte“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Testbericht einbauen?

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

Kann ich Testbericht ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden ReviewArticle-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert