Zum Inhalt springen
UI Kit
Accent

Zuletzt angesehene Produkte

Zuletzt angesehene Produkte, immer mit Bildern. Ein Karussell, eine schmale Leiste, ein Verlauf mit Preissenkungen oder Weitermachen.

4 VariantsEine von 4 Components in Products

Zurück zu dem, was gefallen hat

Die meisten Käufer vergleichen, bevor sie kaufen, und verlieren dabei den Überblick. „Zuletzt angesehen“ holt zurück, was sie sich angesehen haben, immer mit Bildern, der Weg zurück zu einem Produkt ist also ein Klick.

Das „Karussell“ ist eine einrastende Reihe aus Bildkarten mit Pfeilen, Pfeiltasten und weichen Kanten, wo mehr folgt, jede Karte lässt sich aus dem Verlauf nehmen und der ganze Verlauf löschen, beides mit Rückgängig. Die „Schmale Leiste“ setzt runde Bilder in ein Band am Fuß einer Produktseite, Hover und Fokus zeigen Name und Preis, ein roter Punkt markiert einen niedrigeren Preis. „Nach Tagen“ gruppiert Besuche in heute, gestern und früher, mit Filtern für Preissenkungen und letzte Stücke. „Weitermachen“ zeigt das letzte Produkt groß mit den damals gewählten Optionen und einem Knopf, der genau diese in den Warenkorb legt.

Setz die Leiste an den Fuß von Produktseiten und das Karussell für wiederkehrende Besucher auf die Startseite. Weise auf Preissenkungen hin, sie sind der beste Grund zurückzukommen. Lass Menschen den Verlauf löschen, das schafft Vertrauen.

Variants

Schmale LeisteNeu in 1.2

Runde Bilder in einer schmalen Leiste für das Ende einer Produktseite. Hover und Fokus öffnen eine Karte mit Name und Preis, ein roter Punkt zeigt einen gesenkten Preis, am Telefon scrollt die Reihe.

Nach TagenNeu in 1.2

Gruppiert in heute, gestern und früher mit der Uhrzeit jedes Besuchs. Filter mit Zahlen zeigen Preissenkungen oder die letzten Stücke, jeder Eintrag sagt, wie viel günstiger er jetzt ist.

WeitermachenNeu in 1.2

Das letzte Produkt groß mit der damals gewählten Ausführung und einem Knopf, der genau diese hinzufügt. Drei weitere stehen auf dem Desktop daneben, auf Tablet und Telefon darunter.

Details

Was Zuletzt angesehen mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Products
Bereich
E-Commerce
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/recently-viewed.tsx
Bringt mit
utilskitbuttoniconproduct-imagemotion
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 27 eigene Texte
Studio
Im Code anpassen
In Sammlungen
Online-Shop

Props

Die Schnittstelle RecentlyViewedProps aus components/ui/recently-viewed.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    RecentlyViewedVariant

    carouselstriptimelinecontinue

  • eyebrowoptional
    string
  • titleoptional
    string
  • itemsoptional
    ViewedItem[]
  • todayoptional
    string

    "Today" for the groups, ISO date. Default the real today.

  • historyHrefoptional
    string

    strip: link to the full history

  • onRemoveoptional
    (id: string) => void
  • onClearoptional
    () => void
  • onAddoptional
    (item: ViewedItem) => void
  • currencyoptional
    string

Eingebaute Texte

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

