New
E-commerceecommerceshopcardproducteditorialquick-add
Product Card Minimal
Tarjeta de producto editorial: imagen alta que cambia al pasar el ratón, tallas de añadido rápido, nombre, precio y colores.
Vista Previa en Vivo
Uso
Copia y pega este código directamente en tu componente React:
tsx
import { ProductCardMinimal } from 'react-component-kit-ui/ecommerce';
<ProductCardMinimal href="/product/linen-overshirt/" index={1} product={{
slug: 'linen-overshirt', name: 'Linen Overshirt', price: 145, category: 'clothing',
sizes: ['S', 'M', 'L'], icon: 'shirt', hue: 30,
}} />Instalación Manual
Si no estás usando el paquete npm, crea un archivo nuevo y pega el siguiente código: src/components/ProductCardMinimal.tsx + ProductCardMinimal.css:
ProductCardMinimal.tsx
'use client';
import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import ProductMedia from './ProductMedia';
import { formatMoney } from './cart-store';
import type { Product } from './types';
import { bindAddToCart } from './cart-store';
import './ProductCardMinimal.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;
}
/**
* ProductCardMinimal — Editorial product card: tall media that swaps to a second view on hover,
* quick-add size buttons, name, price and colour count. Square corners, typography-led.
*
* @param {Product} product - Product data. A demo product is used by default.
* @param {string} href - Product page URL. Default '#'.
* @param {number} index - Optional catalogue number shown as "N° 01".
* @param {string} currency - ISO currency. Default 'EUR'.
* @param {string} locale - Number format locale. Default 'en-GB'.
* @param {string} cartKey - Cart storage key. Default 'ack-cart'.
* @param {boolean} quickAdd - Show quick-add (sizes, or a single "Add" button). Default true.
* @param {string} ratio - Media aspect ratio. Default '3 / 4'.
* @param {string} variant - Style (plain|framed). Default 'plain'.
* @param {string} size - Type scale (sm|md|lg). Default 'md'.
* @param {string} headingLevel - Title element (h2|h3|h4). Default 'h3'.
* @param {string} className - Extra class.
*
* Accessibility: the title is a stretched link; quick-add buttons sit above it with their own
* labels ("Add size M"), are reachable by keyboard and appear on focus as well as hover.
*/
export interface ProductCardMinimalProps {
product?: Product;
href?: string;
index?: number;
currency?: string;
locale?: string;
cartKey?: string;
quickAdd?: boolean;
ratio?: string;
variant?: 'plain' | 'framed';
size?: 'sm' | 'md' | 'lg';
headingLevel?: 'h2' | 'h3' | 'h4';
className?: string;
}
export function ProductCardMinimal({
product = {
slug: 'linen-overshirt',
name: 'Linen Overshirt',
price: 145,
category: 'clothing',
badge: 'New',
colors: [{ name: 'Oat', hex: '#d9cbb3' }, { name: 'Ink', hex: '#22252b' }, { name: 'Rust', hex: '#9c3d1c' }],
sizes: ['XS', 'S', 'M', 'L', 'XL'],
icon: 'shirt',
hue: 30,
},
href = '#',
index,
currency = 'EUR',
locale = 'en-GB',
cartKey = 'ack-cart',
quickAdd = true,
ratio = '3 / 4',
variant = 'plain',
size = 'md',
headingLevel = 'h3',
className = '',
}: ProductCardMinimalProps) {
const Heading: any = headingLevel;
const money = { currency, locale };
const inStock = product.inStock !== false;
const base = { id: product.slug, name: product.name, price: product.price, icon: product.icon, hue: product.hue, image: product.image };
const color = product.colors?.[0]?.name;
const titleId = `pcm-${product.slug}`;
// 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;
bindAddToCart(document);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<article className={cx(['pcm', `pcm--${variant}`, `pcm--${size}`, className])} aria-labelledby={titleId} data-product data-name={product.name.toLowerCase()} data-category={product.category} data-price={product.price} data-rating={product.rating ?? 0} data-colors={(product.colors ?? []).map((c) => c.name.toLowerCase()).join(',')} data-stock={inStock ? 'in' : 'out'} data-rk-5864b0="">
<div className="pcm__media" data-rk-5864b0="">
<div className="pcm__view pcm__view--a" data-rk-5864b0="">
<ProductMedia image={product.image} alt={product.name} icon={product.icon} hue={product.hue} ratio={ratio} variant="soft" size="lg" />
</div>
<div className="pcm__view pcm__view--b" aria-hidden="true" data-rk-5864b0="">
<ProductMedia icon={product.icon} hue={(product.hue ?? 30) + 20} ratio={ratio} variant="glow" size="sm" />
</div>
{(product.badge || !inStock) && <span className="pcm__badge" data-rk-5864b0="">{inStock ? product.badge : 'Sold out'}</span>}
{index !== undefined && <span className="pcm__index" aria-hidden="true" data-rk-5864b0="">N° {String(index).padStart(2, '0')}</span>}
{quickAdd && inStock && (
<div className="pcm__quick" role="group" aria-label={`Quick add ${product.name}`} data-rk-5864b0="">
{product.sizes?.length ? (
<>
<span className="pcm__quick-label" aria-hidden="true" data-rk-5864b0="">Quick add</span>
<div className="pcm__sizes" data-rk-5864b0="">
{product.sizes.map((s) => (
<button type="button" className="pcm__size" data-add-to-cart={JSON.stringify({ ...base, variant: [color, s].filter(Boolean).join(' / ') })} data-cart-key={cartKey} data-open-cart="false" aria-label={`Add ${product.name}, size ${s}`} data-rk-5864b0=""><span data-label data-rk-5864b0="">{s}</span></button>
))}
</div>
</>
) : (
<button type="button" className="pcm__size pcm__size--wide" data-add-to-cart={JSON.stringify({ ...base, variant: color })} data-cart-key={cartKey} data-open-cart="false" data-added-label="Added" aria-label={`Add ${product.name} to bag`} data-rk-5864b0=""><span data-label data-rk-5864b0="">Add to bag +</span></button>
)}
</div>
)}
</div>
<div className="pcm__info" data-rk-5864b0="">
<Heading className="pcm__title" id={titleId} data-rk-5864b0=""><a className="pcm__link" href={href} data-rk-5864b0="">{product.name}</a></Heading>
<p className="pcm__price" data-rk-5864b0="">
<span data-rk-5864b0="">{formatMoney(product.price, money)}</span>
{product.compareAt && <s data-rk-5864b0=""><span className="sr-only" data-rk-5864b0="">Was </span>{formatMoney(product.compareAt, money)}</s>}
</p>
{product.colors && product.colors.length > 0 && (
<p className="pcm__colors" data-rk-5864b0="">
<span className="pcm__dots" aria-hidden="true" data-rk-5864b0="">{product.colors.slice(0, 4).map((c, __i: number) => <i key={__i} style={st(`--sw: ${c.hex}`)} data-rk-5864b0=""></i>)}</span>
{product.colors.length} {product.colors.length === 1 ? 'colour' : 'colours'}
</p>
)}
</div>
</article>
</>
);
}
export default ProductCardMinimal;ProductCardMinimal.css
/* ProductCardMinimal — ported from astro-component-kit (ecommerce/ProductCardMinimal.astro) */
.pcm:where([data-rk-5864b0]) { position: relative; display: flex; flex-direction: column; gap: var(--sp-3, 0.75rem); min-width: 0; }
.pcm--framed:where([data-rk-5864b0]) { padding: var(--sp-3, 0.75rem); border: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); }
.pcm__media:where([data-rk-5864b0]) { position: relative; overflow: hidden; border-radius: var(--r-sm, 2px); background: var(--c-bg-2, #111); }
.pcm__view:where([data-rk-5864b0]) { transition: opacity 0.5s ease, transform 0.8s cubic-bezier(0.16, 1, 0.3, 1); }
.pcm__view--b:where([data-rk-5864b0]) { position: absolute; inset: 0; opacity: 0; transform: scale(1.04); }
.pcm__view--b:where([data-rk-5864b0]) .p-media__art { transform: rotate(-12deg) scale(1.25); }
.pcm:hover:where([data-rk-5864b0]) .pcm__view--b:where([data-rk-5864b0]), .pcm:focus-within:where([data-rk-5864b0]) .pcm__view--b:where([data-rk-5864b0]) { opacity: 1; transform: none; }
.pcm:hover:where([data-rk-5864b0]) .pcm__view--a:where([data-rk-5864b0]) { transform: scale(1.03); }
.pcm__badge:where([data-rk-5864b0]), .pcm__index:where([data-rk-5864b0]) {
position: absolute;
top: var(--sp-3, 0.75rem);
z-index: 2;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.pcm__badge:where([data-rk-5864b0]) { left: var(--sp-3, 0.75rem); padding: 0.3rem 0.55rem; background: var(--c-bg-1, #0a0a0a); color: var(--c-text-1, #fff); }
.pcm__index:where([data-rk-5864b0]) { right: var(--sp-3, 0.75rem); color: var(--c-text-2, #a3a3a3); font-variant-numeric: tabular-nums; }
.pcm__quick:where([data-rk-5864b0]) {
position: absolute;
left: var(--sp-2, 0.5rem);
right: var(--sp-2, 0.5rem);
bottom: var(--sp-2, 0.5rem);
z-index: 3;
display: grid;
gap: 6px;
padding: var(--sp-3, 0.75rem);
background: rgb(var(--paper-rgb, 5 5 5) / 0.92);
backdrop-filter: blur(10px);
transform: translateY(calc(100% + 12px));
transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.pcm:hover:where([data-rk-5864b0]) .pcm__quick:where([data-rk-5864b0]), .pcm__quick:focus-within:where([data-rk-5864b0]) { transform: none; }
.pcm__quick-label:where([data-rk-5864b0]) { font-size: 0.66rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--c-text-2, #a3a3a3); }
.pcm__sizes:where([data-rk-5864b0]) { display: flex; flex-wrap: wrap; gap: 4px; }
.pcm__size:where([data-rk-5864b0]) {
flex: 1 0 36px;
min-height: 36px;
padding: 0 6px;
border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.18);
border-radius: var(--r-sm, 2px);
background: transparent;
color: var(--c-text-1, #fff);
font: inherit;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.pcm__size:hover:where([data-rk-5864b0]) { background: var(--c-text-1, #fff); color: var(--c-bg, #050505); border-color: var(--c-text-1, #fff); }
.pcm__size.is-added:where([data-rk-5864b0]) { background: var(--shop-accent, var(--c-primary-light, #d4a017)); border-color: transparent; color: var(--c-on-primary, #050505); }
.pcm__size--wide:where([data-rk-5864b0]) { flex: 1 1 100%; letter-spacing: 0.04em; }
.pcm__size:focus-visible:where([data-rk-5864b0]) { outline: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); outline-offset: 2px; }
.pcm__info:where([data-rk-5864b0]) { display: grid; gap: 2px; }
.pcm__title:where([data-rk-5864b0]) { margin: 0; font-family: var(--font-display, Georgia, 'Times New Roman', serif); font-size: 1.05rem; font-weight: 500; letter-spacing: -0.01em; line-height: 1.25; }
.pcm--sm:where([data-rk-5864b0]) .pcm__title:where([data-rk-5864b0]) { font-size: 0.95rem; }
.pcm--lg:where([data-rk-5864b0]) .pcm__title:where([data-rk-5864b0]) { font-size: 1.3rem; }
.pcm__link:where([data-rk-5864b0]) { color: var(--c-text-1, #fff); text-decoration: none; }
.pcm__link:where([data-rk-5864b0])::after { content: ''; position: absolute; inset: 0; z-index: 1; }
.pcm__link:focus-visible:where([data-rk-5864b0]) { outline: none; }
.pcm:focus-within:not(:has(.pcm__quick:focus-within)):where([data-rk-5864b0]) { outline: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); outline-offset: 4px; }
.pcm__price:where([data-rk-5864b0]) { display: flex; gap: var(--sp-2, 0.5rem); margin: 0; font-size: 0.9rem; color: var(--c-text-1, #fff); font-variant-numeric: tabular-nums; }
.pcm__price:where([data-rk-5864b0]) s:where([data-rk-5864b0]) { color: var(--c-text-2, #a3a3a3); }
.pcm__colors:where([data-rk-5864b0]) { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); margin: 2px 0 0; font-size: 0.78rem; color: var(--c-text-2, #a3a3a3); }
.pcm__dots:where([data-rk-5864b0]) { display: inline-flex; }
.pcm__dots:where([data-rk-5864b0]) i:where([data-rk-5864b0]) { width: 10px; height: 10px; margin-right: -3px; border-radius: 50%; background: var(--sw); box-shadow: 0 0 0 1.5px var(--c-bg, #050505); }
.sr-only:where([data-rk-5864b0]) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (hover: none) {
.pcm__quick:where([data-rk-5864b0]) { position: static; transform: none; padding: var(--sp-2, 0.5rem) 0 0; background: transparent; backdrop-filter: none; }
.pcm__quick-label:where([data-rk-5864b0]) { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.pcm__view:where([data-rk-5864b0]), .pcm__quick:where([data-rk-5864b0]) { transition: none; }
}Información Rápida
- Categoría
- E-commerce
- Nombre de archivo
ProductCardMinimal.tsx- Dependencias
- Ninguna — React + CSS puro
- Etiquetas
- ecommerceshopcardproducteditorialquick-add