1 звезда на GitHub = 1 новый компонент.

Поставить звезду
E-commerceecommerceshopcardproductadd-to-cartwishlist

Store Product Card

Карточка товара с иллюстрацией или фото, бейджем, рейтингом, цветами, старой ценой, избранным и кнопкой «в корзину».

Живое превью

Использование

Скопируйте и вставьте этот код в ваш React-компонент:

tsx
import { StoreProductCard } from 'react-component-kit-ui/ecommerce';

<StoreProductCard href="/product/aurora-headphones/" categoryLabel="Audio" product={{
    slug: 'aurora-headphones',
    name: 'Aurora Wireless Headphones',
    price: 189,
    compareAt: 229,
    category: 'audio',
    rating: 4.8,
    reviews: 312,
    badge: '-18%',
    icon: 'headphones',
    hue: 38,
  }} />

Ручная установка

Если вы не используете пакет npm, создайте файл и вставьте следующий код: src/components/StoreProductCard.tsx + StoreProductCard.css:

StoreProductCard.tsx
'use client';

import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import ProductMedia from './ProductMedia';
import ProductRating from './ProductRating';
import Badge from '../feedback/Badge';
import { formatMoney } from './cart-store';
import type { Product } from './types';
import { bindAddToCart } from './cart-store';
import './StoreProductCard.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;
}

/**
 * StoreProductCard — Shop product tile with media, badge, rating, colour swatches,
 * price (with compare-at), wishlist toggle and an add-to-cart button wired to the cart store.
 *
 * @param {Product} product - Product data (see ecommerce/types.ts). A demo product is used by default.
 * @param {string} href - Product page URL. Default '#'.
 * @param {string} categoryLabel - Small label above the title (e.g. category name).
 * @param {string} currency - ISO currency. Default 'USD'.
 * @param {string} locale - Number format locale. Default 'en-US'.
 * @param {string} ctaLabel - Add-to-cart label. Default 'Add to cart'.
 * @param {string} addedLabel - Label shown briefly after adding. Default 'Added'.
 * @param {string} cartKey - Cart storage key (several stores on one domain). Default 'ack-cart'.
 * @param {boolean} openCart - Open the CartDrawer after adding; false fires a CartToast instead. Default true.
 * @param {string} variant - Visual style (glass|minimal|bordered). Default 'glass'.
 * @param {string} size - Card density (sm|md|lg). Default 'md'.
 * @param {string} headingLevel - Title element (h2|h3|h4). Default 'h3'.
 * @param {string} className - Extra class.
 *
 * Accessibility: the title link covers the whole card (stretched link) so there are no
 * nested interactive elements; wishlist uses aria-pressed; out-of-stock disables the button.
 */
export interface StoreProductCardProps {
  product?: Product;
  href?: string;
  categoryLabel?: string;
  currency?: string;
  locale?: string;
  ctaLabel?: string;
  addedLabel?: string;
  cartKey?: string;
  openCart?: boolean;
  variant?: 'glass' | 'minimal' | 'bordered';
  size?: 'sm' | 'md' | 'lg';
  headingLevel?: 'h2' | 'h3' | 'h4';
  className?: string;
}

