Zum Inhalt springen
UI Kit
Accent

Flash Sale mit Countdown

Eine Aktion mit tickender Uhr und knappem Vorrat. Ein Band, ein Angebotsraster, ein Angebot des Tages und Drops mit Erinnerung.

4 VariantsEine von 2 Components in Sale und Aktionen

Eine Uhr, ein Vorratsbalken und ein klares Angebot

Ein Flash Sale lebt von zwei Signalen, die Zeit läuft und der Vorrat wird knapp. Diese Component zeigt beides, Ziffern, die jede Sekunde weiterrollen, und Balken, die sich mit jedem verkauften Stück füllen, und nach dem Ende sagt sie, wann die nächsten Angebote starten.

Das „Countdown-Band“ ist ein dunkles Band mit wanderndem Licht, großen Ziffern und einer Handlungsaufforderung. Das „Angebotsraster“ zeigt vier Angebote mit der Uhr im Kopf, jeder Balken füllt sich beim Scrollen, wird ab 80 Prozent warm und zeigt die restlichen Stücke, ausverkaufte Karten werden grau. Das „Angebot des Tages“ zeigt ein Produkt groß mit einem Ring an der Ecke, der sich mit der Restzeit leert, dazu Vorratsbalken und eine Menge bis zum Limit je Kunde. „Drops nach Uhrzeit“ zeigt die Zeitfenster eines Tages als Reiter mit Pfeiltasten, das laufende ist kaufbar, spätere zeigen einen unscharfen Vorgeschmack und nehmen eine E-Mail für die Erinnerung.

Lass jeden Countdown auf ein echtes Ende zulaufen, diese Ehrlichkeit bringt Kunden zurück. Nimm Drops für Neuheiten mit mehreren Zeitfenstern am Tag. Stell das Band auf die Startseite und das Raster auf eine eigene Sale-Seite.

Variants

Countdown-BandNeu in 1.2

Ein dunkles Band mit wanderndem Licht, der Countdown in großen Ziffern, die jede Sekunde rollen, und ein Aufruf. Nach dem Ende sagt es, wann die nächsten Angebote starten.

AngebotsrasterNeu in 1.2

Vier Angebote mit der Uhr im Kopf. Jede Karte hat einen Vorratsbalken, der sich beim Scrollen füllt, ab 80 Prozent warm wird und die Stückzahl nennt, und graut die Karte aus, wenn sie ausverkauft ist.

Angebot des TagesNeu in 1.2

Ein Produkt groß mit einem Ring auf der Ecke, der sich mit der Restzeit leert. Preis, Ersparnis, die tickende Uhr, der Vorratsbalken und eine Menge, die beim Limit pro Person endet.

Drops nach UhrzeitNeu in 1.2

Die Zeitfenster eines Tages als Tabs mit Pfeiltasten, das laufende mit pulsierendem Punkt. Dessen Angebote sind kaufbar, spätere zeigen einen unscharfen ersten Blick und nehmen eine E-Mail für die Erinnerung.

Details

Was Flash Sale mitbringt, aus Registry und Quellcode gelesen.

Bereich
E-Commerce
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/flash-sale.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 31 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle FlashSaleProps aus components/ui/flash-sale.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    FlashSaleVariant

    bannerdeal-griddeal-of-daydrops

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • endsInoptional
    number

    Seconds until the sale ends

  • dealsoptional
    FlashDeal[]
  • dropsoptional
    FlashDrop[]
  • limitoptional
    number

    Most pieces per customer (deal-of-day)

  • ctaoptional
    { label: string; href: string }
  • onAddoptional
    (deal: FlashDeal, quantity: number) => void
  • onNotifyoptional
    (email: string, time: string) => void
  • currencyoptional
    string

Eingebaute Texte

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

