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

Поставить звезду
E-commerceecommerceshopcheckoutsummarytotalspromo

Order Summary

Боковая сводка заказа с товарами, промокодами, доставкой, налогом и живым итогом по выбранному способу доставки.

Живое превью

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

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

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

<OrderSummary taxRate={0.08} promoCodes={{ ASTRO10: 10 }} />

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

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

OrderSummary.tsx
'use client';

import { useEffect, useRef } from 'react';
import { createCart, formatMoney } from './cart-store';
import { productIcon } from './product-icons';
import type { CartItem, ShippingRate } from './types';
import './OrderSummary.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(' ');
}

/**
 * OrderSummary — Checkout sidebar: cart lines, promo code, subtotal, shipping, tax and total.
 * Reads the cart store live and follows the shipping method chosen in CheckoutForm.
 *
 * @param {string} title - Heading. Default 'Order summary'.
 * @param {string} cartKey - Cart storage key. Default 'ack-cart'.
 * @param {ShippingRate[]} shippingRates - Rates matching CheckoutForm's `shipping` radios.
 * @param {number} freeShippingThreshold - Subtotal that makes the first rate free (0 disables). Default 75.
 * @param {number} taxRate - Tax as a fraction (0.08 = 8%). Default 0.08.
 * @param {Record<string, number>} promoCodes - Code → percent discount. Default { ASTRO10: 10 }.
 * @param {string} editHref - "Edit cart" link (optional).
 * @param {string} currency - ISO currency. Default 'USD'.
 * @param {string} locale - Number format locale. Default 'en-US'.
 * @param {boolean} sticky - Stick while the form scrolls (desktop). Default true.
 * @param {string} variant - Style (glass|plain). Default 'glass'.
 * @param {string} className - Extra class.
 *
 * Emits `ack:totals` on document with `{ subtotal, discount, shipping, tax, total, formatted }`.
 *
 * Accessibility: the totals block is a polite live region; promo feedback is a status message.
 */
export interface OrderSummaryProps {
  title?: string;
  cartKey?: string;
  shippingRates?: ShippingRate[];
  freeShippingThreshold?: number;
  taxRate?: number;
  promoCodes?: Record<string, number>;
  editHref?: string;
  currency?: string;
  locale?: string;
  sticky?: boolean;
  variant?: 'glass' | 'plain';
  className?: string;
}