Alle 27 Texte anzeigen
titleEnglisch Recently viewedDeutsch Zuletzt angesehen
clearEnglisch Clear historyDeutsch Verlauf leeren
clearedEnglisch History cleared.Deutsch Verlauf geleert.
undoEnglisch UndoDeutsch Rückgängig
removeEnglisch Remove {name} from historyDeutsch {name} aus dem Verlauf entfernen
removedEnglisch {name} removed.Deutsch {name} entfernt.
previousEnglisch Previous productsDeutsch Vorherige Produkte
nextEnglisch Next productsDeutsch Nächste Produkte
emptyEnglisch Products you look at show up here.Deutsch Produkte, die du ansiehst, erscheinen hier.
todayEnglisch TodayDeutsch Heute
yesterdayEnglisch YesterdayDeutsch Gestern
earlierEnglisch EarlierDeutsch Früher
allEnglisch AllDeutsch Alle
dropsEnglisch Price droppedDeutsch Preis gesenkt
lowEnglisch Few leftDeutsch Fast weg
dropByEnglisch {amount} less than when you lookedDeutsch {amount} günstiger als bei deinem Besuch
lowStockEnglisch Only a few leftDeutsch Nur noch wenige da
continueTitleEnglisch Pick up where you left offDeutsch Mach weiter, wo du warst
youPickedEnglisch You pickedDeutsch Deine Auswahl
backEnglisch Back to the productDeutsch Zurück zum Produkt
addEnglisch Add to cartDeutsch In den Warenkorb
addedEnglisch In the cartDeutsch Im Warenkorb
viewedAtEnglisch Viewed {time}Deutsch Angesehen {time}
moreEnglisch Also viewedDeutsch Auch angesehen
filterEnglisch FilterDeutsch Filter
noneEnglisch Nothing matches this filter.Deutsch Zu diesem Filter passt nichts.
viewAllEnglisch See all {n}Deutsch Alle {n} ansehen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Zuletzt angesehen reagiert selbst auf die Tasten Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-describedby und aria-label, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen status und tooltip.
  • 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, 525 Zeilen
components/ui/recently-viewed.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, formatTime, round, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Stagger } from '@/components/ui/motion';
import { FlyUp, ProductMedia, ShopMotion, Swap, formatPrice } from '@/components/ui/product-image';

/*
 * What the visitor looked at before, always with pictures.
 *
 *   carousel  a row of cards with arrows, each removable, the whole history clearable with undo
 *   strip     a slim bar of round pictures, a card with name and price opens on hover and focus
 *   timeline  grouped by day with the time, filters for price drops and low stock
 *   continue  the last product large with the options picked back then, three more beside it
 */

export type RecentlyViewedVariant = 'carousel' | 'strip' | 'timeline' | 'continue';

export interface ViewedItem {
  id: string;
  name: string;
  href?: string;
  image: React.ReactNode;
  price: number;
  /** The price when it was viewed, a lower price now shows as a drop */
  priceThen?: number;
  subtitle?: string;
  /** "YYYY-MM-DD" and "HH:MM" of the visit */
  date?: string;
  time?: string;
  /** Options picked back then, e.g. "Stone · Size 42" */
  picked?: string;
  /** Few pieces left */
  lowStock?: boolean;
}

export interface RecentlyViewedProps {
  variant?: RecentlyViewedVariant;
  eyebrow?: string;
  title?: string;
  items?: ViewedItem[];
  /** "Today" for the groups, ISO date. Default the real today. */
  today?: string;
  /** strip: link to the full history */
  historyHref?: string;
  onRemove?: (id: string) => void;
  onClear?: () => void;
  onAdd?: (item: ViewedItem) => void;
  currency?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    title: 'Recently viewed',
    clear: 'Clear history',
    cleared: 'History cleared.',
    undo: 'Undo',
    remove: 'Remove {name} from history',
    removed: '{name} removed.',
    previous: 'Previous products',
    next: 'Next products',
    empty: 'Products you look at show up here.',
    today: 'Today',
    yesterday: 'Yesterday',
    earlier: 'Earlier',
    all: 'All',
    drops: 'Price dropped',
    low: 'Few left',
    dropBy: '{amount} less than when you looked',
    lowStock: 'Only a few left',
    continueTitle: 'Pick up where you left off',
    youPicked: 'You picked',
    back: 'Back to the product',
    add: 'Add to cart',
    added: 'In the cart',
    viewedAt: 'Viewed {time}',
    more: 'Also viewed',
    filter: 'Filter',
    none: 'Nothing matches this filter.',
    viewAll: 'See all {n}',
  },
  de: {
    title: 'Zuletzt angesehen',
    clear: 'Verlauf leeren',
    cleared: 'Verlauf geleert.',
    undo: 'Rückgängig',
    remove: '{name} aus dem Verlauf entfernen',
    removed: '{name} entfernt.',
    previous: 'Vorherige Produkte',
    next: 'Nächste Produkte',
    empty: 'Produkte, die du ansiehst, erscheinen hier.',
    today: 'Heute',
    yesterday: 'Gestern',
    earlier: 'Früher',
    all: 'Alle',
    drops: 'Preis gesenkt',
    low: 'Fast weg',
    dropBy: '{amount} günstiger als bei deinem Besuch',
    lowStock: 'Nur noch wenige da',
    continueTitle: 'Mach weiter, wo du warst',
    youPicked: 'Deine Auswahl',
    back: 'Zurück zum Produkt',
    add: 'In den Warenkorb',
    added: 'Im Warenkorb',
    viewedAt: 'Angesehen {time}',
    more: 'Auch angesehen',
    filter: 'Filter',
    none: 'Zu diesem Filter passt nichts.',
    viewAll: 'Alle {n} ansehen',
  },
};

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 dropOf = (i: ViewedItem) => (i.priceThen && i.priceThen > i.price ? round(i.priceThen - i.price) : 0);