Alle 31 Texte anzeigen
endsInEnglisch Ends inDeutsch Endet in
endedEnglisch This sale has endedDeutsch Diese Aktion ist vorbei
endedTextEnglisch New deals start every day at 9:00.Deutsch Neue Angebote starten jeden Tag um 09:00.
daysEnglisch DaysDeutsch Tage
hoursEnglisch HoursDeutsch Std.
minutesEnglisch MinDeutsch Min.
secondsEnglisch SecDeutsch Sek.
claimedEnglisch {pct} claimedDeutsch {pct} vergriffen
almostGoneEnglisch Almost goneDeutsch Fast weg
soldOutEnglisch Sold outDeutsch Ausverkauft
onlyLeftEnglisch Only {n} leftDeutsch Nur noch {n}
addEnglisch Add to cartDeutsch In den Warenkorb
addedEnglisch AddedDeutsch Im Warenkorb
addNamedEnglisch Add {name} to cartDeutsch {name} in den Warenkorb
saveEnglisch Save {amount}Deutsch Spare {amount}
offEnglisch −{pct}Deutsch −{pct}
limitEnglisch Up to {n} per customerDeutsch Bis zu {n} pro Person
quantityEnglisch QuantityDeutsch Menge
liveEnglisch Live nowDeutsch Jetzt live
nextEnglisch NextDeutsch Als Nächstes
laterEnglisch LaterDeutsch Später
startsAtEnglisch Starts at {time}Deutsch Startet um {time}
notifyTitleEnglisch Get a reminderDeutsch Erinnerung bekommen
notifyTextEnglisch We send one email when this drop goes live. Nothing else.Deutsch Wir schicken eine E-Mail, wenn dieser Drop live geht. Sonst nichts.
emailEnglisch Email addressDeutsch E-Mail-Adresse
notifyEnglisch Remind meDeutsch Erinnere mich
notifiedEnglisch Done. We write to {email} at {time}.Deutsch Erledigt. Wir schreiben dir um {time} an {email}.
eEmptyEnglisch Enter your email address for the reminder.Deutsch Gib deine E-Mail-Adresse für die Erinnerung ein.
eEmailEnglisch This email address looks incomplete. Check the part after the @.Deutsch Diese E-Mail-Adresse wirkt unvollständig. Prüf den Teil nach dem @.
previewEnglisch A first lookDeutsch Ein erster Blick
slotsEnglisch Drops todayDeutsch Drops heute

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Flash Sale reagiert selbst auf die Tasten Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
  • Setzt aria-controls, aria-describedby, aria-invalid, aria-label, aria-labelledby, aria-selected, aria-valuemax, aria-valuemin und aria-valuenow, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen progressbar, status, tab, tablist, tabpanel und timer.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • 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. 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, 643 Zeilen
components/ui/flash-sale.tsx
'use client';

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

/*
 * A sale with a clock and a stock that runs low.
 *
 *   banner        a dark band with the countdown in large digits and one call to action
 *   deal-grid     deal cards with a claimed bar each, "almost gone" from 80 percent, sold out at 100
 *   deal-of-day   one product large, the countdown, stock left and a limit per customer
 *   drops         time slots as tabs, the live one shows its deals, the next ones take an email for a reminder
 *
 * `endsIn` counts down from mount, so server and browser show the same first value.
 */

export type FlashSaleVariant = 'banner' | 'deal-grid' | 'deal-of-day' | 'drops';

export interface FlashDeal {
  id: string;
  name: string;
  subtitle?: string;
  href?: string;
  image?: React.ReactNode;
  price: number;
  compareAt: number;
  /** Share already sold, 0 to 100 */
  claimed?: number;
  /** Pieces left, shown when low */
  left?: number;
}

export interface FlashDrop {
  /** "HH:MM" */
  time: string;
  label?: string;
  status: 'live' | 'next' | 'later';
  deals: FlashDeal[];
}

export interface FlashSaleProps {
  variant?: FlashSaleVariant;
  eyebrow?: string;
  title?: string;
  description?: string;
  /** Seconds until the sale ends */
  endsIn?: number;
  deals?: FlashDeal[];
  drops?: FlashDrop[];
  /** Most pieces per customer (deal-of-day) */
  limit?: number;
  cta?: { label: string; href: string };
  onAdd?: (deal: FlashDeal, quantity: number) => void;
  onNotify?: (email: string, time: string) => void;
  currency?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    endsIn: 'Ends in',
    ended: 'This sale has ended',
    endedText: 'New deals start every day at 9:00.',
    days: 'Days',
    hours: 'Hours',
    minutes: 'Min',
    seconds: 'Sec',
    claimed: '{pct} claimed',
    almostGone: 'Almost gone',
    soldOut: 'Sold out',
    onlyLeft: 'Only {n} left',
    add: 'Add to cart',
    added: 'Added',
    addNamed: 'Add {name} to cart',
    save: 'Save {amount}',
    off: '−{pct}',
    limit: 'Up to {n} per customer',
    quantity: 'Quantity',
    live: 'Live now',
    next: 'Next',
    later: 'Later',
    startsAt: 'Starts at {time}',
    notifyTitle: 'Get a reminder',
    notifyText: 'We send one email when this drop goes live. Nothing else.',
    email: 'Email address',
    notify: 'Remind me',
    notified: 'Done. We write to {email} at {time}.',
    eEmpty: 'Enter your email address for the reminder.',
    eEmail: 'This email address looks incomplete. Check the part after the @.',
    preview: 'A first look',
    slots: 'Drops today',
  },
  de: {
    endsIn: 'Endet in',
    ended: 'Diese Aktion ist vorbei',
    endedText: 'Neue Angebote starten jeden Tag um 09:00.',
    days: 'Tage',
    hours: 'Std.',
    minutes: 'Min.',
    seconds: 'Sek.',
    claimed: '{pct} vergriffen',
    almostGone: 'Fast weg',
    soldOut: 'Ausverkauft',
    onlyLeft: 'Nur noch {n}',
    add: 'In den Warenkorb',
    added: 'Im Warenkorb',
    addNamed: '{name} in den Warenkorb',
    save: 'Spare {amount}',
    off: '−{pct}',
    limit: 'Bis zu {n} pro Person',
    quantity: 'Menge',
    live: 'Jetzt live',
    next: 'Als Nächstes',
    later: 'Später',
    startsAt: 'Startet um {time}',
    notifyTitle: 'Erinnerung bekommen',
    notifyText: 'Wir schicken eine E-Mail, wenn dieser Drop live geht. Sonst nichts.',
    email: 'E-Mail-Adresse',
    notify: 'Erinnere mich',
    notified: 'Erledigt. Wir schreiben dir um {time} an {email}.',
    eEmpty: 'Gib deine E-Mail-Adresse für die Erinnerung ein.',
    eEmail: 'Diese E-Mail-Adresse wirkt unvollständig. Prüf den Teil nach dem @.',
    preview: 'Ein erster Blick',
    slots: 'Drops heute',
  },
};

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 pctOff = (d: FlashDeal) => Math.round((1 - d.price / d.compareAt) * 100);

