Shop-Versprechen
Was ein Shop zu Versand, Rückgabe, Bezahlung und Service verspricht. Eine ruhige Leiste unter dem Header, Karten mit Details, der Balken bis zum Gratisversand und eine Geld-zurück-Garantie mit Siegel.
Eine von 4 Components in Versand und Filialen
Variants
Details
Was Shop-Versprechen mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Versand und Filialen
- Bereich
- E-Commerce
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/shop-benefits.tsx
- Bringt mit
- utilskiticonrevealmotionsectionproduct-image
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 11 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ShopBenefitsProps aus components/ui/shop-benefits.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalShopBenefitsVariantstripcardsfree-shippingguarantee
eyebrowoptionalstringtitleoptionalstringdescriptionoptionalstringitemsoptionalShopBenefit[]thresholdoptionalnumberfree-shipping: the threshold and what is in the cart
subtotaloptionalnumberstepoptionalnumberfree-shipping: the step buttons in the demo add this much
daysoptionalnumberguarantee: the number on the seal, e.g. 30
termsoptionalstring[]guarantee: the fine print as short lines
actionoptional{ label: string; href: string }currencyoptionalstring
Eingebaute Texte
Diese Texte schreibt Shop-Versprechen selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 11 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| benefits | Englisch Our promises | Deutsch Unsere Versprechen |
| more | Englisch Details | Deutsch Details |
| toGo | Englisch more for free shipping | Deutsch bis zum kostenlosen Versand |
| reached | Englisch Free shipping unlocked | Deutsch Versand ist kostenlos |
| inCart | Englisch In your cart | Deutsch Im Warenkorb |
| add | Englisch Add | Deutsch Hinzufügen |
| remove | Englisch Remove | Deutsch Entfernen |
| days | Englisch days | Deutsch Tage |
| seal | Englisch Money back guarantee | Deutsch Geld-zurück-Garantie |
| previous | Englisch Previous promise | Deutsch Vorheriges Versprechen |
| next | Englisch Next promise | Deutsch Nächstes Versprechen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-label, aria-live, aria-valuemax, aria-valuemin und aria-valuenow, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle progressbar.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- 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. 4 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 347 Zeilen
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } 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, TweenPrice, formatPrice } from '@/components/ui/product-image';
/*
* What a shop promises: shipping, returns, payment, service. The small
* strip that sits under the header or above the footer of almost every shop.
*
* strip a quiet row of four promises with icons, a rotating line on phones
* cards the promises as cards with a short text and a link to the details
* free-shipping the free shipping threshold as a bar that fills with the cart
* guarantee one big promise with its seal and the fine print people want to see
*/
export type ShopBenefitsVariant = 'strip' | 'cards' | 'free-shipping' | 'guarantee';
export interface ShopBenefit {
icon?: IconName;
title: string;
text?: string;
href?: string;
}
export interface ShopBenefitsProps {
variant?: ShopBenefitsVariant;
eyebrow?: string;
title?: string;
description?: string;
items?: ShopBenefit[];
/** free-shipping: the threshold and what is in the cart */
threshold?: number;
subtotal?: number;
/** free-shipping: the step buttons in the demo add this much */
step?: number;
/** guarantee: the number on the seal, e.g. 30 */
days?: number;
/** guarantee: the fine print as short lines */
terms?: string[];
action?: { label: string; href: string };
currency?: string;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
benefits: 'Our promises',
more: 'Details',
toGo: 'more for free shipping',
reached: 'Free shipping unlocked',
inCart: 'In your cart',
add: 'Add',
remove: 'Remove',
days: 'days',
seal: 'Money back guarantee',
previous: 'Previous promise',
next: 'Next promise',
},
de: {
benefits: 'Unsere Versprechen',
more: 'Details',
toGo: 'bis zum kostenlosen Versand',
reached: 'Versand ist kostenlos',
inCart: 'Im Warenkorb',
add: 'Hinzufügen',
remove: 'Entfernen',
days: 'Tage',
seal: 'Geld-zurück-Garantie',
previous: 'Vorheriges Versprechen',
next: 'Nächstes Versprechen',
},
};
type Texts = (typeof TEXTS)['en'];
/** "€60.00" reads as "€60" on a scale */
const whole = (price: string) => price.split(/[.,]00(?=\D|$)/).join('');
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 ACCENT_TEXT = 'text-[color-mix(in_oklab,var(--primary)_78%,var(--foreground))]';
const ACCENT_SOFT = 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)]';
/* ------------------------------------------------------------------ */
/* strip */
/* ------------------------------------------------------------------ */
function Strip(p: ShopBenefitsProps & { t: Texts }) {
const { t } = p;
const items = p.items ?? [];
const [i, setI] = useState(0);
const [paused, setPaused] = useState(false);
// Phones show one promise at a time, the next one slides in every four seconds
useEffect(() => {
if (paused || items.length < 2 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const x = window.setInterval(() => setI((n) => (n + 1) % items.length), 4000);
return () => window.clearInterval(x);
}, [paused, items.length]);
const cur = items[i];
return (
<section aria-label={t.benefits} className={cn('border-y border-border bg-surface', p.className)}>
<ShopMotion />
<div className="kit-container">
{/* Tablet and desktop: all promises in a row, separated by hairlines */}
<Stagger step={70} as="ul" className="hidden grid-cols-2 md:grid lg:grid-cols-4">
{items.map((b, k) => (
<li key={b.title} className={cn('flex items-center gap-3 py-4 md:px-5 lg:justify-center', k > 0 && 'lg:border-s lg:border-border', k % 2 === 1 && 'md:border-s md:border-border lg:border-s', k >= 2 && 'md:border-t md:border-border lg:border-t-0')}>
<span className={cn('grid size-9 shrink-0 place-items-center rounded-full', ACCENT_SOFT, ACCENT_TEXT)}>
<Icon name={b.icon ?? 'check'} size={17} />
</span>
<span className="min-w-0">
{b.href ? (
<a href={b.href} className={cn('block truncate rounded-sm text-[14px] font-medium text-foreground hover:underline hover:decoration-border-strong hover:underline-offset-4', FOCUS)}>
{b.title}
</a>
) : (
<span className="block truncate text-[14px] font-medium text-foreground">{b.title}</span>
)}
{b.text && <span className="block truncate text-[12.5px] text-muted-foreground">{b.text}</span>}
</span>
</li>
))}
</Stagger>
{/* Phones: one promise, arrows and a quiet progress */}
<div className="flex items-center gap-2 py-2 md:hidden" onPointerDown={() => setPaused(true)} onFocus={() => setPaused(true)}>
<button type="button" onClick={() => setI((n) => (n - 1 + items.length) % items.length)} aria-label={t.previous} className={cn('grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground hover:text-foreground', FOCUS)}>
<Icon name="chevron-left" size={16} />
</button>
<div className="relative min-w-0 flex-1 overflow-hidden text-center" aria-live={paused ? 'polite' : 'off'}>
{cur && (
<p key={i} className="flex animate-[kitshop-next_0.45s_var(--ease-kit)] items-center justify-center gap-2 text-[13.5px] font-medium text-foreground">
<Icon name={cur.icon ?? 'check'} size={15} className={ACCENT_TEXT} />
<span className="truncate">{cur.title}</span>
</p>
)}
</div>
<button type="button" onClick={() => setI((n) => (n + 1) % items.length)} aria-label={t.next} className={cn('grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground hover:text-foreground', FOCUS)}>
<Icon name="chevron-right" size={16} />
</button>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* cards */
/* ------------------------------------------------------------------ */
function Cards(p: ShopBenefitsProps & { t: Texts }) {
const { t } = p;
const items = p.items ?? [];
return (
<Section className={p.className}>
<ShopMotion />
{p.title && (
<div className="mb-10 max-w-[40rem] sm:mb-12">
{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>}
</div>
)}
<Stagger step={80} as="ul" className={cn('grid grid-cols-1 gap-4 sm:grid-cols-2 lg:gap-5', items.length >= 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3')}>
{items.map((b) => (
<li key={b.title}>
<article className={cn(CARD, 'group/b relative flex h-full flex-col overflow-hidden p-6 transition-[transform,box-shadow] duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-lg)] motion-reduce:transition-none')}>
<span aria-hidden data-clip-ok className="pointer-events-none absolute -right-10 -top-10 size-32 rounded-full bg-[radial-gradient(circle,color-mix(in_oklab,var(--primary)_18%,transparent),transparent_70%)] opacity-0 transition-opacity duration-500 group-hover/b:opacity-100" />
<span className={cn('relative grid size-12 place-items-center rounded-xl transition-transform duration-500 ease-[var(--ease-kit)] group-hover/b:-rotate-6 group-hover/b:scale-105', ACCENT_SOFT, ACCENT_TEXT)}>
<Icon name={b.icon ?? 'check'} size={22} />
</span>
<h3 className="relative mt-5 text-[16.5px] font-semibold text-foreground">{b.title}</h3>
{b.text && <p className="relative mt-1.5 text-pretty text-[14px] leading-relaxed text-muted-foreground">{b.text}</p>}
{b.href && (
<a href={b.href} className={cn('relative mt-auto inline-flex items-center gap-1.5 pt-5 text-[14px] font-medium text-foreground after:absolute after:inset-0 after:content-[""]', FOCUS)} aria-label={`${b.title}, ${t.more}`}>
{t.more}
<Icon name="arrow-right" size={14} className="transition-transform duration-300 group-hover/b:translate-x-1" />
</a>
)}
</article>
</li>
))}
</Stagger>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* free-shipping */
/* ------------------------------------------------------------------ */
function FreeShipping(p: ShopBenefitsProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const threshold = p.threshold ?? 60;
const step = p.step ?? 14.9;
const [sub, setSub] = useState(p.subtotal ?? 0);
const [ref, inView] = useInView<HTMLDivElement>();
const share = Math.min(1, sub / threshold);
const left = Math.max(0, threshold - sub);
const reached = left <= 0;
const wasReached = useRef(reached);
const [burst, setBurst] = useState(0);
useEffect(() => {
if (reached && !wasReached.current) setBurst((b) => b + 1);
wasReached.current = reached;
}, [reached]);
return (
<Section spacing="sm" className={p.className}>
<ShopMotion />
<div ref={ref} className={cn(CARD, 'mx-auto max-w-[40rem] p-5 sm:p-6')}>
<div className="flex items-center gap-4">
<span key={burst} className={cn('grid size-11 shrink-0 place-items-center rounded-full transition-colors duration-500', reached ? 'animate-[kitshop-pop_0.5s_var(--ease-kit)] bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-success-foreground' : cn(ACCENT_SOFT, ACCENT_TEXT))}>
<Icon name={reached ? 'check' : 'truck'} size={19} />
</span>
<p className="min-w-0 flex-1 text-[15px] text-foreground" aria-live="polite">
<Swap value={reached}>
{reached ? (
<span className="font-semibold text-success-foreground">{t.reached}</span>
) : (
<span>
<span className="font-semibold tabular-nums">{formatPrice(locale, left, currency)}</span> {t.toGo}
</span>
)}
</Swap>
</p>
</div>
<div className="relative mt-5 h-2.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" role="progressbar" aria-valuemin={0} aria-valuemax={threshold} aria-valuenow={round(Math.min(sub, threshold))} aria-label={t.toGo}>
<span className={cn('absolute inset-0 origin-left rounded-full transition-[transform,background-color] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', reached ? 'bg-success' : 'bg-primary')} style={{ transform: `scaleX(${inView ? round(share, 3) : 0})` }} />
{/* A light that runs along the filled part */}
<span aria-hidden className="absolute inset-y-0 left-0 w-full overflow-hidden" style={{ clipPath: `inset(0 ${round((1 - (inView ? share : 0)) * 100, 2)}% 0 0)` }}>
<span className="absolute inset-0 animate-[kit-shine_2.4s_linear_infinite] bg-[linear-gradient(90deg,transparent_30%,rgb(255_255_255/0.4)_50%,transparent_70%)] bg-[length:200%_100%] motion-reduce:hidden" />
</span>
</div>
<div className="mt-2 flex justify-between text-[12px] tabular-nums text-muted-foreground" aria-hidden>
<span>{whole(formatPrice(locale, 0, currency))}</span>
<span>{whole(formatPrice(locale, threshold, currency))}</span>
</div>
{p.description && <p className="mt-3 text-pretty text-[13px] text-muted-foreground">{p.description}</p>}
<div className="mt-5 flex items-center justify-between gap-3 border-t border-border pt-4">
<span className="text-[13.5px] text-muted-foreground">
{t.inCart} <TweenPrice value={sub} currency={currency} className="font-semibold text-foreground" />
</span>
<span className="flex gap-2">
<button type="button" onClick={() => setSub((s) => Math.max(0, round(s - step)))} disabled={sub <= 0} aria-label={t.remove} className={cn('grid size-11 place-items-center rounded-lg border border-border-strong text-foreground transition-colors hover:bg-accent disabled:opacity-40 sm:size-10', FOCUS)}>
<Icon name="minus" size={15} />
</button>
<button type="button" onClick={() => setSub((s) => round(s + step))} aria-label={`${t.add} ${formatPrice(locale, step, currency)}`} className={cn('inline-flex h-11 items-center gap-1.5 rounded-lg border border-border-strong px-3 text-[13.5px] font-medium text-foreground transition-colors hover:bg-accent sm:h-10', FOCUS)}>
<Icon name="plus" size={15} />
<span className="tabular-nums">{formatPrice(locale, step, currency)}</span>
</button>
</span>
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* guarantee */
/* ------------------------------------------------------------------ */
function Guarantee(p: ShopBenefitsProps & { t: Texts }) {
const { t } = p;
const [ref, inView] = useInView<HTMLDivElement>();
const days = p.days ?? 30;
const ticks = 36;
return (
<Section className={p.className}>
<ShopMotion />
<div className={cn(CARD, 'grid grid-cols-1 items-center gap-8 overflow-hidden p-6 sm:p-10 md:grid-cols-[auto_minmax(0,40rem)] md:justify-center md:gap-14 lg:py-14')}>
{/* The seal: a ring of ticks that draw in, the number counts on top */}
<div ref={ref} className="relative mx-auto grid size-48 place-items-center sm:size-56" aria-hidden>
<svg viewBox="0 0 200 200" className="absolute inset-0 size-full">
{Array.from({ length: ticks }, (_, k) => {
const a = (k / ticks) * Math.PI * 2 - Math.PI / 2;
const r1 = 88;
const r2 = k % 3 === 0 ? 76 : 80;
return (
<line
key={k}
x1={round(100 + Math.cos(a) * r1)}
y1={round(100 + Math.sin(a) * r1)}
x2={round(100 + Math.cos(a) * r2)}
y2={round(100 + Math.sin(a) * r2)}
strokeWidth={k % 3 === 0 ? 2.4 : 1.4}
strokeLinecap="round"
className={cn('stroke-primary transition-opacity duration-300 motion-reduce:transition-none', inView ? 'opacity-100' : 'opacity-0')}
style={{ transitionDelay: inView ? `${k * 22}ms` : '0ms' }}
/>
);
})}
<circle cx="100" cy="100" r="66" className="fill-[color-mix(in_oklab,var(--primary)_10%,var(--card))] stroke-[color-mix(in_oklab,var(--primary)_35%,transparent)]" strokeWidth="1" />
</svg>
<span className="relative text-center">
<span className="block text-[56px] font-semibold leading-none tracking-[-0.05em] tabular-nums text-foreground">{days}</span>
<span className="mt-1 block text-[13px] font-medium uppercase tracking-[0.14em] text-muted-foreground">{t.days}</span>
</span>
</div>
<div>
{p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
<h2 className="text-balance text-h2 font-semibold text-foreground">{p.title ?? t.seal}</h2>
{p.description && <p className="mt-4 max-w-[36rem] text-pretty text-lead text-muted-foreground">{p.description}</p>}
{p.terms?.length ? (
<Stagger step={70} as="ul" className="mt-6 grid grid-cols-1 gap-2.5 sm:grid-cols-2">
{p.terms.map((term) => (
<li key={term} className="flex items-start gap-2.5 text-[14px] leading-snug text-foreground">
<Icon name="check-circle" size={16} className="mt-px shrink-0 text-success-foreground" />
{term}
</li>
))}
</Stagger>
) : null}
{p.action && (
<a href={p.action.href} className={cn('group/a mt-7 inline-flex min-h-11 items-center gap-2 rounded-md text-[14.5px] font-medium text-foreground', FOCUS)}>
{p.action.label}
<Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover/a:translate-x-1" />
</a>
)}
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* The block */
/* ------------------------------------------------------------------ */
/**
* The promises of a shop in four layouts. strip: a quiet row under the
* header. cards: promises with text and details. free-shipping: the
* threshold as a bar. guarantee: one promise with its seal and terms.
*/
export function ShopBenefits(props: ShopBenefitsProps) {
const { variant = 'strip' } = props;
const t = useTexts(TEXTS, props.labels);
if (variant === 'cards') return <Cards {...props} t={t} />;
if (variant === 'free-shipping') return <FreeShipping {...props} t={t} />;
if (variant === 'guarantee') return <Guarantee {...props} t={t} />;
return <Strip {...props} t={t} />;
}Häufige Fragen
Wie installiere ich Shop-Versprechen?
Mit aiw add @ai-website/shop-benefits. Der Befehl legt die Datei components/ui/shop-benefits.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, reveal, motion, section und product-image. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Shop-Versprechen?
4 Varianten, Versprechensleiste, Versprechenskarten, Balken bis Gratisversand und Garantie. Du wählst sie mit der Prop variant, zum Beispiel variant="cards".
Kann ich die Texte von Shop-Versprechen übersetzen?
Ja. Shop-Versprechen bringt 11 eigene Texte auf Deutsch und Englisch mit, etwa „Unsere Versprechen“, „Details“ und „bis zum kostenlosen Versand“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Shop-Versprechen einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Shop-Versprechen mit aiw_components und installieren sie mit aiw_add @ai-website/shop-benefits. Nenn im Prompt die Variante, etwa variant="strip", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Shop-Versprechen ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden ShopBenefits-Code zum Kopieren.