/** The list with remove, clear and one undo for the last step */
function useHistory(initial: ViewedItem[], props: RecentlyViewedProps) {
  const [items, setItems] = useState(initial);
  const [undo, setUndo] = useState<{ items: ViewedItem[]; text: string } | null>(null);
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
  useEffect(() => () => clearTimeout(timer.current), []);
  const keep = (text: string) => {
    setUndo({ items, text });
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setUndo(null), 6000);
  };
  return {
    items,
    undo,
    remove: (i: ViewedItem, t: Texts) => {
      keep(fill(t.removed, { name: i.name }));
      setItems((xs) => xs.filter((x) => x.id !== i.id));
      props.onRemove?.(i.id);
    },
    clear: (t: Texts) => {
      keep(t.cleared);
      setItems([]);
      props.onClear?.();
    },
    restore: () => {
      if (undo) setItems(undo.items);
      setUndo(null);
    },
  };
}

function UndoBar({ h, t }: { h: ReturnType<typeof useHistory>; t: Texts }) {
  return (
    <div role="status" className="min-h-0">
      {h.undo && (
        <div className="mb-4 flex animate-[kitshop-in_0.35s_var(--ease-kit)] items-center justify-between gap-3 rounded-lg bg-surface-2 px-4 py-2 text-[13px] text-muted-foreground">
          <span className="min-w-0 truncate">{h.undo.text}</span>
          <button type="button" onClick={h.restore} className="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-md px-2 font-medium text-foreground hover:bg-accent max-sm:h-11">
            <Icon name="undo" size={14} />
            {t.undo}
          </button>
        </div>
      )}
    </div>
  );
}

function PriceLine({ i, currency, size = 'sm' }: { i: ViewedItem; currency: string; size?: 'sm' | 'md' }) {
  const locale = useLocale();
  const drop = dropOf(i);
  return (
    <p className={cn('flex flex-wrap items-baseline gap-x-1.5 tabular-nums', size === 'md' ? 'text-[16px]' : 'text-[13px]')}>
      <span className={cn('font-medium', drop ? 'text-[color-mix(in_oklch,var(--destructive)_85%,var(--foreground))]' : 'text-foreground')}>{formatPrice(locale, i.price, currency)}</span>
      {drop > 0 && <s className="text-[12px] text-muted-foreground">{formatPrice(locale, i.priceThen!, currency)}</s>}
    </p>
  );
}