/** Seconds left, ticking once a second from mount */
function useCountdown(endsIn = 0) {
  const [left, setLeft] = useState(endsIn);
  useEffect(() => {
    const end = Date.now() + endsIn * 1000;
    setLeft(endsIn);
    const id = setInterval(() => setLeft(Math.max(0, Math.round((end - Date.now()) / 1000))), 1000);
    return () => clearInterval(id);
  }, [endsIn]);
  return left;
}

const parts = (s: number) => ({ d: Math.floor(s / 86400), h: Math.floor((s % 86400) / 3600), m: Math.floor((s % 3600) / 60), s: s % 60 });
const two = (n: number) => String(n).padStart(2, '0');

/** One unit of the clock, the digits roll when they change */
function Unit({ value, label, size }: { value: number; label: string; size: 'xl' | 'md' | 'sm' }) {
  const first = useRef(value);
  const text = two(value);
  return (
    <div className="flex flex-col items-center">
      <span
        className={cn(
          'relative grid place-items-center overflow-hidden rounded-xl font-semibold tabular-nums [clip-path:inset(0_round_0.75rem)]',
          size === 'xl' && 'h-16 w-14 bg-white/8 text-[34px] ring-1 ring-white/12 sm:h-24 sm:w-[5.25rem] sm:text-[52px]',
          size === 'md' && 'h-14 w-14 bg-surface-2 text-[28px] text-foreground ring-1 ring-border',
          size === 'sm' && 'h-9 w-10 rounded-lg bg-foreground text-[17px] text-background',
        )}
      >
        <span key={text} className={cn('block', text !== two(first.current) && 'animate-[kitshop-up_0.42s_var(--ease-kit)]')}>
          {text}
        </span>
      </span>
      <span className={cn('mt-1.5 text-[11px] font-medium uppercase tracking-[0.08em]', size === 'xl' ? 'text-white/60' : 'text-muted-foreground')}>{label}</span>
    </div>
  );
}

function Clock({ left, t, size = 'md', className }: { left: number; t: Texts; size?: 'xl' | 'md' | 'sm'; className?: string }) {
  const p = parts(left);
  const units = [
    ...(p.d ? [{ v: p.d, l: t.days }] : []),
    { v: p.h, l: t.hours },
    { v: p.m, l: t.minutes },
    { v: p.s, l: t.seconds },
  ];
  const spoken = `${p.d ? `${p.d} ${t.days}, ` : ''}${p.h} ${t.hours}, ${p.m} ${t.minutes}`;
  return (
    <div className={cn('flex items-start', size === 'sm' ? 'gap-1' : 'gap-1.5 sm:gap-3', className)} role="timer" aria-label={`${t.endsIn} ${spoken}`}>
      {units.map((u, i) => (
        <div key={u.l} className="flex items-start gap-1.5 sm:gap-3" aria-hidden>
          {i > 0 && size !== 'sm' && <span className={cn('font-semibold', size === 'xl' ? 'mt-3 text-[24px] text-white/35 sm:mt-6 sm:text-[36px]' : 'mt-3 text-[22px] text-border-strong')}>:</span>}
          <Unit value={u.v} label={u.l} size={size} />
        </div>
      ))}
    </div>
  );
}

