Produkt-Abzeichen
Abzeichen für Neu, Sale, Nachhaltig, Limitiert, Bestseller und Bewertung. Auf Karten, erklärt, als Sticker oder als Filter.
Kleine Labels mit klarer Aufgabe
Ein Produkt-Abzeichen sagt Käufern in einem Wort, warum sich ein zweiter Blick lohnt. Diese decken Neu, Sale, Nachhaltig, Limitiert, Bestseller und Bewertung ab, jede Art mit eigener ruhiger Farbe und festem Platz, ein volles Raster bleibt also ruhig.
„Auf Produktkarten“ stapelt Status-Abzeichen oben links, höchstens zwei, setzt die Bewertung oben rechts und Nachhaltigkeit an den Fuß des Bildes. „Mit Erklärung“ öffnet auf der Produktseite zu jedem Abzeichen eine kurze Erklärung mit Nachweis, immer eine zur Zeit, Escape und ein Klick daneben schließen sie. „Sticker“ sind gezeichnet für Kampagnenbilder, ein langsam drehender Sale-Stern, ein Ring mit umlaufendem Text, ein Eckband für Neuheiten und ein Stempel für Nachhaltiges. „Als Filter“ macht die Abzeichen zu Schaltern über einem Raster, jeder zählt, was übrig bliebe.
Begrenz jede Karte auf zwei Status-Abzeichen, dann behält jedes seine Bedeutung. Belege Aussagen wie Nachhaltig mit der Fassung „Mit Erklärung“. Nimm Sticker für Kampagnenbilder und Banner, Karten lesen sich mit den ruhigen Abzeichen am besten.
Variants
Details
Was Produkt-Abzeichen mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Sale und Aktionen
- Bereich
- E-Commerce
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/product-badges.tsx
- Bringt mit
- utilskitbuttoniconproduct-image
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 18 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ProductBadgesProps aus components/ui/product-badges.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalProductBadgesVarianton-cardsexplainedstickersfilter
eyebrowoptionalstringtitleoptionalstringdescriptionoptionalstringproductsoptionalBadgeProduct[]ringTextoptionalstringstickers: the lines on the ring sticker
currencyoptionalstring
Eingebaute Texte
Diese Texte schreibt Produkt-Abzeichen selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 18 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| new | Englisch New | Deutsch Neu |
| sale | Englisch Sale | Deutsch Sale |
| eco | Englisch Sustainable | Deutsch Nachhaltig |
| limited | Englisch Limited | Deutsch Limitiert |
| limitedOf | Englisch {n} pieces | Deutsch {n} Stück |
| bestseller | Englisch Bestseller | Deutsch Bestseller |
| rating | Englisch {value} of 5 stars | Deutsch {value} von 5 Sternen |
| reviews | Englisch {n} reviews | Deutsch {n} Bewertungen |
| off | Englisch −{pct} | Deutsch −{pct} |
| whatMeans | Englisch What does "{label}" mean? | Deutsch Was bedeutet „{label}“? |
| close | Englisch Close | Deutsch Schließen |
| filters | Englisch Show only | Deutsch Nur zeigen |
| all | Englisch Show all | Deutsch Alle zeigen |
| results | Englisch {n} products | Deutsch {n} Produkte |
| none | Englisch No product carries all of these. Remove a filter to see more. | Deutsch Kein Produkt trägt alle diese Merkmale. Nimm einen Filter heraus, um mehr zu sehen. |
| handmade | Englisch Made by hand | Deutsch Von Hand gemacht |
| salePrice | Englisch Sale price | Deutsch Angebotspreis |
| regularPrice | Englisch Regular price | Deutsch Regulärer Preis |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Produkt-Abzeichen reagiert selbst auf die Tasten Escape.
- Setzt aria-controls, aria-expanded, aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle group.
- 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).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- 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, 501 Zeilen
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, formatPercent, round, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { ProductMedia, ShopMotion, formatNumber, formatPrice } from '@/components/ui/product-image';
/*
* Badges on products: new, sale, sustainable, limited, bestseller and the rating.
*
* on-cards product cards with badges in fixed places: status top left, rating top right, sustainability at the foot
* explained badges on a product page that open a short explanation with the proof behind it
* stickers drawn stickers for campaign pictures: a sale burst, a turning ring, a ribbon, a stamp
* filter the badges as filters above a grid, several at once, with counts
*
* <ProductBadge kind="sale" value={20} /> is exported for your own cards.
*/
export type ProductBadgesVariant = 'on-cards' | 'explained' | 'stickers' | 'filter';
export type BadgeKind = 'new' | 'sale' | 'eco' | 'limited' | 'bestseller' | 'rating' | 'custom';
export interface BadgeData {
kind: BadgeKind;
/** Own text. Without it the badge writes its own (New, −20 %, Limited …) */
label?: string;
/** sale: percent off. rating: stars. limited: pieces. */
value?: number;
/** explained: the short text behind the badge */
detail?: string;
icon?: IconName;
}
export interface BadgeProduct {
id: string;
name: string;
href?: string;
image?: React.ReactNode;
price: number;
compareAt?: number;
subtitle?: string;
badges: BadgeData[];
/** rating count */
reviews?: number;
}
export interface ProductBadgesProps {
variant?: ProductBadgesVariant;
eyebrow?: string;
title?: string;
description?: string;
products?: BadgeProduct[];
/** stickers: the lines on the ring sticker */
ringText?: string;
currency?: string;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
new: 'New',
sale: 'Sale',
eco: 'Sustainable',
limited: 'Limited',
limitedOf: '{n} pieces',
bestseller: 'Bestseller',
rating: '{value} of 5 stars',
reviews: '{n} reviews',
off: '−{pct}',
whatMeans: 'What does "{label}" mean?',
close: 'Close',
filters: 'Show only',
all: 'Show all',
results: '{n} products',
none: 'No product carries all of these. Remove a filter to see more.',
handmade: 'Made by hand',
salePrice: 'Sale price',
regularPrice: 'Regular price',
},
de: {
new: 'Neu',
sale: 'Sale',
eco: 'Nachhaltig',
limited: 'Limitiert',
limitedOf: '{n} Stück',
bestseller: 'Bestseller',
rating: '{value} von 5 Sternen',
reviews: '{n} Bewertungen',
off: '−{pct}',
whatMeans: 'Was bedeutet „{label}“?',
close: 'Schließen',
filters: 'Nur zeigen',
all: 'Alle zeigen',
results: '{n} Produkte',
none: 'Kein Produkt trägt alle diese Merkmale. Nimm einen Filter heraus, um mehr zu sehen.',
handmade: 'Von Hand gemacht',
salePrice: 'Angebotspreis',
regularPrice: 'Regulärer Preis',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, vars: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''));
const currencyOf = (locale: Locale, currency?: string) => currency ?? (locale === 'de' ? 'EUR' : 'USD');
const KIND_ICON: Partial<Record<BadgeKind, IconName>> = { new: 'sparkle', eco: 'leaf', limited: 'hourglass', bestseller: 'trophy', rating: 'star' };
/* Each kind has its own calm color. Warm sale red and green stay readable in both modes through oklab mixes. */
const STYLE: Record<BadgeKind, string> = {
new: 'bg-primary text-primary-foreground',
sale: 'bg-[color-mix(in_oklch,var(--destructive)_88%,black)] text-white',
eco: 'bg-[color-mix(in_oklab,var(--success)_16%,var(--card))] text-success-foreground ring-1 ring-inset ring-[color-mix(in_oklab,var(--success)_35%,transparent)]',
limited: 'bg-foreground text-background',
bestseller: 'bg-[color-mix(in_oklab,var(--warning)_22%,var(--card))] text-warning-foreground ring-1 ring-inset ring-[color-mix(in_oklab,var(--warning)_40%,transparent)]',
rating: 'bg-background/92 text-foreground shadow-[var(--shadow-sm)] backdrop-blur',
custom: 'bg-card text-foreground ring-1 ring-inset ring-border-strong',
};
export function badgeText(b: BadgeData, t: Texts, locale: Locale) {
if (b.label) return b.label;
if (b.kind === 'sale' && b.value) return fill(t.off, { pct: formatPercent(b.value, locale) });
if (b.kind === 'rating') return formatNumber(locale, b.value ?? 0, 1);
if (b.kind === 'custom') return '';
return t[b.kind];
}
/** One badge. Small and quiet on cards, a little larger on product pages. */
export function ProductBadge({ kind, label, value, icon, size = 'sm', className, labels }: BadgeData & { size?: 'sm' | 'md'; className?: string; labels?: Partial<Texts> }) {
const locale = useLocale();
const t = useTexts(TEXTS, labels);
const text = badgeText({ kind, label, value }, t, locale);
const ic = icon ?? KIND_ICON[kind];
return (
<span className={cn('inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-md font-semibold tabular-nums', size === 'sm' ? 'h-6 px-2 text-[11px]' : 'h-7 px-2.5 text-[12px]', STYLE[kind], className)}>
{ic && <Icon name={ic} size={size === 'sm' ? 12 : 13} className={cn(kind === 'rating' && 'fill-[oklch(0.8_0.15_80)] text-[oklch(0.72_0.15_75)]')} />}
{text}
{kind === 'rating' && <span className="sr-only"> {fill(t.rating, { value: formatNumber(locale, value ?? 0, 1) })}</span>}
</span>
);
}
function PriceLine({ p, currency, t }: { p: BadgeProduct; currency: string; t: Texts }) {
const locale = useLocale();
const reduced = p.compareAt && p.compareAt > p.price;
return (
<p className="flex flex-wrap items-baseline gap-x-2 text-[14px] tabular-nums">
{reduced && <span className="sr-only">{t.salePrice}</span>}
<span className={cn('font-medium', reduced ? 'text-[color-mix(in_oklch,var(--destructive)_85%,var(--foreground))]' : 'text-foreground')}>{formatPrice(locale, p.price, currency)}</span>
{reduced && (
<>
<span className="sr-only">{t.regularPrice}</span>
<s className="text-[13px] text-muted-foreground">{formatPrice(locale, p.compareAt!, currency)}</s>
</>
)}
</p>
);
}
function Head({ props }: { props: ProductBadgesProps }) {
if (!props.title) return null;
return (
<div className="mb-8 max-w-[40rem] sm:mb-10">
{props.eyebrow && <p className="mb-3 text-sm font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{props.eyebrow}</p>}
<h2 className="text-balance text-h2 font-semibold text-foreground">{props.title}</h2>
{props.description && <p className="mt-3 text-pretty text-[16px] text-muted-foreground">{props.description}</p>}
</div>
);
}
/** A card with badges in fixed places, so a grid stays calm however many there are */
function BadgeCard({ p, t, currency, index }: { p: BadgeProduct; t: Texts; currency: string; index: number }) {
const status = p.badges.filter((b) => b.kind === 'new' || b.kind === 'sale' || b.kind === 'limited' || b.kind === 'bestseller' || b.kind === 'custom').slice(0, 2);
const rating = p.badges.find((b) => b.kind === 'rating');
const eco = p.badges.find((b) => b.kind === 'eco');
return (
<div className="kit-rise group/card" style={{ '--kit-delay': `${index * 70}ms` } as React.CSSProperties}>
<div className="relative">
<ProductMedia className="aspect-[4/5] rounded-xl [&>*]:transition-transform [&>*]:duration-700 [&>*]:ease-[var(--ease-kit)] group-hover/card:[&>*]:scale-[1.04] motion-reduce:[&>*]:transition-none">{p.image}</ProductMedia>
{status.length > 0 && (
<div className="absolute left-2.5 top-2.5 flex flex-col items-start gap-1.5">
{status.map((b, i) => (
<ProductBadge key={i} {...b} className="kit-rise" />
))}
</div>
)}
{rating && <ProductBadge {...rating} className="absolute right-2.5 top-2.5" />}
{eco && <ProductBadge {...eco} className="absolute bottom-2.5 left-2.5" />}
</div>
<a href={p.href ?? '#'} className="mt-3 block text-[14px] font-medium text-foreground hover:underline hover:underline-offset-4 relative after:absolute after:inset-x-0 after:-inset-y-3">
{p.name}
</a>
{p.subtitle && <p className="text-[13px] text-muted-foreground">{p.subtitle}</p>}
<div className="mt-1">
<PriceLine p={p} currency={currency} t={t} />
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* on-cards */
/* ------------------------------------------------------------------ */
function OnCards(props: ProductBadgesProps & { t: Texts; currency: string }) {
const { t, currency } = props;
return (
<section className={cn('py-16 sm:py-20 lg:py-24', props.className)}>
<div className="kit-container">
<Head props={props} />
<ul className="grid grid-cols-2 gap-x-3 gap-y-8 sm:gap-x-5 lg:grid-cols-4 lg:gap-x-6">
{(props.products ?? []).map((p, i) => (
<li key={p.id}>
<BadgeCard p={p} t={t} currency={currency} index={i} />
</li>
))}
</ul>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* explained */
/* ------------------------------------------------------------------ */
function Explained(props: ProductBadgesProps & { t: Texts; currency: string }) {
const { t, currency } = props;
const locale = useLocale();
const p = props.products?.[0];
const [open, setOpen] = useState<number | null>(null);
const wrap = useRef<HTMLDivElement>(null);
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
const base = useId();
useEffect(() => {
if (open === null) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
buttons.current[open]?.focus();
setOpen(null);
};
const onDown = (e: PointerEvent) => wrap.current && !wrap.current.contains(e.target as Node) && setOpen(null);
window.addEventListener('keydown', onKey);
document.addEventListener('pointerdown', onDown);
return () => { window.removeEventListener('keydown', onKey); document.removeEventListener('pointerdown', onDown); };
}, [open]);
if (!p) return null;
const rating = p.badges.find((b) => b.kind === 'rating');
const others = p.badges.filter((b) => b.kind !== 'rating');
return (
<section className={cn('py-16 sm:py-20 lg:py-24', props.className)}>
<ShopMotion />
<div className="kit-container">
<div className="grid grid-cols-1 items-start gap-8 md:grid-cols-2 lg:gap-16">
<div className="relative">
<ProductMedia className="aspect-square rounded-3xl max-md:aspect-[5/4]">{p.image}</ProductMedia>
<div className="absolute left-4 top-4 flex flex-wrap gap-1.5">
{others.slice(0, 2).map((b, i) => (
<ProductBadge key={i} {...b} size="md" />
))}
</div>
</div>
<div ref={wrap} className="min-w-0">
{props.eyebrow && <p className="mb-2 text-sm font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{props.eyebrow}</p>}
<h2 className="text-balance text-h2 font-semibold text-foreground">{p.name}</h2>
{rating && (
<p className="mt-3 flex items-center gap-2 text-[14px] text-muted-foreground">
<ProductBadge {...rating} size="md" className="shadow-none ring-1 ring-inset ring-border" />
{p.reviews ? fill(t.reviews, { n: formatNumber(locale, p.reviews) }) : null}
</p>
)}
<div className="mt-4">
<PriceLine p={p} currency={currency} t={t} />
</div>
{p.subtitle && <p className="mt-4 text-pretty text-[15px] text-muted-foreground">{p.subtitle}</p>}
<ul className="mt-7 space-y-2 border-t border-border pt-6">
{others.map((b, i) => {
const label = badgeText(b, t, locale);
const isOpen = open === i;
return (
<li key={i} className="relative">
<button
ref={(el) => { buttons.current[i] = el; }}
type="button"
aria-expanded={isOpen}
aria-controls={`${base}-${i}`}
onClick={() => setOpen(isOpen ? null : i)}
className={cn('flex min-h-12 w-full items-center gap-3 rounded-xl border px-3 text-left transition-[border-color,background-color] duration-200', isOpen ? 'border-border-strong bg-surface' : 'border-transparent hover:bg-surface')}
>
<ProductBadge {...b} size="md" />
<span className="min-w-0 flex-1 truncate text-[13px] text-muted-foreground">{fill(t.whatMeans, { label })}</span>
<Icon name="chevron-down" size={15} className={cn('shrink-0 text-muted-foreground transition-transform duration-300 motion-reduce:transition-none', isOpen && 'rotate-180')} />
</button>
<div id={`${base}-${i}`} className={cn('grid transition-[grid-template-rows,opacity] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', isOpen ? 'grid-rows-[1fr] opacity-100' : 'invisible grid-rows-[0fr] opacity-0')}>
<div className="min-h-0 overflow-hidden">
<p className="px-3 pb-3 pt-2 text-pretty text-[14px] leading-relaxed text-foreground">{b.detail}</p>
</div>
</div>
</li>
);
})}
</ul>
</div>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* stickers */
/* ------------------------------------------------------------------ */
/** A sale burst with twelve points */
function Burst({ text, className }: { text: string; className?: string }) {
const pts = Array.from({ length: 24 }, (_, i) => {
const a = (i / 24) * Math.PI * 2 - Math.PI / 2;
const r = i % 2 ? 41 : 50;
return `${round(50 + Math.cos(a) * r)},${round(50 + Math.sin(a) * r)}`;
}).join(' ');
return (
<div className={cn('relative grid size-24 place-items-center sm:size-28', className)}>
<svg viewBox="0 0 100 100" className="absolute inset-0 size-full drop-shadow-[0_6px_10px_rgb(0_0_0/0.18)] motion-safe:animate-[kit-spin_24s_linear_infinite]" aria-hidden>
<polygon points={pts} fill="color-mix(in oklch, var(--destructive) 88%, black)" />
</svg>
<span className="relative -rotate-12 text-center text-[22px] font-bold leading-none tracking-[-0.03em] text-white sm:text-[26px]">{text}</span>
</div>
);
}
/** A round sticker with text running around its edge */
function Ring({ text, center, className }: { text: string; center: React.ReactNode; className?: string }) {
const id = 'r' + useId().replace(/[^a-zA-Z0-9]/g, '');
return (
<div className={cn('relative grid size-28 place-items-center rounded-full bg-foreground text-background shadow-[var(--shadow-lg)] sm:size-32', className)}>
<svg viewBox="0 0 100 100" className="absolute inset-0 size-full motion-safe:animate-[kit-spin_18s_linear_infinite]" aria-hidden>
<defs>
<path id={id} d="M50 50m-38 0a38 38 0 1 1 76 0a38 38 0 1 1 -76 0" />
</defs>
<text fontSize="9.2" fontWeight="600" letterSpacing="2.2" fill="currentColor">
<textPath href={`#${id}`}>{text}</textPath>
</text>
</svg>
<span className="relative grid size-12 place-items-center rounded-full bg-background text-foreground">{center}</span>
</div>
);
}
function Ribbon({ text }: { text: string }) {
return (
<div data-clip-ok className="pointer-events-none absolute right-0 top-0 size-28 overflow-hidden" aria-hidden>
<span className="absolute right-[-2.6rem] top-[1.4rem] w-[10rem] rotate-45 bg-primary py-1.5 text-center text-[12px] font-bold uppercase tracking-[0.14em] text-primary-foreground shadow-[var(--shadow-md)]">{text}</span>
</div>
);
}
function Stamp({ text }: { text: string }) {
return (
<span className="inline-flex -rotate-6 items-center gap-1.5 rounded-lg border-2 border-dashed border-[color-mix(in_oklab,var(--success)_70%,var(--foreground))] bg-background/85 px-3 py-1.5 text-[12px] font-bold uppercase tracking-[0.12em] text-success-foreground backdrop-blur">
<Icon name="leaf" size={14} />
{text}
</span>
);
}
function Stickers(props: ProductBadgesProps & { t: Texts; currency: string }) {
const { t, currency } = props;
const locale = useLocale();
const ps = props.products ?? [];
const sticker = (p: BadgeProduct) => {
const sale = p.badges.find((b) => b.kind === 'sale');
const limited = p.badges.find((b) => b.kind === 'limited');
const eco = p.badges.find((b) => b.kind === 'eco');
const nw = p.badges.find((b) => b.kind === 'new');
return (
<>
{nw && <Ribbon text={badgeText(nw, t, locale)} />}
{sale && (
<div className="absolute -right-2 -top-3 sm:-right-4 sm:-top-4">
<Burst text={badgeText(sale, t, locale)} />
</div>
)}
{limited && (
<div className="absolute -bottom-4 -left-2 sm:-left-4">
<Ring text={props.ringText ?? `${t.limited.toUpperCase()} · ${fill(t.limitedOf, { n: limited.value ?? 0 }).toUpperCase()} · ${t.limited.toUpperCase()} · `} center={<Icon name="hourglass" size={18} />} />
</div>
)}
{eco && (
<div className="absolute bottom-4 right-4">
<Stamp text={badgeText(eco, t, locale)} />
</div>
)}
<span className="sr-only">{p.badges.map((b) => badgeText(b, t, locale)).filter(Boolean).join(', ')}</span>
</>
);
};
return (
<section className={cn('overflow-hidden py-16 sm:py-20 lg:py-24', props.className)}>
<div className="kit-container">
<Head props={props} />
<ul className="grid grid-cols-1 gap-x-8 gap-y-14 px-3 sm:grid-cols-2 sm:px-4 lg:gap-x-12">
{ps.map((p, i) => (
<li key={p.id} className="kit-rise" style={{ '--kit-delay': `${i * 90}ms` } as React.CSSProperties}>
<div className="relative">
<ProductMedia className="aspect-[5/4] rounded-2xl">{p.image}</ProductMedia>
{sticker(p)}
</div>
<div className="mt-6 flex items-baseline justify-between gap-4">
<a href={p.href ?? '#'} className="text-[16px] font-medium text-foreground hover:underline hover:underline-offset-4 relative after:absolute after:inset-x-0 after:-inset-y-3">
{p.name}
</a>
<PriceLine p={p} currency={currency} t={t} />
</div>
</li>
))}
</ul>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* filter */
/* ------------------------------------------------------------------ */
function Filter(props: ProductBadgesProps & { t: Texts; currency: string }) {
const { t, currency } = props;
const locale = useLocale();
const ps = props.products ?? [];
const kinds = (['new', 'sale', 'eco', 'limited', 'bestseller'] as BadgeKind[]).filter((k) => ps.some((p) => p.badges.some((b) => b.kind === k)));
const [on, setOn] = useState<BadgeKind[]>([]);
const shown = ps.filter((p) => on.every((k) => p.badges.some((b) => b.kind === k)));
const count = (k: BadgeKind) => ps.filter((p) => [...on.filter((x) => x !== k), k].every((x) => p.badges.some((b) => b.kind === x))).length;
const key = on.join('-') || 'all';
return (
<section className={cn('py-16 sm:py-20 lg:py-24', props.className)}>
<ShopMotion />
<div className="kit-container">
<Head props={props} />
<div className="mb-8 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div role="group" aria-label={t.filters} className="kit-no-scrollbar relative -mb-1 flex gap-2 overflow-x-auto pb-1 max-sm:kit-fade-end max-sm:pr-6">
{kinds.map((k) => {
const pressed = on.includes(k);
const n = count(k);
return (
<button
key={k}
type="button"
aria-pressed={pressed}
disabled={!pressed && n === 0}
onClick={() => setOn((xs) => (pressed ? xs.filter((x) => x !== k) : [...xs, k]))}
className={cn('flex h-10 shrink-0 items-center gap-2 rounded-full border pl-1.5 pr-3.5 text-[13px] font-medium transition-[border-color,background-color] duration-200 disabled:opacity-40 max-sm:h-11', pressed ? 'border-foreground bg-surface-2' : 'border-border-strong bg-card hover:border-foreground/50')}
>
<ProductBadge kind={k} label={k === 'sale' ? t.sale : undefined} />
<span className="tabular-nums text-muted-foreground">{n}</span>
{pressed && <Icon name="x" size={13} className="-mr-1 animate-[kitshop-pop_0.3s_var(--ease-kit)] text-foreground" />}
</button>
);
})}
</div>
<div className="flex shrink-0 items-center gap-3">
<p className="text-[13px] tabular-nums text-muted-foreground" aria-live="polite">
{fill(t.results, { n: formatNumber(locale, shown.length) })}
</p>
{on.length > 0 && (
<button type="button" onClick={() => setOn([])} className="inline-flex h-10 items-center rounded-md px-2 text-[13px] font-medium text-foreground underline decoration-border-strong underline-offset-4 hover:decoration-foreground max-sm:h-11">
{t.all}
</button>
)}
</div>
</div>
{shown.length ? (
<ul key={key} className="grid grid-cols-2 gap-x-3 gap-y-8 sm:gap-x-5 lg:grid-cols-4 lg:gap-x-6">
{shown.map((p, i) => (
<li key={p.id} className="animate-[kitshop-zoom_0.45s_var(--ease-kit)_both]" style={{ animationDelay: `${Math.min(i, 7) * 50}ms` }}>
<BadgeCard p={p} t={t} currency={currency} index={0} />
</li>
))}
</ul>
) : (
<p className="rounded-xl border border-dashed border-border-strong p-8 text-center text-[14px] text-muted-foreground">{t.none}</p>
)}
</div>
</section>
);
}
/**
* Product badges for new, sale, sustainable, limited, bestseller and the
* rating: in fixed places on cards, explained on a product page, as drawn
* stickers on campaign pictures, or as filters above a grid.
*/
export function ProductBadges(props: ProductBadgesProps) {
const locale = useLocale();
const t = useTexts(TEXTS, props.labels);
const currency = currencyOf(locale, props.currency);
const { variant = 'on-cards' } = props;
if (variant === 'explained') return <Explained {...props} t={t} currency={currency} />;
if (variant === 'stickers') return <Stickers {...props} t={t} currency={currency} />;
if (variant === 'filter') return <Filter {...props} t={t} currency={currency} />;
return <OnCards {...props} t={t} currency={currency} />;
}Häufige Fragen
Wie installiere ich Produkt-Abzeichen?
Mit aiw add @ai-website/product-badges. Der Befehl legt die Datei components/ui/product-badges.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon und product-image. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Produkt-Abzeichen?
4 Varianten, Auf Produktkarten, Mit Erklärung, Sticker und Als Filter. Du wählst sie mit der Prop variant, zum Beispiel variant="explained".
Kann ich die Texte von Produkt-Abzeichen übersetzen?
Ja. Produkt-Abzeichen bringt 18 eigene Texte auf Deutsch und Englisch mit, etwa „Neu“, „Sale“ und „Nachhaltig“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Produkt-Abzeichen einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Produkt-Abzeichen mit aiw_components und installieren sie mit aiw_add @ai-website/product-badges. Nenn im Prompt die Variante, etwa variant="on-cards", dann schreibt der Assistent deine Inhalte direkt in die Props.