function Empty({ t }: { t: Texts }) {
  return (
    <div className="flex items-center gap-4 rounded-2xl border border-dashed border-border-strong p-6">
      <span className="grid size-11 shrink-0 place-items-center rounded-xl bg-muted text-muted-foreground">
        <Icon name="history" size={20} />
      </span>
      <p className="text-[14px] text-muted-foreground">{t.empty}</p>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* carousel                                                            */
/* ------------------------------------------------------------------ */

function Carousel(props: RecentlyViewedProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const h = useHistory(props.items ?? [], props);
  const row = useRef<HTMLUListElement>(null);
  const [edge, setEdge] = useState<'start' | 'mid' | 'end' | 'none'>('start');
  const update = () => {
    const el = row.current;
    if (!el) return;
    if (el.scrollWidth <= el.clientWidth + 2) return setEdge('none');
    setEdge(el.scrollLeft < 4 ? 'start' : el.scrollLeft + el.clientWidth >= el.scrollWidth - 4 ? 'end' : 'mid');
  };
  useEffect(() => {
    update();
    window.addEventListener('resize', update);
    return () => window.removeEventListener('resize', update);
  }, [h.items.length]);
  const step = (d: 1 | -1) => row.current?.scrollBy({ left: d * row.current.clientWidth * 0.85, behavior: 'smooth' });
  return (
    <section className={cn('py-14 sm:py-16 lg:py-20', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <div className="mb-6 flex items-end justify-between gap-4">
          <div>
            {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-h3 font-semibold text-foreground">{props.title ?? t.title}</h2>
          </div>
          <div className="flex items-center gap-1">
            {h.items.length > 0 && (
              <button type="button" onClick={() => h.clear(t)} className="mr-2 inline-flex h-10 items-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-sm:h-11 max-sm:min-w-11 max-sm:justify-center">
                <Icon name="trash" size={14} />
                <span className="max-sm:sr-only">{t.clear}</span>
              </button>
            )}
            <button type="button" onClick={() => step(-1)} disabled={edge === 'start' || edge === 'none'} aria-label={t.previous} className="grid size-10 place-items-center rounded-full border border-border text-foreground transition-colors hover:bg-accent disabled:opacity-35 max-sm:hidden">
              <Icon name="chevron-left" size={17} />
            </button>
            <button type="button" onClick={() => step(1)} disabled={edge === 'end' || edge === 'none'} aria-label={t.next} className="grid size-10 place-items-center rounded-full border border-border text-foreground transition-colors hover:bg-accent disabled:opacity-35 max-sm:hidden">
              <Icon name="chevron-right" size={17} />
            </button>
          </div>
        </div>
        <UndoBar h={h} t={t} />
        {h.items.length ? (
          <ul
            ref={row}
            onScroll={update}
            tabIndex={0}
            aria-label={props.title ?? t.title}
            onKeyDown={(e) => {
              if (e.key === 'ArrowRight') { e.preventDefault(); step(1); }
              if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); }
            }}
            className={cn('kit-no-scrollbar relative flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2 sm:gap-4', edge === 'start' && 'kit-fade-end', edge === 'mid' && 'kit-fade-x', edge === 'end' && 'kit-fade-start')}
          >
            {h.items.map((i, n) => (
              <li key={i.id} className="kit-rise group/card relative w-[44%] shrink-0 snap-start sm:w-[30%] md:w-[23%] lg:w-[calc((100%-5rem)/6)]" style={{ '--kit-delay': `${Math.min(n, 6) * 60}ms` } as React.CSSProperties}>
                <a href={i.href ?? '#'} className="block rounded-xl">
                  <ProductMedia className="aspect-[4/5] rounded-xl [&>*]:transition-transform [&>*]:duration-700 [&>*]:ease-[var(--ease-kit)] group-hover/card:[&>*]:scale-[1.05] motion-reduce:[&>*]:transition-none">{i.image}</ProductMedia>
                  <p className="mt-2.5 line-clamp-1 text-[14px] font-medium text-foreground">{i.name}</p>
                </a>
                <PriceLine i={i} currency={currency} />
                <button
                  type="button"
                  onClick={() => h.remove(i, t)}
                  aria-label={fill(t.remove, { name: i.name })}
                  className="absolute right-2 top-2 grid size-8 place-items-center rounded-full bg-background/90 text-muted-foreground shadow-[var(--shadow-sm)] backdrop-blur transition-[opacity,color] after:absolute after:-inset-1.5 hover:text-foreground sm:opacity-0 sm:group-hover/card:opacity-100 sm:group-focus-within/card:opacity-100 sm:focus-visible:opacity-100 [@media(hover:none)]:opacity-100"
                >
                  <Icon name="x" size={14} />
                </button>
              </li>
            ))}
          </ul>
        ) : (
          <Empty t={t} />
        )}
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* strip                                                               */
/* ------------------------------------------------------------------ */

function Strip(props: RecentlyViewedProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const items = props.items ?? [];
  const [hover, setHover] = useState<number | null>(null);
  const base = useId();
  return (
    <section className={cn('py-10 sm:py-12', props.className)}>
      <div className="kit-container">
        <div className="rounded-2xl border border-border bg-card px-4 py-3 shadow-[var(--highlight),var(--shadow-sm)] sm:px-5">
          <div className="flex items-center gap-4">
            <h2 className="flex shrink-0 items-center gap-2 text-[13px] font-medium text-foreground">
              <Icon name="history" size={16} className="text-muted-foreground" />
              <span className="max-sm:sr-only">{props.title ?? t.title}</span>
            </h2>
            <span aria-hidden className="h-8 w-px shrink-0 bg-border max-sm:hidden" />
            <ul className="kit-no-scrollbar relative -my-2 flex min-w-0 flex-1 gap-2 overflow-x-auto py-2 sm:gap-2.5 sm:overflow-visible" aria-label={props.title ?? t.title}>
              {items.map((i, n) => (
                <li key={i.id} className="relative shrink-0" onMouseEnter={() => setHover(n)} onMouseLeave={() => setHover(null)}>
                  <a
                    href={i.href ?? '#'}
                    aria-describedby={`${base}-${n}`}
                    onFocus={() => setHover(n)}
                    onBlur={() => setHover(null)}
                    className="kit-rise block rounded-full"
                    style={{ '--kit-delay': `${n * 50}ms` } as React.CSSProperties}
                  >
                    <ProductMedia className={cn('size-11 rounded-full ring-2 transition-[box-shadow,transform] duration-300 ease-[var(--ease-kit)] lg:size-14', hover === n ? 'scale-105 ring-primary' : 'ring-transparent')}>{i.image}</ProductMedia>
                    <span className="sr-only">{i.name}</span>
                    {dropOf(i) > 0 && <span aria-hidden className="absolute right-0 top-0 size-3 rounded-full bg-[var(--destructive)] ring-2 ring-card" />}
                  </a>
                  {/* Name and price above the picture, from 640 px. On phones the picture is the link. */}
                  <div
                    id={`${base}-${n}`}
                    role="tooltip"
                    data-clip-ok
                    className={cn(
                      'pointer-events-none absolute bottom-[calc(100%+0.5rem)] left-1/2 z-20 w-48 -translate-x-1/2 rounded-xl border border-border bg-popover p-3 text-popover-foreground shadow-[var(--shadow-lg)] transition-[opacity,translate,visibility] duration-200 max-sm:sr-only',
                      hover === n ? 'visible translate-y-0 opacity-100' : 'invisible translate-y-1 opacity-0',
                      n === 0 && 'left-0 translate-x-0',
                    )}
                  >
                    <p className="text-[13px] font-medium text-foreground">{i.name}</p>
                    <PriceLine i={i} currency={currency} />
                    {dropOf(i) > 0 && <p className="mt-1 text-[11px] font-medium text-[color-mix(in_oklch,var(--destructive)_80%,var(--foreground))]">{t.drops}</p>}
                  </div>
                </li>
              ))}
            </ul>
            <a href={props.historyHref ?? '#'} className="ml-auto inline-flex h-11 shrink-0 items-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-foreground hover:bg-accent max-lg:hidden">
              {fill(t.viewAll, { n: items.length })}
              <Icon name="arrow-right" size={14} />
            </a>
          </div>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* timeline                                                            */
/* ------------------------------------------------------------------ */

function Timeline(props: RecentlyViewedProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const h = useHistory(props.items ?? [], props);
  const [filter, setFilter] = useState<'all' | 'drops' | 'low'>('all');
  const today = props.today ?? new Date().toISOString().slice(0, 10);
  const yesterday = new Date(new Date(today + 'T12:00:00Z').getTime() - 86400000).toISOString().slice(0, 10);
  const shown = h.items.filter((i) => (filter === 'drops' ? dropOf(i) > 0 : filter === 'low' ? i.lowStock : true));
  const groups = [
    { key: 'today', label: t.today, items: shown.filter((i) => i.date === today) },
    { key: 'yesterday', label: t.yesterday, items: shown.filter((i) => i.date === yesterday) },
    { key: 'earlier', label: t.earlier, items: shown.filter((i) => i.date !== today && i.date !== yesterday) },
  ].filter((g) => g.items.length);
  const counts = { all: h.items.length, drops: h.items.filter((i) => dropOf(i) > 0).length, low: h.items.filter((i) => i.lowStock).length };
  const name = useId();
  return (
    <section className={cn('py-14 sm:py-16 lg:py-20', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <div className="mb-6 flex flex-wrap items-end justify-between gap-4">
          <div>
            {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-h3 font-semibold text-foreground">{props.title ?? t.title}</h2>
          </div>
          {h.items.length > 0 && (
            <button type="button" onClick={() => h.clear(t)} className="inline-flex h-10 items-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-sm:h-11 max-sm:min-w-11 max-sm:justify-center">
              <Icon name="trash" size={14} />
              {t.clear}
            </button>
          )}
        </div>
        <fieldset className="mb-6 min-w-0">
          <legend className="sr-only">{t.filter}</legend>
          <div className="kit-no-scrollbar relative flex gap-2 overflow-x-auto max-sm:kit-fade-end max-sm:pr-6">
            {(['all', 'drops', 'low'] as const).map((f) => (
              <label key={f} className={cn('flex h-10 shrink-0 cursor-pointer items-center gap-2 rounded-full border px-4 text-[13px] font-medium transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid max-sm:h-11', filter === f ? 'border-foreground bg-foreground text-background' : 'border-border-strong text-foreground hover:border-foreground/50')}>
                <input type="radio" name={name} checked={filter === f} onChange={() => setFilter(f)} className="sr-only" />
                {f === 'drops' && <Icon name="trending-down" size={14} />}
                {f === 'low' && <Icon name="hourglass" size={14} />}
                {t[f]}
                <span className={cn('tabular-nums', filter === f ? 'text-background/70' : 'text-muted-foreground')}>{counts[f]}</span>
              </label>
            ))}
          </div>
        </fieldset>
        <UndoBar h={h} t={t} />
        {!h.items.length ? (
          <Empty t={t} />
        ) : !groups.length ? (
          <p className="rounded-xl border border-dashed border-border-strong p-6 text-center text-[14px] text-muted-foreground">{t.none}</p>
        ) : (
          <div className="space-y-10">
            {groups.map((g) => (
              <section key={g.key + filter} aria-label={g.label}>
                <h3 className="mb-4 flex items-center gap-3 text-[14px] font-semibold text-foreground">
                  {g.label}
                  <span className="h-px flex-1 bg-border" aria-hidden />
                </h3>
                <Stagger as="ul" step={60} className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
                  {g.items.map((i) => (
                    <li key={i.id} className="group/row relative flex gap-4 rounded-xl border border-border bg-card p-3 transition-shadow hover:shadow-[var(--shadow-md)]">
                      <ProductMedia className="size-24 shrink-0 rounded-lg">{i.image}</ProductMedia>
                      <div className="flex min-w-0 flex-1 flex-col py-0.5 pr-8">
                        <a href={i.href ?? '#'} className="text-[14px] font-medium text-foreground after:absolute after:inset-0 after:rounded-xl hover:underline hover:underline-offset-4">
                          {i.name}
                        </a>
                        {i.time && <p className="text-[12px] text-muted-foreground">{fill(t.viewedAt, { time: formatTime(i.time, locale) })}</p>}
                        <div className="mt-auto pt-2">
                          <PriceLine i={i} currency={currency} />
                          {dropOf(i) > 0 && <p className="text-[12px] font-medium text-[color-mix(in_oklch,var(--destructive)_80%,var(--foreground))]">{fill(t.dropBy, { amount: formatPrice(locale, dropOf(i), currency) })}</p>}
                          {i.lowStock && (
                            <p className="flex items-center gap-1 text-[12px] font-medium text-warning-foreground">
                              <Icon name="hourglass" size={12} />
                              {t.lowStock}
                            </p>
                          )}
                        </div>
                      </div>
                      <button type="button" onClick={() => h.remove(i, t)} aria-label={fill(t.remove, { name: i.name })} className="absolute right-1.5 top-1.5 z-10 grid size-9 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-sm:size-11">
                        <Icon name="x" size={15} />
                      </button>
                    </li>
                  ))}
                </Stagger>
              </section>
            ))}
          </div>
        )}
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* continue                                                            */
/* ------------------------------------------------------------------ */

function Continue(props: RecentlyViewedProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const [first, ...rest] = props.items ?? [];
  const [added, setAdded] = useState(false);
  const [fly, setFly] = useState(0);
  if (!first) return null;
  return (
    <section className={cn('py-14 sm:py-16 lg:py-20', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <h2 className="mb-6 text-h3 font-semibold text-foreground">{props.title ?? t.continueTitle}</h2>
        <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)] lg:gap-6">
          <article className="kit-rise grid grid-cols-1 overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)] sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
            <a href={first.href ?? '#'} className="group/hero block" tabIndex={-1} aria-hidden>
              <ProductMedia className="aspect-[4/3] rounded-none sm:aspect-auto sm:h-full [&>*]:transition-transform [&>*]:duration-700 [&>*]:ease-[var(--ease-kit)] group-hover/hero:[&>*]:scale-[1.04] motion-reduce:[&>*]:transition-none">{first.image}</ProductMedia>
            </a>
            <div className="flex flex-col p-5 sm:p-7">
              <h3 className="text-[20px] font-semibold tracking-[-0.02em] text-foreground">{first.name}</h3>
              {first.subtitle && <p className="mt-1 text-[14px] text-muted-foreground">{first.subtitle}</p>}
              <div className="mt-3">
                <PriceLine i={first} currency={currency} size="md" />
              </div>
              {first.picked && (
                <div className="mt-5 rounded-xl bg-surface-2 p-3.5">
                  <p className="text-[12px] font-medium text-muted-foreground">{t.youPicked}</p>
                  <p className="mt-0.5 text-[14px] font-medium text-foreground">{first.picked}</p>
                </div>
              )}
              <div className="mt-6 flex flex-col gap-2 sm:mt-auto sm:pt-6">
                <span className="relative">
                  <Button onClick={() => { setAdded(true); setFly((f) => f + 1); props.onAdd?.(first); }} className="w-full overflow-hidden">
                    <Swap value={added} className="gap-2">
                      <Icon name={added ? 'check' : 'bag'} size={16} />
                      {added ? t.added : t.add}
                    </Swap>
                  </Button>
                  <FlyUp count={fly} className="-top-5" />
                </span>
                <Button href={first.href ?? '#'} variant="ghost" icon="arrow-right">
                  {t.back}
                </Button>
              </div>
            </div>
          </article>
          {rest.length > 0 && (
            <div>
              <h3 className="mb-3 text-[13px] font-medium text-muted-foreground lg:sr-only">{t.more}</h3>
              <Stagger as="ul" step={80} className="grid grid-cols-1 gap-3 min-[480px]:grid-cols-3 lg:grid-cols-1">
                {rest.slice(0, 3).map((i) => (
                  <li key={i.id} className="group/mini relative flex gap-3 rounded-xl border border-border bg-card p-2.5 transition-shadow hover:shadow-[var(--shadow-md)] min-[480px]:max-lg:flex-col">
                    <ProductMedia className="size-20 shrink-0 rounded-lg min-[480px]:max-lg:aspect-square min-[480px]:max-lg:size-auto">{i.image}</ProductMedia>
                    <div className="min-w-0 py-1">
                      <a href={i.href ?? '#'} className="line-clamp-2 text-[14px] font-medium text-foreground after:absolute after:inset-0 after:rounded-xl">
                        {i.name}
                      </a>
                      {i.picked && <p className="mt-0.5 truncate text-[12px] text-muted-foreground">{i.picked}</p>}
                      <div className="mt-1">
                        <PriceLine i={i} currency={currency} />
                      </div>
                    </div>
                  </li>
                ))}
              </Stagger>
            </div>
          )}
        </div>
      </div>
    </section>
  );
}

/**
 * Recently viewed products, always with pictures: a carousel with remove
 * and undo, a slim strip of round pictures, a timeline by day with price
 * drops, or the last product large to pick up where the visitor left off.
 */
export function RecentlyViewed(props: RecentlyViewedProps) {
  const locale = useLocale();
  const t = useTexts(TEXTS, props.labels);
  const currency = currencyOf(locale, props.currency);
  const { variant = 'carousel' } = props;
  if (variant === 'strip') return <Strip {...props} t={t} currency={currency} />;
  if (variant === 'timeline') return <Timeline {...props} t={t} currency={currency} />;
  if (variant === 'continue') return <Continue {...props} t={t} currency={currency} />;
  return <Carousel {...props} t={t} currency={currency} />;
}

Häufige Fragen

Wie installiere ich Zuletzt angesehen?

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

Welche Varianten hat Zuletzt angesehen?

4 Varianten, Karussell, Schmale Leiste, Nach Tagen und Weitermachen. Du wählst sie mit der Prop variant, zum Beispiel variant="strip".

Kann ich die Texte von Zuletzt angesehen übersetzen?

Ja. Zuletzt angesehen bringt 27 eigene Texte auf Deutsch und Englisch mit, etwa „Zuletzt angesehen“, „Verlauf leeren“ und „Verlauf geleert.“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Zuletzt angesehen einbauen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert