E-commerceecommerceshopsocial-prooftoastnotificationconversion
Live Activity Toast
Сменяющиеся уведомления о недавних покупках (социальное доказательство); пауза при наведении и закрытие на сессию.
Живое превью
Использование
Скопируйте и вставьте этот код в ваш React-компонент:
tsx
import { LiveActivityToast } from 'react-component-kit-ui/ecommerce';
<LiveActivityToast items={[
{ name: 'Lucía', city: 'Madrid', product: 'Aurora Headphones', icon: 'headphones', hue: 38, ago: '2 min ago' },
{ name: 'Tom', city: 'Berlin', product: 'Trail Backpack', icon: 'backpack', hue: 150, ago: '6 min ago' },
]} />Ручная установка
Если вы не используете пакет npm, создайте файл и вставьте следующий код: src/components/LiveActivityToast.tsx + LiveActivityToast.css:
LiveActivityToast.tsx
'use client';
import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import { productIcon } from './product-icons';
import './LiveActivityToast.css';
/** class:list → className */
function cx(...args: unknown[]): string {
const out: string[] = [];
const walk = (v: unknown) => {
if (!v) return;
if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
else if (Array.isArray(v)) v.forEach(walk);
else if (typeof v === 'object') for (const [k, on] of Object.entries(v as Record<string, unknown>)) if (on) out.push(k);
};
args.forEach(walk);
return out.join(' ');
}
/** "a: b; --c: d" → style object */
function st(css: string | undefined): CSSProperties {
const o: Record<string, string> = {};
for (const decl of String(css ?? '').split(';')) {
const i = decl.indexOf(':');
if (i < 0) continue;
const k = decl.slice(0, i).trim();
if (k) o[k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = decl.slice(i + 1).trim();
}
return o as CSSProperties;
}
interface Activity { name: string; city: string; product: string; href?: string; icon?: string; hue?: number; ago: string }
/**
* LiveActivityToast — Rotating social-proof notice ("Lucía in Madrid bought Aurora Headphones
* · 3 min ago") that slides in, waits and hides, cycling through `items`.
*
* @param {Array} items - `{ name, city, product, href?, icon?, hue?, ago }` entries to cycle through.
* @param {string} verb - Action text between person and product. Default 'just bought'.
* @param {number} interval - Time between notices in ms. Default 9000.
* @param {number} visibleFor - How long each notice stays in ms. Default 5000.
* @param {number} delay - Wait before the first notice in ms. Default 4000.
* @param {string} position - Corner (bottom-left|bottom-right). Default 'bottom-left'.
* @param {string} storageKey - sessionStorage key that remembers "stop showing". Default 'ack-activity-off'.
* @param {boolean} demo - Render statically with the first item (docs/previews). Default false.
* @param {string} variant - Style (glass|solid). Default 'glass'.
* @param {string} className - Extra class.
*
* Accessibility: not announced by screen readers (it is promotional, aria-live="off"), never
* steals focus, has a close button that stops it for the session, pauses on hover/focus and
* when the tab is hidden; respects prefers-reduced-motion.
*/
export interface LiveActivityToastProps {
items?: Activity[];
verb?: string;
interval?: number;
visibleFor?: number;
delay?: number;
position?: 'bottom-left' | 'bottom-right';
storageKey?: string;
demo?: boolean;
variant?: 'glass' | 'solid';
className?: string;
}
export function LiveActivityToast({
items = [
{ name: 'Lucía', city: 'Madrid', product: 'Aurora Wireless Headphones', icon: 'headphones', hue: 38, ago: '2 min ago' },
{ name: 'Tom', city: 'Berlin', product: 'Trail 24L Backpack', icon: 'backpack', hue: 150, ago: '6 min ago' },
{ name: 'Aiko', city: 'Osaka', product: 'Orbit Smart Watch', icon: 'watch', hue: 205, ago: '11 min ago' },
],
verb = 'just bought',
interval = 9000,
visibleFor = 5000,
delay = 4000,
position = 'bottom-left',
storageKey = 'ack-activity-off',
demo = false,
variant = 'glass',
className = '',
}: LiveActivityToastProps) {
const first = items[0];
// Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
const mounted = useRef(false);
useEffect(() => {
if (mounted.current) return;
mounted.current = true;
document.querySelectorAll<HTMLElement>('[data-live-activity]:not([data-bound])').forEach((el) => {
el.dataset.bound = '';
const key = el.dataset.key || 'ack-activity-off';
try { if (window.sessionStorage.getItem(key)) return; } catch { /* storage blocked */ }
let items: Array<Record<string, any>> = [];
try { items = JSON.parse(el.dataset.liveActivity || '[]'); } catch { return; }
if (!items.length) return;
const q = (s: string) => el.querySelector<HTMLElement>(s);
const media = q('[data-media]');
const link = q('[data-link]') as HTMLAnchorElement | null;
const verb = el.dataset.verb || 'just bought';
const interval = Number(el.dataset.interval) || 9000;
const visibleFor = Number(el.dataset.visible) || 5000;
let index = 0;
let paused = false;
let stopped = false;
let hideTimer = 0;
function fill(item: Record<string, any>) {
q('[data-who]')!.textContent = item.name;
q('[data-city]')!.textContent = `in ${item.city}`;
q('[data-product]')!.textContent = item.product;
q('[data-ago]')!.textContent = item.ago;
if (link) link.href = item.href || '#';
if (media) {
media.style.setProperty('--h', String(item.hue ?? 40));
media.innerHTML = `<svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">${productIcon(item.icon)}</svg>`;
}
const line = q('.live-activity__line');
if (line && line.lastChild) line.lastChild.textContent = ` ${verb}`;
}
function cycle() {
if (stopped) return;
if (paused || document.hidden) { window.setTimeout(cycle, 1500); return; }
fill(items[index % items.length]);
index++;
el.hidden = false;
requestAnimationFrame(() => el.classList.add('is-visible'));
hideTimer = window.setTimeout(hide, visibleFor);
}
function hide() {
if (paused) { hideTimer = window.setTimeout(hide, 1000); return; }
el.classList.remove('is-visible');
window.setTimeout(() => { el.hidden = true; }, 300);
window.setTimeout(cycle, Math.max(1000, interval - visibleFor));
}
el.addEventListener('mouseenter', () => { paused = true; });
el.addEventListener('mouseleave', () => { paused = false; });
el.addEventListener('focusin', () => { paused = true; });
el.addEventListener('focusout', () => { paused = false; });
q('[data-close]')?.addEventListener('click', () => {
stopped = true;
window.clearTimeout(hideTimer);
el.classList.remove('is-visible');
el.hidden = true;
try { window.sessionStorage.setItem(key, '1'); } catch { /* ignore */ }
});
window.setTimeout(cycle, Number(el.dataset.delay) || 4000);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div className={cx(['live-activity', `live-activity--${position}`, `live-activity--${variant}`, { 'live-activity--demo is-visible': demo }, className])} aria-live="off" data-live-activity={demo ? undefined : JSON.stringify(items)} data-verb={verb} data-interval={interval} data-visible={visibleFor} data-delay={delay} data-key={storageKey} hidden={!demo} data-rk-3695c2="">
{first && (
<>
<span className="live-activity__media" style={st(`--h: ${first.hue ?? 40}`)} aria-hidden="true" data-media data-rk-3695c2="">
<svg viewBox="0 0 64 64" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" data-rk-3695c2=""><g dangerouslySetInnerHTML={{ __html: productIcon(first.icon) }} data-rk-3695c2="" /></svg>
</span>
<a className="live-activity__body" href={first.href ?? '#'} data-link data-rk-3695c2="">
<span className="live-activity__line" data-rk-3695c2=""><strong data-who data-rk-3695c2="">{first.name}</strong> <span data-city data-rk-3695c2="">in {first.city}</span> {verb}</span>
<span className="live-activity__product" data-product data-rk-3695c2="">{first.product}</span>
<span className="live-activity__ago" data-rk-3695c2=""><span className="live-activity__dot" aria-hidden="true" data-rk-3695c2=""></span><span data-ago data-rk-3695c2="">{first.ago}</span> · Verified</span>
</a>
<button className="live-activity__close" type="button" aria-label="Stop showing purchase notifications" data-close data-rk-3695c2="">×</button>
</>
)}
</div>
</>
);
}
export default LiveActivityToast;LiveActivityToast.css
/* LiveActivityToast — ported from astro-component-kit (ecommerce/LiveActivityToast.astro) */
.live-activity:where([data-rk-3695c2]) {
position: fixed;
bottom: 1rem;
z-index: 1050;
display: grid;
grid-template-columns: 52px 1fr auto;
align-items: center;
gap: var(--sp-3, 0.75rem);
width: min(360px, calc(100vw - 2rem));
padding: var(--sp-3, 0.75rem);
border-radius: var(--r-lg, 20px);
border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.1);
background: rgb(var(--paper-rgb, 5 5 5) / 0.86);
backdrop-filter: blur(16px) saturate(1.4);
box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.6);
opacity: 0;
transform: translateY(16px);
transition: opacity 0.3s ease, transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.live-activity[hidden]:where([data-rk-3695c2]) { display: none; }
.live-activity.is-visible:where([data-rk-3695c2]) { opacity: 1; transform: none; }
.live-activity--bottom-left:where([data-rk-3695c2]) { left: 1rem; }
.live-activity--bottom-right:where([data-rk-3695c2]) { right: 1rem; }
.live-activity--solid:where([data-rk-3695c2]) { background: var(--c-bg-1, #0a0a0a); backdrop-filter: none; }
.live-activity--demo:where([data-rk-3695c2]) { position: relative; inset: auto; margin: 0 auto; }
.live-activity__media:where([data-rk-3695c2]) {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: var(--r-md, 12px);
color: hsl(var(--h) var(--pm-ink-s, 80%) var(--pm-ink-l, 80%));
background: radial-gradient(circle at 50% 40%, hsl(var(--h) 70% 50% / 0.3), transparent 70%), var(--c-bg-2, #111);
}
.live-activity__media:where([data-rk-3695c2]) svg { width: 60%; }
.live-activity__body:where([data-rk-3695c2]) { display: grid; gap: 1px; min-width: 0; color: inherit; text-decoration: none; border-radius: var(--r-sm, 8px); }
.live-activity__body:hover:where([data-rk-3695c2]) .live-activity__product:where([data-rk-3695c2]) { text-decoration: underline; }
.live-activity__body:focus-visible:where([data-rk-3695c2]), .live-activity__close:focus-visible:where([data-rk-3695c2]) { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 2px; }
.live-activity__line:where([data-rk-3695c2]) { font-size: 0.8rem; color: var(--c-text-2, #a3a3a3); }
.live-activity__line:where([data-rk-3695c2]) strong:where([data-rk-3695c2]) { color: var(--c-text-1, #fff); }
.live-activity__product:where([data-rk-3695c2]) { font-size: 0.88rem; font-weight: 700; color: var(--c-text-1, #fff); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.live-activity__ago:where([data-rk-3695c2]) { display: inline-flex; align-items: center; gap: 6px; font-size: 0.72rem; color: var(--c-text-2, #a3a3a3); }
.live-activity__dot:where([data-rk-3695c2]) { width: 6px; height: 6px; border-radius: 50%; background: var(--c-success, #34d399); box-shadow: 0 0 8px var(--c-success, #34d399); }
.live-activity__close:where([data-rk-3695c2]) {
align-self: start;
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--c-text-2, #a3a3a3);
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
}
.live-activity__close:hover:where([data-rk-3695c2]) { color: var(--c-text-1, #fff); background: rgb(var(--ink-rgb, 255 255 255) / 0.06); }
@media (prefers-reduced-motion: reduce) {
.live-activity:where([data-rk-3695c2]) { transition: none; transform: none; }
}Краткая информация
- Категория
- E-commerce
- Имя файла
LiveActivityToast.tsx- Зависимости
- Нет — чистый React + CSS
- Теги
- ecommerceshopsocial-prooftoastnotificationconversion