function Claimed({ deal, t, className }: { deal: FlashDeal; t: Texts; className?: string }) {
  const locale = useLocale();
  const [ref, inView] = useInView<HTMLDivElement>();
  const c = deal.claimed ?? 0;
  const hot = c >= 80;
  return (
    <div ref={ref} className={className}>
      <div className="mb-1.5 flex items-center justify-between gap-2 text-[12px]">
        <span className={cn('font-medium', hot ? 'text-[color-mix(in_oklch,var(--destructive)_80%,var(--foreground))]' : 'text-muted-foreground')}>
          {c >= 100 ? t.soldOut : hot ? (deal.left ? fill(t.onlyLeft, { n: deal.left }) : t.almostGone) : fill(t.claimed, { pct: formatPercent(c, locale) })}
        </span>
        {hot && c < 100 && <span className="tabular-nums text-muted-foreground">{formatPercent(c, locale)}</span>}
      </div>
      <div className="h-1.5 overflow-hidden rounded-full bg-track" role="progressbar" aria-label={fill(t.claimed, { pct: '' }).trim()} aria-valuenow={c} aria-valuemin={0} aria-valuemax={100}>
        <div className={cn('h-full origin-left rounded-full transition-transform duration-[1200ms] ease-[var(--ease-kit)] motion-reduce:transition-none', hot ? 'bg-[color-mix(in_oklch,var(--destructive)_85%,var(--primary))]' : 'bg-primary')} style={{ transform: `scaleX(${inView ? c / 100 : 0})` }} />
      </div>
    </div>
  );
}

function OffBadge({ deal, t, className }: { deal: FlashDeal; t: Texts; className?: string }) {
  const locale = useLocale();
  return <span className={cn('inline-flex h-6 items-center rounded-md bg-[color-mix(in_oklch,var(--destructive)_88%,black)] px-2 text-[12px] font-semibold tabular-nums text-white', className)}>{fill(t.off, { pct: formatPercent(pctOff(deal), locale) })}</span>;
}

function PriceRow({ deal, currency, size = 'md' }: { deal: FlashDeal; currency: string; size?: 'md' | 'xl' }) {
  const locale = useLocale();
  return (
    <p className="flex flex-wrap items-baseline gap-x-2 tabular-nums">
      <span className={cn('font-semibold text-foreground', size === 'xl' ? 'text-[32px] tracking-[-0.03em]' : 'text-[16px]')}>{formatPrice(locale, deal.price, currency)}</span>
      <s className={cn('text-muted-foreground', size === 'xl' ? 'text-[17px]' : 'text-[13px]')}>{formatPrice(locale, deal.compareAt, currency)}</s>
    </p>
  );
}

function AddButton({ deal, t, onAdd, quantity = 1, size = 'md', className }: { deal: FlashDeal; t: Texts; onAdd?: FlashSaleProps['onAdd']; quantity?: number; size?: 'md' | 'lg'; className?: string }) {
  const [added, setAdded] = useState(false);
  const [fly, setFly] = useState(0);
  const out = (deal.claimed ?? 0) >= 100;
  return (
    <span className={cn('relative block', className)}>
      <Button
        size={size}
        variant={out ? 'secondary' : 'primary'}
        disabled={out}
        aria-label={out ? `${t.soldOut}, ${deal.name}` : fill(t.addNamed, { name: deal.name })}
        onClick={() => { setAdded(true); setFly((f) => f + 1); onAdd?.(deal, quantity); }}
        className="w-full overflow-hidden"
      >
        <Swap value={out ? 'out' : added} className="gap-2">
          <Icon name={out ? 'ban' : added ? 'check' : 'bag'} size={16} />
          {out ? t.soldOut : added ? t.added : t.add}
        </Swap>
      </Button>
      <FlyUp count={fly} label={`+${quantity}`} className="-top-5" />
    </span>
  );
}