export function OrderSummary({
  title = 'Order summary',
  cartKey = 'ack-cart',
  shippingRates = [
    { id: 'standard', label: 'Standard', eta: '3–5 business days', price: 6 },
    { id: 'express', label: 'Express', eta: '1–2 business days', price: 14 },
  ],
  freeShippingThreshold = 75,
  taxRate = 0.08,
  promoCodes = { ASTRO10: 10 },
  editHref,
  currency = 'USD',
  locale = 'en-US',
  sticky = true,
  variant = 'glass',
  className = '',
}: OrderSummaryProps) {
  const config = JSON.stringify({ shippingRates, freeShippingThreshold, taxRate, promoCodes, currency, locale });

  // 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-order-summary]:not([data-bound])').forEach((root) => {
        root.dataset.bound = '';
        const cfg = JSON.parse(root.dataset.config || '{}') as {
          shippingRates: ShippingRate[]; freeShippingThreshold: number; taxRate: number;
          promoCodes: Record<string, number>; currency: string; locale: string;
        };
        const money = { currency: cfg.currency, locale: cfg.locale };
        const cart = createCart(root.dataset.key);
        const list = root.querySelector<HTMLUListElement>('[data-lines]')!;
        const empty = root.querySelector<HTMLElement>('[data-empty]')!;
        const promo = root.querySelector<HTMLFormElement>('[data-promo]')!;
        const promoMsg = root.querySelector<HTMLElement>('[data-promo-msg]')!;
        const discountRow = root.querySelector<HTMLElement>('[data-discount-row]')!;
        const codeOut = root.querySelector<HTMLElement>('[data-code]')!;
        const out = (k: string) => root.querySelector<HTMLElement>(`[data-t="${k}"]`)!;
        let code = '';
        let shippingId = cfg.shippingRates[0]?.id;
    
        const line = (i: CartItem) => {
          const li = document.createElement('li');
          li.className = 'o-line';
          const media = document.createElement('span');
          media.className = 'o-line__media';
          media.style.setProperty('--h', String(i.hue ?? 40));
          media.innerHTML = i.image
            ? `<img src="${encodeURI(i.image)}" alt="">`
            : `<svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${productIcon(i.icon)}</svg>`;
          const qty = document.createElement('span');
          qty.className = 'o-line__qty';
          qty.textContent = String(i.qty);
          qty.setAttribute('aria-hidden', 'true');
          media.append(qty);
          const info = document.createElement('span');
          info.className = 'o-line__info';
          const name = document.createElement('span');
          name.className = 'o-line__name';
          name.textContent = i.name;
          const meta = document.createElement('span');
          meta.className = 'o-line__meta';
          meta.textContent = [i.variant, `Qty ${i.qty}`].filter(Boolean).join(' · ');
          info.append(name, meta);
          const price = document.createElement('span');
          price.className = 'o-line__price';
          price.textContent = formatMoney(i.price * i.qty, money);
          li.append(media, info, price);
          return li;
        };
    
        function render() {
          const items = cart.items();
          list.replaceChildren(...items.map(line));
          empty.hidden = items.length > 0;
          const subtotal = items.reduce((n, i) => n + i.price * i.qty, 0);
          const pct = code ? cfg.promoCodes[code] ?? 0 : 0;
          const discount = Math.round(subtotal * pct) / 100;
          const rate = cfg.shippingRates.find((r) => r.id === shippingId) ?? cfg.shippingRates[0];
          const freeStandard = cfg.freeShippingThreshold > 0 && subtotal - discount >= cfg.freeShippingThreshold && rate === cfg.shippingRates[0];
          const shipping = items.length === 0 || !rate ? 0 : freeStandard ? 0 : rate.price;
          const tax = Math.round((subtotal - discount) * cfg.taxRate * 100) / 100;
          const total = Math.max(0, subtotal - discount + shipping + tax);
    
          out('subtotal').textContent = formatMoney(subtotal, money);
          out('discount').textContent = `−${formatMoney(discount, money)}`;
          discountRow.hidden = discount === 0;
          codeOut.textContent = code ? `(${code})` : '';
          out('shipping').textContent = shipping === 0 && items.length ? 'Free' : formatMoney(shipping, money);
          out('tax').textContent = formatMoney(tax, money);
          out('total').textContent = formatMoney(total, money);
    
          document.dispatchEvent(new CustomEvent('ack:totals', {
            detail: { subtotal, discount, shipping, tax, total, formatted: formatMoney(total, money), freeStandard, count: items.length },
          }));
        }
    
        promo.addEventListener('submit', (e) => {
          e.preventDefault();
          const input = promo.elements.namedItem('code') as HTMLInputElement;
          const value = input.value.trim().toUpperCase();
          if (!value) { promoMsg.textContent = 'Enter a code first.'; promoMsg.dataset.state = 'error'; return; }
          if (cfg.promoCodes[value] !== undefined) {
            code = value;
            promoMsg.textContent = `${value} applied — ${cfg.promoCodes[value]}% off.`;
            promoMsg.dataset.state = 'ok';
          } else {
            promoMsg.textContent = `"${value}" is not a valid code.`;
            promoMsg.dataset.state = 'error';
          }
          render();
        });
    
        document.addEventListener('change', (e) => {
          const t = e.target as HTMLInputElement;
          if (t?.name === 'shipping' && t.checked) { shippingId = t.value; render(); }
        });
        document.addEventListener('ack:totals-request', render);
    
        render();
        cart.subscribe(render);
      });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <aside className={cx(['o-summary', `o-summary--${variant}`, { 'o-summary--sticky': sticky }, className])} aria-labelledby="o-summary-title" data-order-summary data-key={cartKey} data-config={config} data-rk-abd13c="">
        <div className="o-summary__head" data-rk-abd13c="">
          <h2 className="o-summary__title" id="o-summary-title" data-rk-abd13c="">{title}</h2>
          {editHref && <a className="o-summary__edit" href={editHref} data-rk-abd13c="">Edit cart</a>}
        </div>
      
        <ul className="o-summary__lines" data-lines aria-label="Items in your order" data-rk-abd13c=""></ul>
        <p className="o-summary__empty" data-empty hidden data-rk-abd13c="">Your cart is empty.</p>
      
        <form className="o-summary__promo" data-promo noValidate data-rk-abd13c="">
          <label className="sr-only" htmlFor="o-summary-code" data-rk-abd13c="">Discount code</label>
          <input id="o-summary-code" name="code" type="text" placeholder="Discount code" autoComplete="off" autoCapitalize="characters" spellCheck="false" data-rk-abd13c="" />
          <button type="submit" data-rk-abd13c="">Apply</button>
        </form>
        <p className="o-summary__promo-msg" role="status" data-promo-msg data-rk-abd13c=""></p>
      
        <dl className="o-summary__totals" aria-live="polite" data-rk-abd13c="">
          <div data-rk-abd13c=""><dt data-rk-abd13c="">Subtotal</dt><dd data-t="subtotal" data-rk-abd13c="">—</dd></div>
          <div data-discount-row hidden data-rk-abd13c=""><dt data-rk-abd13c="">Discount <span data-code data-rk-abd13c=""></span></dt><dd data-t="discount" data-rk-abd13c="">—</dd></div>
          <div data-rk-abd13c=""><dt data-rk-abd13c="">Shipping</dt><dd data-t="shipping" data-rk-abd13c="">—</dd></div>
          <div data-rk-abd13c=""><dt data-rk-abd13c="">Estimated tax</dt><dd data-t="tax" data-rk-abd13c="">—</dd></div>
          <div className="o-summary__total" data-rk-abd13c=""><dt data-rk-abd13c="">Total</dt><dd data-t="total" data-rk-abd13c="">—</dd></div>
        </dl>
      </aside>
    </>
  );
}

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

