Flash Sale mit Countdown
Eine Aktion mit tickender Uhr und knappem Vorrat. Ein Band, ein Angebotsraster, ein Angebot des Tages und Drops mit Erinnerung.
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
Details
Was Flash Sale mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Sale und Aktionen
- Bereich
- E-Commerce
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/flash-sale.tsx
- Bringt mit
- utilskitbuttoniconproduct-imageinput
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 31 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle FlashSaleProps aus components/ui/flash-sale.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalFlashSaleVariantbannerdeal-griddeal-of-daydrops
eyebrowoptionalstringtitleoptionalstringdescriptionoptionalstringendsInoptionalnumberSeconds until the sale ends
dealsoptionalFlashDeal[]dropsoptionalFlashDrop[]limitoptionalnumberMost pieces per customer (deal-of-day)
ctaoptional{ label: string; href: string }onAddoptional(deal: FlashDeal, quantity: number) => voidonNotifyoptional(email: string, time: string) => voidcurrencyoptionalstring
Eingebaute Texte
Diese Texte schreibt Flash Sale selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 31 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| endsIn | Englisch Ends in | Deutsch Endet in |
| ended | Englisch This sale has ended | Deutsch Diese Aktion ist vorbei |
| endedText | Englisch New deals start every day at 9:00. | Deutsch Neue Angebote starten jeden Tag um 09:00. |
| days | Englisch Days | Deutsch Tage |
| hours | Englisch Hours | Deutsch Std. |
| minutes | Englisch Min | Deutsch Min. |
| seconds | Englisch Sec | Deutsch Sek. |
| claimed | Englisch {pct} claimed | Deutsch {pct} vergriffen |
| almostGone | Englisch Almost gone | Deutsch Fast weg |
| soldOut | Englisch Sold out | Deutsch Ausverkauft |
| onlyLeft | Englisch Only {n} left | Deutsch Nur noch {n} |
| add | Englisch Add to cart | Deutsch In den Warenkorb |
| added | Englisch Added | Deutsch Im Warenkorb |
| addNamed | Englisch Add {name} to cart | Deutsch {name} in den Warenkorb |
| save | Englisch Save {amount} | Deutsch Spare {amount} |
| off | Englisch −{pct} | Deutsch −{pct} |
| limit | Englisch Up to {n} per customer | Deutsch Bis zu {n} pro Person |
| quantity | Englisch Quantity | Deutsch Menge |
| live | Englisch Live now | Deutsch Jetzt live |
| next | Englisch Next | Deutsch Als Nächstes |
| later | Englisch Later | Deutsch Später |
| startsAt | Englisch Starts at {time} | Deutsch Startet um {time} |
| notifyTitle | Englisch Get a reminder | Deutsch Erinnerung bekommen |
| notifyText | Englisch We send one email when this drop goes live. Nothing else. | Deutsch Wir schicken eine E-Mail, wenn dieser Drop live geht. Sonst nichts. |
| Englisch Email address | Deutsch E-Mail-Adresse | |
| notify | Englisch Remind me | Deutsch Erinnere mich |
| notified | Englisch Done. We write to {email} at {time}. | Deutsch Erledigt. Wir schreiben dir um {time} an {email}. |
| eEmpty | Englisch Enter your email address for the reminder. | Deutsch Gib deine E-Mail-Adresse für die Erinnerung ein. |
| eEmail | Englisch This email address looks incomplete. Check the part after the @. | Deutsch Diese E-Mail-Adresse wirkt unvollständig. Prüf den Teil nach dem @. |
| preview | Englisch A first look | Deutsch Ein erster Blick |
| slots | Englisch Drops today | Deutsch 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
'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.