export function StoreProductCard({
  product = {
    slug: 'aurora-headphones',
    name: 'Aurora Wireless Headphones',
    price: 189,
    compareAt: 229,
    category: 'audio',
    rating: 4.8,
    reviews: 312,
    badge: '-18%',
    badgeVariant: 'warning',
    colors: [{ name: 'Graphite', hex: '#2b2b2f' }, { name: 'Sand', hex: '#d8c3a5' }, { name: 'Ocean', hex: '#3d6f9e' }],
    icon: 'headphones',
    hue: 38,
    inStock: true,
  },
  href = '#',
  categoryLabel = 'Audio',
  currency = 'USD',
  locale = 'en-US',
  ctaLabel = 'Add to cart',
  addedLabel = 'Added',
  cartKey = 'ack-cart',
  openCart = true,
  variant = 'glass',
  size = 'md',
  headingLevel = 'h3',
  className = '',
}: StoreProductCardProps) {
  const Heading: any = headingLevel;
  const money = { currency, locale };
  const inStock = product.inStock !== false;
  const cartPayload = JSON.stringify({
    id: product.slug,
    name: product.name,
    price: product.price,
    icon: product.icon,
    hue: product.hue,
    image: product.image,
    variant: product.colors?.[0]?.name,
  });
  const titleId = `spc-${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;
    function init() {
        bindAddToCart(document);
        document.querySelectorAll<HTMLButtonElement>('[data-wishlist]:not([data-wish-bound])').forEach((btn) => {
          btn.dataset.wishBound = '';
          btn.addEventListener('click', () => {
            btn.setAttribute('aria-pressed', String(btn.getAttribute('aria-pressed') !== 'true'));
          });
        });
      }
      init();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <article className={cx(['sp-card', `sp-card--${variant}`, `sp-card--${size}`, 'is-hoverable', 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-82d407="">
        <div className="sp-card__media" data-rk-82d407="">
          <ProductMedia image={product.image} alt={product.name} icon={product.icon} hue={product.hue} />
          {product.badge && (
            <div className="sp-card__badge" data-rk-82d407=""><Badge variant={product.badgeVariant ?? 'default'}>{product.badge}</Badge></div>
          )}
          <button className="sp-card__wish" type="button" aria-pressed="false" aria-label={`Save ${product.name} to wishlist`} data-wishlist data-rk-82d407="">
            <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" data-rk-82d407=""><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1 1.1L12 21l7.8-7.5 1-1.1a5.5 5.5 0 0 0 0-7.8z" data-rk-82d407="" /></svg>
          </button>
        </div>
      
        <div className="sp-card__body" data-rk-82d407="">
          {categoryLabel && <p className="sp-card__cat" data-rk-82d407="">{categoryLabel}</p>}
          <Heading className="sp-card__title" id={titleId} data-rk-82d407="">
            <a className="sp-card__link" href={href} data-rk-82d407="">{product.name}</a>
          </Heading>
          {product.rating !== undefined && <ProductRating value={product.rating} count={product.reviews} />}
      
          {product.colors && product.colors.length > 0 && (
            <ul className="sp-card__swatches" aria-label={`Colours: ${product.colors.map((c) => c.name).join(', ')}`} data-rk-82d407="">
              {product.colors.slice(0, 4).map((c, __i: number) => (
                <li key={__i} className="sp-card__swatch" style={st(`--sw: ${c.hex}`)} title={c.name} data-rk-82d407="" />
              ))}
              {product.colors.length > 4 && <li className="sp-card__swatch-more" data-rk-82d407="">+{product.colors.length - 4}</li>}
            </ul>
          )}
      
          <div className="sp-card__foot" data-rk-82d407="">
            <p className="sp-card__price" data-rk-82d407="">
              <span className="sp-card__now" data-rk-82d407="">{formatMoney(product.price, money)}</span>
              {product.compareAt && (
                <s className="sp-card__was" data-rk-82d407=""><span className="sr-only" data-rk-82d407="">Was </span>{formatMoney(product.compareAt, money)}</s>
              )}
            </p>
            <button className="sp-card__add" type="button" data-add-to-cart={cartPayload} data-cart-key={cartKey} data-open-cart={openCart ? undefined : 'false'} data-added-label={addedLabel} disabled={!inStock} aria-label={inStock ? `${ctaLabel}: ${product.name}` : `${product.name} is sold out`} data-rk-82d407="">
              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" data-rk-82d407=""><path d="M6 6h15l-1.5 9h-12z" data-rk-82d407="" /><path d="M6 6 5 3H2" data-rk-82d407="" /><circle cx="9" cy="20" r="1.4" data-rk-82d407="" /><circle cx="18" cy="20" r="1.4" data-rk-82d407="" /></svg>
              <span data-label data-rk-82d407="">{inStock ? ctaLabel : 'Sold out'}</span>
            </button>
          </div>
        </div>
      </article>
    </>
  );
}

export default StoreProductCard;
StoreProductCard.css
/* StoreProductCard — ported from astro-component-kit (ecommerce/StoreProductCard.astro) */

.sp-card:where([data-rk-82d407]) {
  container-type: inline-size;
  position: relative;
  display: flex;
  flex-direction: column;
  border-radius: var(--r-lg, 20px);
  overflow: hidden;
  background: var(--c-surface, rgb(var(--ink-rgb, 255 255 255) / 0.02));
  border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.05));
  transition: border-color 0.25s ease, transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.3s ease;
}
.sp-card--glass:where([data-rk-82d407]) {
  background: linear-gradient(180deg, rgb(var(--ink-rgb, 255 255 255) / 0.035), rgb(var(--ink-rgb, 255 255 255) / 0.01));
  backdrop-filter: blur(10px);
}
.sp-card--minimal:where([data-rk-82d407]) { background: transparent; border-color: transparent; }
.sp-card--bordered:where([data-rk-82d407]) { border-color: rgb(var(--ink-rgb, 255 255 255) / 0.12); }
.sp-card:hover:where([data-rk-82d407]) {
  border-color: rgba(212, 160, 23, 0.35);
  transform: translateY(-3px);
  box-shadow: 0 18px 40px -22px rgba(212, 160, 23, 0.45);
}
.sp-card:focus-within:where([data-rk-82d407]) {
  outline: 2px solid var(--c-primary-light, #d4a017);
  outline-offset: 2px;
}

.sp-card__media:where([data-rk-82d407]) { position: relative; border-radius: var(--r-lg, 20px) var(--r-lg, 20px) 0 0; }
.sp-card--minimal:where([data-rk-82d407]) .sp-card__media:where([data-rk-82d407]) { border-radius: var(--r-lg, 20px); }
.sp-card__badge:where([data-rk-82d407]) { position: absolute; top: var(--sp-3, 0.75rem); left: var(--sp-3, 0.75rem); z-index: 3; }
.sp-card__wish:where([data-rk-82d407]) {
  position: absolute;
  top: var(--sp-2, 0.5rem);
  right: var(--sp-2, 0.5rem);
  z-index: 3;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: var(--r-full, 9999px);
  border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.1);
  background: rgb(var(--paper-rgb, 5 5 5) / 0.55);
  backdrop-filter: blur(8px);
  color: var(--c-text-1, #fff);
  cursor: pointer;
  transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.sp-card__wish:hover:where([data-rk-82d407]) { transform: scale(1.06); }
.sp-card__wish[aria-pressed='true']:where([data-rk-82d407]) { color: var(--c-danger, #f87171); }
.sp-card__wish[aria-pressed='true']:where([data-rk-82d407]) svg:where([data-rk-82d407]) { fill: currentColor; }
.sp-card__wish:focus-visible:where([data-rk-82d407]),
.sp-card__add:focus-visible:where([data-rk-82d407]) { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 2px; }

.sp-card__body:where([data-rk-82d407]) {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2, 0.5rem);
  padding: var(--sp-4, 1rem) var(--sp-4, 1rem) var(--sp-5, 1.25rem);
  flex: 1;
}
.sp-card--sm:where([data-rk-82d407]) .sp-card__body:where([data-rk-82d407]) { padding: var(--sp-3, 0.75rem); }
.sp-card--lg:where([data-rk-82d407]) .sp-card__body:where([data-rk-82d407]) { padding: var(--sp-6, 1.5rem); }
.sp-card--minimal:where([data-rk-82d407]) .sp-card__body:where([data-rk-82d407]) { padding-inline: 0; }

.sp-card__cat:where([data-rk-82d407]) {
  margin: 0;
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--c-primary-light, #d4a017);
}
.sp-card__title:where([data-rk-82d407]) { margin: 0; font-size: 1rem; font-weight: 700; line-height: 1.3; }
.sp-card--lg:where([data-rk-82d407]) .sp-card__title:where([data-rk-82d407]) { font-size: 1.15rem; }
.sp-card__link:where([data-rk-82d407]) { color: var(--c-text-1, #fff); text-decoration: none; }
.sp-card__link:focus-visible:where([data-rk-82d407]) { outline: none; }
.sp-card__link:where([data-rk-82d407])::after { content: ''; position: absolute; inset: 0; z-index: 2; }

.sp-card__swatches:where([data-rk-82d407]) { display: flex; align-items: center; gap: 6px; margin: 2px 0 0; padding: 0; list-style: none; }
.sp-card__swatch:where([data-rk-82d407]) {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--sw);
  box-shadow: 0 0 0 1px rgb(var(--ink-rgb, 255 255 255) / 0.25);
}
.sp-card__swatch-more:where([data-rk-82d407]) { font-size: 0.72rem; color: var(--c-text-2, #a3a3a3); }

.sp-card__foot:where([data-rk-82d407]) {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3, 0.75rem);
  margin-top: auto;
  padding-top: var(--sp-2, 0.5rem);
}
.sp-card__price:where([data-rk-82d407]) { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0 var(--sp-2, 0.5rem); margin: 0; }
.sp-card__now:where([data-rk-82d407]) { font-size: 1.1rem; font-weight: 800; color: var(--c-text-1, #fff); }
.sp-card__was:where([data-rk-82d407]) { font-size: 0.82rem; color: var(--c-text-2, #a3a3a3); }

.sp-card__add:where([data-rk-82d407]) {
  position: relative;
  z-index: 3;
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  min-height: 44px;
  padding: 0 var(--sp-4, 1rem);
  border: 0;
  border-radius: var(--r-full, 9999px);
  background: var(--c-primary-light, #d4a017);
  color: var(--c-on-primary, #050505);
  font: inherit;
  font-size: 0.82rem;
  font-weight: 700;
  white-space: nowrap;
  cursor: pointer;
  transition: transform 0.2s ease, filter 0.2s ease, background 0.2s ease;
}
.sp-card__add:hover:where([data-rk-82d407]) { filter: brightness(1.08); transform: translateY(-1px); }
.sp-card__add:disabled:where([data-rk-82d407]) { background: rgb(var(--ink-rgb, 255 255 255) / 0.08); color: var(--c-text-2, #a3a3a3); cursor: not-allowed; transform: none; }
.sp-card__add.is-added:where([data-rk-82d407]) { background: var(--c-success, #34d399); }
.sp-card--sm:where([data-rk-82d407]) .sp-card__add:where([data-rk-82d407]) span:where([data-rk-82d407]) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.sp-card--sm:where([data-rk-82d407]) .sp-card__add:where([data-rk-82d407]) { width: 44px; padding: 0; justify-content: center; }

.sr-only:where([data-rk-82d407]) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }

/* Narrow cards (2-up on phones): stack price above a full-width button */
@container (max-width: 250px) {
  .sp-card__body:where([data-rk-82d407]) { padding: var(--sp-3, 0.75rem) var(--sp-3, 0.75rem) var(--sp-4, 1rem); }
  .sp-card__foot:where([data-rk-82d407]) { flex-direction: column; align-items: stretch; gap: var(--sp-2, 0.5rem); }
  .sp-card__add:where([data-rk-82d407]) { justify-content: center; }
  .sp-card__title:where([data-rk-82d407]) { font-size: 0.92rem; }
}

@media (prefers-reduced-motion: reduce) {
  .sp-card:where([data-rk-82d407]), .sp-card__add:where([data-rk-82d407]), .sp-card__wish:where([data-rk-82d407]) { transition: none; }
  .sp-card:hover:where([data-rk-82d407]) { transform: none; }
}

Краткая информация

Категория
E-commerce
Имя файла
StoreProductCard.tsx
Зависимости
Нет — чистый React + CSS
Теги
ecommerceshopcardproductadd-to-cartwishlist