.o-summary:where([data-rk-abd13c]) {
  display: grid;
  gap: var(--sp-4, 1rem);
  align-self: start;
  min-width: 0;
}
.o-summary--glass:where([data-rk-abd13c]) {
  padding: var(--sp-6, 1.5rem);
  border-radius: var(--r-xl, 28px);
  border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.07);
  background: linear-gradient(180deg, rgb(var(--ink-rgb, 255 255 255) / 0.035), rgb(var(--ink-rgb, 255 255 255) / 0.012));
}
.o-summary--sticky:where([data-rk-abd13c]) { position: sticky; top: calc(var(--store-header-h, 68px) + 1.5rem); }

.o-summary__head:where([data-rk-abd13c]) { display: flex; justify-content: space-between; align-items: baseline; }
.o-summary__title:where([data-rk-abd13c]) { margin: 0; font-size: 1.15rem; font-weight: 800; color: var(--c-text-1, #fff); }
.o-summary__edit:where([data-rk-abd13c]) { font-size: 0.85rem; font-weight: 600; }

.o-summary__lines:where([data-rk-abd13c]) { display: grid; gap: var(--sp-3, 0.75rem); margin: 0; padding: 0; list-style: none; }
.o-summary__lines:where([data-rk-abd13c]) .o-line { display: grid; grid-template-columns: 60px 1fr auto; gap: var(--sp-3, 0.75rem); align-items: center; }
.o-summary__lines:where([data-rk-abd13c]) .o-line__media {
  position: relative;
  display: grid;
  place-items: center;
  width: 60px;
  height: 60px;
  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);
  border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.05));
}
.o-summary__lines:where([data-rk-abd13c]) .o-line__media svg { width: 58%; }
.o-summary__lines:where([data-rk-abd13c]) .o-line__media img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
.o-summary__lines:where([data-rk-abd13c]) .o-line__qty {
  position: absolute;
  top: -7px;
  right: -7px;
  min-width: 22px;
  height: 22px;
  padding: 0 6px;
  border-radius: 999px;
  background: var(--c-text-1, #fff);
  color: var(--c-bg, #050505);
  font-size: 0.72rem;
  font-weight: 800;
  line-height: 22px;
  text-align: center;
}
.o-summary__lines:where([data-rk-abd13c]) .o-line__info { display: grid; gap: 2px; min-width: 0; }
.o-summary__lines:where([data-rk-abd13c]) .o-line__name { font-size: 0.88rem; font-weight: 700; color: var(--c-text-1, #fff); line-height: 1.3; }
.o-summary__lines:where([data-rk-abd13c]) .o-line__meta { font-size: 0.78rem; color: var(--c-text-2, #a3a3a3); }
.o-summary__lines:where([data-rk-abd13c]) .o-line__price { font-size: 0.9rem; font-weight: 700; color: var(--c-text-1, #fff); }
.o-summary__empty:where([data-rk-abd13c]) { margin: 0; color: var(--c-text-2, #a3a3a3); }

.o-summary__promo:where([data-rk-abd13c]) { display: flex; gap: var(--sp-2, 0.5rem); padding-top: var(--sp-4, 1rem); border-top: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.05)); }
.o-summary__promo:where([data-rk-abd13c]) input:where([data-rk-abd13c]) {
  flex: 1;
  min-width: 0;
  min-height: 46px;
  padding: 0 var(--sp-4, 1rem);
  border-radius: var(--r-md, 12px);
  border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.1);
  background: rgb(var(--ink-rgb, 255 255 255) / 0.03);
  color: var(--c-text-1, #fff);
  font: inherit;
  font-size: 0.9rem;
  text-transform: uppercase;
}
.o-summary__promo:where([data-rk-abd13c]) input:where([data-rk-abd13c])::placeholder { text-transform: none; color: var(--c-text-2, #a3a3a3); }
.o-summary__promo:where([data-rk-abd13c]) input:focus:where([data-rk-abd13c]) { outline: none; border-color: var(--c-primary-light, #d4a017); }
.o-summary__promo:where([data-rk-abd13c]) button:where([data-rk-abd13c]) {
  min-height: 46px;
  padding: 0 var(--sp-5, 1.25rem);
  border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.14);
  border-radius: var(--r-md, 12px);
  background: rgb(var(--ink-rgb, 255 255 255) / 0.05);
  color: var(--c-text-1, #fff);
  font: inherit;
  font-weight: 700;
  font-size: 0.88rem;
  cursor: pointer;
}
.o-summary__promo:where([data-rk-abd13c]) button:hover:where([data-rk-abd13c]) { background: rgb(var(--ink-rgb, 255 255 255) / 0.09); }
.o-summary__promo:where([data-rk-abd13c]) button:focus-visible:where([data-rk-abd13c]) { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 2px; }
.o-summary__promo-msg:where([data-rk-abd13c]) { margin: calc(var(--sp-2, 0.5rem) * -1) 0 0; min-height: 1em; font-size: 0.8rem; }
.o-summary__promo-msg:empty:where([data-rk-abd13c]) { display: none; }
.o-summary__promo-msg[data-state='ok']:where([data-rk-abd13c]) { color: var(--c-success, #34d399); }
.o-summary__promo-msg[data-state='error']:where([data-rk-abd13c]) { color: var(--c-danger, #f87171); }

.o-summary__totals:where([data-rk-abd13c]) { display: grid; gap: var(--sp-2, 0.5rem); margin: 0; padding-top: var(--sp-4, 1rem); border-top: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.05)); }
.o-summary__totals:where([data-rk-abd13c]) div:where([data-rk-abd13c]) { display: flex; justify-content: space-between; font-size: 0.9rem; }
.o-summary__totals:where([data-rk-abd13c]) div[hidden]:where([data-rk-abd13c]) { display: none; }
.o-summary__totals:where([data-rk-abd13c]) dt:where([data-rk-abd13c]) { color: var(--c-text-2, #a3a3a3); }
.o-summary__totals:where([data-rk-abd13c]) dd:where([data-rk-abd13c]) { margin: 0; color: var(--c-text-1, #fff); font-weight: 600; }
.o-summary__totals:where([data-rk-abd13c]) [data-discount-row]:where([data-rk-abd13c]) dd:where([data-rk-abd13c]) { color: var(--c-success, #34d399); }
.o-summary__total:where([data-rk-abd13c]) { margin-top: var(--sp-2, 0.5rem); padding-top: var(--sp-3, 0.75rem); border-top: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.05)); }
.o-summary__totals:where([data-rk-abd13c]) .o-summary__total:where([data-rk-abd13c]) dt:where([data-rk-abd13c]) { color: var(--c-text-1, #fff); font-weight: 700; font-size: 1rem; }
.o-summary__totals:where([data-rk-abd13c]) .o-summary__total:where([data-rk-abd13c]) dd:where([data-rk-abd13c]) { font-size: 1.4rem; font-weight: 900; letter-spacing: -0.02em; }

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

@media (max-width: 960px) { .o-summary--sticky:where([data-rk-abd13c]) { position: static; } }

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

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