E-commerceecommerceshopstockurgencyinventoryproduct-page
Stock Indicator
Aviso de stock bajo con barra de nivel y contador en vivo de personas viendo el producto.
Vista Previa en Vivo
Only 3 left — order soon
14 people are viewing this right now
Uso
Copia y pega este código directamente en tu componente React:
tsx
import { StockIndicator } from 'react-component-kit-ui/ecommerce';
<StockIndicator stock={3} viewers={14} />Instalación Manual
Si no estás usando el paquete npm, crea un archivo nuevo y pega el siguiente código: src/components/StockIndicator.tsx + StockIndicator.css:
StockIndicator.tsx
'use client';
import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import './StockIndicator.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;
}
/**
* StockIndicator — Urgency notice for product pages: low-stock bar ("Only 3 left") and a
* live "N people are viewing this" counter that drifts gently.
*
* @param {number} stock - Units left. Default 3.
* @param {number} threshold - At or below this value the low-stock style is used. Default 5.
* @param {number} capacity - Value that fills the bar completely. Default 20.
* @param {number} viewers - Starting number of viewers (0 hides the line). Default 14.
* @param {boolean} live - Let the viewers count move every few seconds. Default true.
* @param {string} variant - Style (card|inline). Default 'card'.
* @param {string} size - Density (sm|md). Default 'md'.
* @param {string} className - Extra class.
*
* Accessibility: stock text is real text (not colour alone); the moving viewers number is not
* announced (aria-live off) to avoid noise; animation stops with prefers-reduced-motion.
*/
export interface StockIndicatorProps {
stock?: number;
threshold?: number;
capacity?: number;
viewers?: number;
live?: boolean;
variant?: 'card' | 'inline';
size?: 'sm' | 'md';
className?: string;
}
export function StockIndicator({
stock = 3,
threshold = 5,
capacity = 20,
viewers = 14,
live = true,
variant = 'card',
size = 'md',
className = '',
}: StockIndicatorProps) {
const low = stock > 0 && stock <= threshold;
const state = stock <= 0 ? 'out' : low ? 'low' : 'ok';
const pct = Math.max(4, Math.min(100, (stock / capacity) * 100));
const message = state === 'out' ? 'Sold out — join the waitlist' : low ? `Only ${stock} left — order soon` : 'In stock and ready to ship';
// 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-stock-indicator][data-live]:not([data-bound])').forEach((el) => {
el.dataset.bound = '';
const out = el.querySelector<HTMLElement>('[data-viewers-count]');
if (!out || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const base = Number(el.dataset.viewers) || 0;
let value = base;
window.setInterval(() => {
if (document.hidden) return;
const step = Math.random() < 0.5 ? -1 : 1;
value = Math.max(Math.max(1, base - 4), Math.min(base + 6, value + step));
out.textContent = String(value);
out.classList.remove('is-bump');
void out.offsetWidth;
out.classList.add('is-bump');
}, 6500);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div className={cx(['stock', `stock--${state}`, `stock--${variant}`, `stock--${size}`, className])} data-stock-indicator data-viewers={viewers} data-live={live ? '' : undefined} data-rk-83468c="">
<p className="stock__msg" data-rk-83468c="">
<span className="stock__icon" aria-hidden="true" data-rk-83468c="">
{state === 'ok'
? <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" data-rk-83468c=""><path d="M20 6 9 17l-5-5" data-rk-83468c="" /></svg>
: <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" data-rk-83468c=""><path d="M13 2 3 14h9l-1 8 10-12h-9z" data-rk-83468c="" /></svg>}
</span>
<strong data-rk-83468c="">{message}</strong>
</p>
{state !== 'out' && (
<div className="stock__bar" aria-hidden="true" data-rk-83468c=""><span style={st(`width: ${pct}%`)} data-rk-83468c=""></span></div>
)}
{viewers > 0 && (
<p className="stock__viewers" aria-live="off" data-rk-83468c="">
<span className="stock__eye" aria-hidden="true" data-rk-83468c="">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" data-rk-83468c=""><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" data-rk-83468c="" /><circle cx="12" cy="12" r="3" data-rk-83468c="" /></svg>
</span>
<span data-rk-83468c=""><strong data-viewers-count data-rk-83468c="">{viewers}</strong> people are viewing this right now</span>
</p>
)}
</div>
</>
);
}
export default StockIndicator;StockIndicator.css
/* StockIndicator — ported from astro-component-kit (ecommerce/StockIndicator.astro) */
.stock:where([data-rk-83468c]) {
--stock-tone: var(--c-success, #34d399);
display: grid;
gap: var(--sp-2, 0.5rem);
}
.stock--low:where([data-rk-83468c]) { --stock-tone: var(--c-warning, #fbbf24); }
.stock--out:where([data-rk-83468c]) { --stock-tone: var(--c-danger, #f87171); }
.stock--card:where([data-rk-83468c]) {
padding: var(--sp-4, 1rem);
border-radius: var(--r-md, 12px);
border: 1px solid color-mix(in srgb, var(--stock-tone) 30%, transparent);
background: color-mix(in srgb, var(--stock-tone) 7%, transparent);
}
.stock--sm.stock--card:where([data-rk-83468c]) { padding: var(--sp-3, 0.75rem); }
.stock__msg:where([data-rk-83468c]) { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); margin: 0; font-size: 0.9rem; color: var(--c-text-1, #fff); }
.stock__icon:where([data-rk-83468c]) {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
color: var(--stock-tone);
background: color-mix(in srgb, var(--stock-tone) 16%, transparent);
}
.stock--low:where([data-rk-83468c]) .stock__icon:where([data-rk-83468c]) { animation: stock-indicator-stock-pulse 1.8s ease-in-out infinite; }
.stock__bar:where([data-rk-83468c]) { height: 6px; border-radius: 999px; background: rgb(var(--ink-rgb, 255 255 255) / 0.08); overflow: hidden; }
.stock__bar:where([data-rk-83468c]) span:where([data-rk-83468c]) { display: block; height: 100%; border-radius: inherit; background: var(--stock-tone); }
.stock__viewers:where([data-rk-83468c]) { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); margin: 0; font-size: 0.8rem; color: var(--c-text-2, #a3a3a3); }
.stock__viewers:where([data-rk-83468c]) strong:where([data-rk-83468c]) { color: var(--c-text-1, #fff); display: inline-block; min-width: 1.2em; }
.stock__viewers:where([data-rk-83468c]) strong.is-bump:where([data-rk-83468c]) { animation: stock-indicator-stock-bump 0.4s ease-out; }
.stock__eye:where([data-rk-83468c]) { display: grid; place-items: center; color: var(--c-text-2, #a3a3a3); }
@keyframes stock-indicator-stock-pulse { 50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--stock-tone) 12%, transparent); } }
@keyframes stock-indicator-stock-bump { from { transform: translateY(-3px); opacity: 0.4; } }
@media (prefers-reduced-motion: reduce) {
.stock--low:where([data-rk-83468c]) .stock__icon:where([data-rk-83468c]) { animation: none; }
}Información Rápida
- Categoría
- E-commerce
- Nombre de archivo
StockIndicator.tsx- Dependencias
- Ninguna — React + CSS puro
- Etiquetas
- ecommerceshopstockurgencyinventoryproduct-page