function Head({ props, left, t }: { props: FlashSaleProps; left: number; t: Texts }) {
  return (
    <div className="mb-8 flex flex-col gap-6 sm:mb-10 lg:flex-row lg:items-end lg:justify-between">
      <div className="max-w-[38rem]">
        {props.eyebrow && (
          <p className="mb-3 inline-flex items-center gap-2 text-sm font-medium text-[color-mix(in_oklch,var(--destructive)_75%,var(--foreground))]">
            <Icon name="bolt" size={15} />
            {props.eyebrow}
          </p>
        )}
        {props.title && <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>
      {left > 0 ? (
        <div className="shrink-0">
          <p className="mb-2 text-[12px] font-medium text-muted-foreground">{t.endsIn}</p>
          <Clock left={left} t={t} size="md" />
        </div>
      ) : (
        <p className="text-[14px] font-medium text-muted-foreground">{t.ended}</p>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* banner                                                              */
/* ------------------------------------------------------------------ */

function Banner(props: FlashSaleProps & { t: Texts }) {
  const { t } = props;
  const left = useCountdown(props.endsIn);
  return (
    <section className={cn('py-10 sm:py-14', props.className)}>
      <div className="kit-container">
        <div data-clip-ok className="dark relative isolate overflow-hidden rounded-3xl bg-bezel px-6 py-10 text-white sm:px-10 sm:py-12 lg:px-14">
          <div aria-hidden className="absolute -right-24 -top-32 -z-10 size-[28rem] rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklab,var(--primary)_55%,transparent),transparent)] opacity-70 motion-safe:animate-[kit-float_9s_ease-in-out_infinite]" />
          <div aria-hidden className="absolute -bottom-40 -left-24 -z-10 size-[26rem] rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklch,var(--destructive)_40%,transparent),transparent)] opacity-60" />
          <svg aria-hidden viewBox="0 0 400 400" className="absolute -right-10 bottom-0 -z-10 h-full opacity-[0.07] max-md:hidden">
            <path d="M230 20 120 220h80l-30 160 140-220h-90Z" fill="white" />
          </svg>
          <div className="grid grid-cols-1 items-center gap-8 lg:grid-cols-[minmax(0,1fr)_auto] lg:gap-14">
            <div className="kit-rise">
              {props.eyebrow && (
                <p className="inline-flex h-7 items-center gap-1.5 rounded-full bg-white/10 px-3 text-[12px] font-semibold uppercase tracking-[0.1em] text-white ring-1 ring-white/15">
                  <Icon name="bolt" size={13} />
                  {props.eyebrow}
                </p>
              )}
              {props.title && <h2 className="mt-5 text-balance text-h1 font-semibold text-white">{props.title}</h2>}
              {props.description && <p className="mt-4 max-w-[32rem] text-pretty text-[16px] text-white/70">{props.description}</p>}
              {props.cta && left > 0 && (
                <Button href={props.cta.href} size="lg" icon="arrow-right" magnetic className="mt-8 max-sm:w-full">
                  {props.cta.label}
                </Button>
              )}
            </div>
            <div className="kit-rise" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>
              {left > 0 ? (
                <>
                  <p className="mb-3 text-[13px] font-medium text-white/60 lg:text-center">{t.endsIn}</p>
                  <Clock left={left} t={t} size="xl" />
                </>
              ) : (
                <div className="rounded-2xl bg-white/8 p-6 ring-1 ring-white/12">
                  <p className="text-[17px] font-semibold text-white">{t.ended}</p>
                  <p className="mt-1 text-[14px] text-white/65">{t.endedText}</p>
                </div>
              )}
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* deal-grid                                                           */
/* ------------------------------------------------------------------ */

function DealGrid(props: FlashSaleProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const left = useCountdown(props.endsIn);
  const deals = props.deals ?? [];
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <Head props={props} left={left} t={t} />
        <ul className="grid grid-cols-1 gap-4 min-[480px]:grid-cols-2 lg:grid-cols-4 lg:gap-5">
          {deals.map((d, i) => {
            const out = (d.claimed ?? 0) >= 100;
            return (
              <li key={d.id} className="kit-rise group/deal flex flex-col rounded-2xl border border-border bg-card p-3 shadow-[var(--shadow-sm)] transition-shadow hover:shadow-[var(--shadow-md)]" style={{ '--kit-delay': `${i * 70}ms` } as React.CSSProperties}>
                <div className="relative">
                  <ProductMedia className={cn('aspect-square rounded-xl [&>*]:transition-transform [&>*]:duration-700 [&>*]:ease-[var(--ease-kit)] group-hover/deal:[&>*]:scale-[1.05] motion-reduce:[&>*]:transition-none', out && 'opacity-55 grayscale')}>{d.image}</ProductMedia>
                  <OffBadge deal={d} t={t} className="absolute left-2.5 top-2.5" />
                </div>
                <div className="flex flex-1 flex-col px-1 pt-3">
                  <a href={d.href ?? '#'} className="text-[15px] font-medium text-foreground hover:underline hover:underline-offset-4 relative after:absolute after:inset-x-0 after:-inset-y-3">
                    {d.name}
                  </a>
                  {d.subtitle && <p className="text-[13px] text-muted-foreground">{d.subtitle}</p>}
                  <div className="mt-2">
                    <PriceRow deal={d} currency={currency} />
                    <p className="text-[12px] font-medium text-success-foreground">{fill(t.save, { amount: formatPrice(locale, round(d.compareAt - d.price), currency) })}</p>
                  </div>
                  <Claimed deal={d} t={t} className="mb-4 mt-4" />
                  <AddButton deal={d} t={t} onAdd={props.onAdd} className="mt-auto" />
                </div>
              </li>
            );
          })}
        </ul>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* deal-of-day                                                         */
/* ------------------------------------------------------------------ */

function DealOfDay(props: FlashSaleProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const left = useCountdown(props.endsIn);
  const d = props.deals?.[0];
  const max = props.limit ?? 2;
  const [qty, setQty] = useState(1);
  if (!d) return null;
  const total = props.endsIn ?? 1;
  const ring = Math.max(0, Math.min(1, left / Math.max(1, total)));
  const C = 2 * Math.PI * 46;
  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-center gap-8 md:grid-cols-2 lg:gap-16">
          <div className="relative">
            <ProductMedia className="aspect-square rounded-3xl max-md:aspect-[5/4]">{d.image}</ProductMedia>
            <div className="absolute left-4 top-4 flex flex-col items-start gap-2 sm:left-5 sm:top-5">
              <OffBadge deal={d} t={t} className="h-8 px-3 text-[14px]" />
            </div>
            {/* The ring of time left sits on the corner of the picture, over its quiet backdrop */}
            {left > 0 && (
              <div className="absolute bottom-4 right-4 grid size-24 place-items-center rounded-full bg-background/90 shadow-[var(--shadow-lg)] backdrop-blur sm:bottom-5 sm:right-5 sm:size-28" aria-hidden>
                <svg viewBox="0 0 100 100" className="absolute inset-1.5 -rotate-90">
                  <circle cx="50" cy="50" r="46" fill="none" strokeWidth="4" className="stroke-[color-mix(in_oklab,var(--foreground)_12%,transparent)]" />
                  <circle cx="50" cy="50" r="46" fill="none" strokeWidth="4" strokeLinecap="round" strokeDasharray={`${round(C * ring)} ${round(C)}`} className="stroke-primary transition-[stroke-dasharray] duration-1000 ease-linear" />
                </svg>
                <span className="relative text-center">
                  <span className="block text-[18px] font-semibold tabular-nums text-foreground sm:text-[20px]">
                    {two(parts(left).h + parts(left).d * 24)}:{two(parts(left).m)}
                  </span>
                  <span className="block text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground">{t.endsIn}</span>
                </span>
              </div>
            )}
          </div>
          <div className="min-w-0">
            {props.eyebrow && (
              <p className="mb-3 inline-flex items-center gap-2 text-sm font-medium text-[color-mix(in_oklch,var(--destructive)_75%,var(--foreground))]">
                <Icon name="bolt" size={15} />
                {props.eyebrow}
              </p>
            )}
            <h2 className="text-balance text-h2 font-semibold text-foreground">{d.name}</h2>
            {d.subtitle && <p className="mt-2 text-[16px] text-muted-foreground">{d.subtitle}</p>}
            <div className="mt-6">
              <PriceRow deal={d} currency={currency} size="xl" />
              <p className="mt-1 text-[14px] font-medium text-success-foreground">{fill(t.save, { amount: formatPrice(locale, round(d.compareAt - d.price), currency) })}</p>
            </div>
            <div className="mt-7">
              {left > 0 ? (
                <>
                  <p className="mb-2 text-[12px] font-medium text-muted-foreground">{t.endsIn}</p>
                  <Clock left={left} t={t} size="sm" />
                </>
              ) : (
                <p className="text-[14px] font-medium text-muted-foreground">{t.ended}</p>
              )}
            </div>
            <Claimed deal={d} t={t} className="mt-7 max-w-[26rem]" />
            <div className="mt-7 flex flex-col gap-3 sm:flex-row sm:items-center">
              <Quantity value={qty} onChange={setQty} max={max} label={t.quantity} />
              <AddButton deal={{ ...d, claimed: left > 0 ? d.claimed : 100 }} t={t} onAdd={props.onAdd} quantity={qty} size="lg" className="sm:flex-1" />
            </div>
            <p className="mt-3 flex items-center gap-2 text-[13px] text-muted-foreground">
              <Icon name="info" size={14} />
              {fill(t.limit, { n: max })}
            </p>
          </div>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* drops                                                               */
/* ------------------------------------------------------------------ */

function Notify({ time, t, onNotify }: { time: string; t: Texts; onNotify?: FlashSaleProps['onNotify'] }) {
  const locale = useLocale();
  const id = useId();
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');
  const [done, setDone] = useState(false);
  const row = useRef<HTMLFormElement>(null);
  const input = useRef<HTMLInputElement>(null);
  useShake(row, !!error);
  if (done)
    return (
      <p role="status" className="flex animate-[kitshop-in_0.4s_var(--ease-kit)] items-start gap-2 rounded-xl border border-[color-mix(in_oklab,var(--success)_35%,var(--border))] bg-[color-mix(in_oklab,var(--success)_8%,var(--card))] p-4 text-[14px] text-foreground">
        <Icon name="check-circle" size={17} className="mt-0.5 shrink-0 text-success" />
        {fill(t.notified, { email, time: formatTime(time, locale) })}
      </p>
    );
  return (
    <form
      ref={row}
      noValidate
      onSubmit={(e) => {
        e.preventDefault();
        const v = email.trim();
        if (!v) return (setError(t.eEmpty), input.current?.focus());
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v)) return (setError(t.eEmail), input.current?.focus());
        setError('');
        setDone(true);
        onNotify?.(v, time);
      }}
    >
      <label htmlFor={id} className="mb-1.5 block text-[13px] font-medium text-foreground">
        {t.email}
      </label>
      <div className="flex flex-col gap-2 sm:flex-row">
        <input ref={input} id={id} type="email" autoComplete="email" value={email} onChange={(e) => { setEmail(e.target.value); setError(''); }} aria-invalid={!!error || undefined} aria-describedby={error ? `${id}-e` : undefined} placeholder="name@example.com" className="h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3.5 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] aria-[invalid=true]:border-[color-mix(in_oklab,var(--destructive)_75%,var(--border))] sm:flex-1 sm:text-[14px]" />
        <Button type="submit" iconLeft="bell" className="w-full sm:w-auto">
          {t.notify}
        </Button>
      </div>
      {error && <p id={`${id}-e`} className="mt-1.5 animate-[kitshop-in_0.3s_var(--ease-kit)] text-[12px] text-destructive">{error}</p>}
    </form>
  );
}

function Drops(props: FlashSaleProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const left = useCountdown(props.endsIn);
  const drops = props.drops ?? [];
  const [active, setActive] = useState(Math.max(0, drops.findIndex((d) => d.status === 'live')));
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const base = useId();
  const drop = drops[active];
  const onKey = (e: React.KeyboardEvent, i: number) => {
    const n = drops.length;
    const go = e.key === 'ArrowRight' ? (i + 1) % n : e.key === 'ArrowLeft' ? (i - 1 + n) % n : e.key === 'Home' ? 0 : e.key === 'End' ? n - 1 : -1;
    if (go < 0) return;
    e.preventDefault();
    setActive(go);
    tabs.current[go]?.focus();
  };
  if (!drop) return null;
  const live = drop.status === 'live';
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <div className="mb-8 max-w-[38rem] sm:mb-10">
          {props.eyebrow && (
            <p className="mb-3 inline-flex items-center gap-2 text-sm font-medium text-[color-mix(in_oklch,var(--destructive)_75%,var(--foreground))]">
              <Icon name="bolt" size={15} />
              {props.eyebrow}
            </p>
          )}
          {props.title && <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>
        <div role="tablist" aria-label={t.slots} className="grid grid-cols-3 gap-2 sm:flex">
          {drops.map((d, i) => {
            const on = i === active;
            return (
              <button
                key={d.time}
                ref={(el) => { tabs.current[i] = el; }}
                role="tab"
                id={`${base}-t${i}`}
                aria-selected={on}
                aria-controls={`${base}-p`}
                tabIndex={on ? 0 : -1}
                onClick={() => setActive(i)}
                onKeyDown={(e) => onKey(e, i)}
                className={cn('flex min-h-14 min-w-0 flex-col items-start justify-center gap-0.5 rounded-xl border px-3 py-2 text-left transition-[border-color,background-color] duration-300 sm:shrink-0 sm:flex-row sm:items-center sm:gap-3 sm:px-4', on ? 'border-foreground bg-foreground text-background' : 'border-border bg-card text-foreground hover:border-border-strong')}
              >
                <span className="text-[17px] font-semibold tabular-nums sm:text-[19px]">{formatTime(d.time, locale)}</span>
                <span className={cn('flex min-w-0 items-center gap-1.5 truncate text-[11px] font-medium sm:text-[12px]', on ? 'text-background/75' : 'text-muted-foreground')}>
                  {d.status === 'live' && (
                    <span className="relative flex size-2">
                      <span className="absolute inset-0 animate-ping rounded-full bg-[var(--destructive)] opacity-70 motion-reduce:animate-none" />
                      <span className="relative size-2 rounded-full bg-[var(--destructive)]" />
                    </span>
                  )}
                  {d.label ?? (d.status === 'live' ? t.live : d.status === 'next' ? t.next : t.later)}
                </span>
              </button>
            );
          })}
        </div>
        <div key={active} id={`${base}-p`} role="tabpanel" aria-labelledby={`${base}-t${active}`} tabIndex={0} className="mt-6 animate-[kitshop-fade_0.35s_var(--ease-kit)] rounded-xl">
          {live ? (
            <>
              {left > 0 && (
                <div className="mb-5 flex flex-wrap items-center gap-3">
                  <span className="text-[13px] font-medium text-muted-foreground">{t.endsIn}</span>
                  <Clock left={left} t={t} size="sm" />
                </div>
              )}
              <ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
                {drop.deals.map((d) => (
                  <li key={d.id} className="flex gap-4 rounded-2xl border border-border bg-card p-3 shadow-[var(--shadow-sm)]">
                    <div className="relative shrink-0">
                      <ProductMedia className="size-28 rounded-xl sm:size-32">{d.image}</ProductMedia>
                      <OffBadge deal={d} t={t} className="absolute left-1.5 top-1.5" />
                    </div>
                    <div className="flex min-w-0 flex-1 flex-col py-1">
                      <p className="text-[15px] font-medium text-foreground">{d.name}</p>
                      <PriceRow deal={d} currency={currency} />
                      <Claimed deal={d} t={t} className="mb-3 mt-2.5" />
                      <AddButton deal={d} t={t} onAdd={props.onAdd} className="mt-auto" />
                    </div>
                  </li>
                ))}
              </ul>
            </>
          ) : (
            <div className="grid grid-cols-1 items-center gap-6 rounded-2xl border border-border bg-surface p-5 sm:p-7 lg:grid-cols-[minmax(0,1fr)_minmax(0,24rem)] lg:gap-10">
              <div>
                <p className="text-[13px] font-medium text-muted-foreground">{t.preview}</p>
                <ul className="mt-3 grid grid-cols-3 gap-2 sm:gap-3" aria-label={t.preview}>
                  {drop.deals.slice(0, 3).map((d) => (
                    <li key={d.id}>
                      <div className="relative overflow-hidden rounded-xl">
                        <ProductMedia className="aspect-square rounded-xl blur-[6px] saturate-50">{d.image}</ProductMedia>
                        <span className="absolute inset-0 grid place-items-center">
                          <span className="grid size-10 place-items-center rounded-full bg-background/85 text-foreground shadow-[var(--shadow-md)]">
                            <Icon name="lock" size={16} />
                          </span>
                        </span>
                      </div>
                      <p className="mt-2 truncate text-[12px] font-medium text-foreground">{d.name}</p>
                      <p className="text-[12px] font-semibold text-[color-mix(in_oklch,var(--destructive)_80%,var(--foreground))]">{fill(t.off, { pct: formatPercent(pctOff(d), locale) })}</p>
                    </li>
                  ))}
                </ul>
              </div>
              <div>
                <p className="text-h3 font-semibold text-foreground">{fill(t.startsAt, { time: formatTime(drop.time, locale) })}</p>
                <p className="mb-5 mt-2 text-[14px] text-muted-foreground">{t.notifyText}</p>
                <Notify time={drop.time} t={t} onNotify={props.onNotify} />
              </div>
            </div>
          )}
        </div>
      </div>
    </section>
  );
}

/**
 * A flash sale with a ticking clock: a dark banner, a grid of deals with a
 * claimed bar each, one deal of the day with a limit per customer, or the
 * drops of a day as tabs with a reminder for the next one.
 */
export function FlashSale(props: FlashSaleProps) {
  const locale = useLocale();
  const t = useTexts(TEXTS, props.labels);
  const currency = currencyOf(locale, props.currency);
  const { variant = 'deal-grid' } = props;
  if (variant === 'banner') return <Banner {...props} t={t} />;
  if (variant === 'deal-of-day') return <DealOfDay {...props} t={t} currency={currency} />;
  if (variant === 'drops') return <Drops {...props} t={t} currency={currency} />;
  return <DealGrid {...props} t={t} currency={currency} />;
}

Häufige Fragen

Wie installiere ich Flash Sale?

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

Welche Varianten hat Flash Sale?

4 Varianten, Countdown-Band, Angebotsraster, Angebot des Tages und Drops nach Uhrzeit. Du wählst sie mit der Prop variant, zum Beispiel variant="deal-grid".

Kann ich die Texte von Flash Sale übersetzen?

Ja. Flash Sale bringt 31 eigene Texte auf Deutsch und Englisch mit, etwa „Endet in“, „Diese Aktion ist vorbei“ und „Neue Angebote starten jeden Tag um 09:00.“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Flash Sale einbauen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert