E-commerceecommerceshopcartbagcheckoutpage
Cart Page List
Full shopping-bag page: line items with quantity and remove, gift note, free-shipping progress and checkout summary.
Live Preview
Your bag
โ Continue shoppingItemQuantityTotal
Add a gift note
Usage
Copy and paste this code into your React component:
tsx
import { CartPageList } from 'react-component-kit-ui/ecommerce';
<CartPageList checkoutHref="/checkout/" continueHref="/shop/" freeShippingThreshold={150} />Manual Installation
If you are not using the npm package, create a new file and paste the following code: src/components/CartPageList.tsx + CartPageList.css:
CartPageList.tsx
'use client';
import { useEffect, useRef } from 'react';
import type { CartItem } from './types';
import { createCart, formatMoney } from './cart-store';
import { productIcon } from './product-icons';
import './CartPageList.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(' ');
}
/**
* CartPageList โ Full shopping-bag page: line items with quantity and remove, gift note,
* free-shipping progress and a summary card with the checkout button. Reads the cart store live.
*
* @param {string} cartKey - Cart storage key. Default 'ack-cart'.
* @param {string} title - Page heading. Default 'Your bag'.
* @param {string} checkoutHref - Checkout URL. Default '#'.
* @param {string} continueHref - "Continue shopping" URL. Default '#'.
* @param {number} freeShippingThreshold - Free shipping from this subtotal (0 hides it). Default 150.
* @param {number} shippingFlat - Shipping estimate below the threshold. Default 8.
* @param {string} currency - ISO currency. Default 'EUR'.
* @param {string} locale - Number format locale. Default 'en-GB'.
* @param {CartItem[]} demoItems - Seeds an empty cart once (demos only).
* @param {string} variant - Style (editorial|card). Default 'editorial'.
* @param {string} className - Extra class.
*
* Accessibility: one h1; the item list is a real list with labelled quantity buttons;
* totals are a polite live region; removing an item moves focus to the next row (or the heading).
*/
export interface CartPageListProps {
cartKey?: string;
title?: string;
checkoutHref?: string;
continueHref?: string;
freeShippingThreshold?: number;
shippingFlat?: number;
currency?: string;
locale?: string;
demoItems?: CartItem[];
variant?: 'editorial' | 'card';
className?: string;
}
export function CartPageList({
cartKey = 'ack-cart',
title = 'Your bag',
checkoutHref = '#',
continueHref = '#',
freeShippingThreshold = 150,
shippingFlat = 8,
currency = 'EUR',
locale = 'en-GB',
demoItems = [],
variant = 'editorial',
className = '',
}: CartPageListProps) {
// 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-bag]:not([data-bound])').forEach((root) => {
root.dataset.bound = '';
const cart = createCart(root.dataset.key);
const money = { currency: root.dataset.currency, locale: root.dataset.locale };
const free = Number(root.dataset.free) || 0;
const flat = Number(root.dataset.ship) || 0;
const list = root.querySelector<HTMLUListElement>('[data-lines]')!;
const empty = root.querySelector<HTMLElement>('[data-empty]')!;
const summary = root.querySelector<HTMLElement>('[data-summary]')!;
const cols = root.querySelector<HTMLElement>('.bag__cols')!;
const noteWrap = root.querySelector<HTMLElement>('[data-note-wrap]')!;
const note = root.querySelector<HTMLTextAreaElement>('[data-note]')!;
const heading = root.querySelector<HTMLElement>('#bag-title')!;
const out = (k: string) => root.querySelector<HTMLElement>(`[data-t="${k}"]`)!;
const noteKey = `${cart.key}:note`;
if (root.dataset.seed) { try { cart.seed(JSON.parse(root.dataset.seed)); } catch { /* ignore */ } }
try { note.value = window.localStorage.getItem(noteKey) || ''; } catch { /* ignore */ }
note.addEventListener('input', () => { try { window.localStorage.setItem(noteKey, note.value); } catch { /* ignore */ } });
const make = <K extends keyof HTMLElementTagNameMap>(tag: K, cls: string, text?: string) => {
const n = document.createElement(tag);
n.className = cls;
if (text !== undefined) n.textContent = text;
return n;
};
function row(item: CartItem, i: number, all: CartItem[]) {
const li = make('li', 'bag-line');
const media = make('div', 'bag-line__media');
media.style.setProperty('--h', String(item.hue ?? 30));
media.innerHTML = item.image
? `<img src="${encodeURI(item.image)}" alt="">`
: `<svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${productIcon(item.icon)}</svg>`;
const info = make('div', 'bag-line__info');
info.append(make('p', 'bag-line__name', item.name));
if (item.variant) info.append(make('p', 'bag-line__meta', item.variant));
info.append(make('p', 'bag-line__unit', formatMoney(item.price, money)));
const remove = make('button', 'bag-line__remove', 'Remove');
remove.type = 'button';
remove.setAttribute('aria-label', `Remove ${item.name}`);
remove.addEventListener('click', () => {
const next = all[i + 1] ?? all[i - 1];
cart.remove(item.id);
const target = next ? root.querySelector<HTMLElement>(`[aria-label="Remove ${CSS.escape(next.name)}"]`) : null;
(target ?? heading).focus();
});
info.append(remove);
const qty = make('div', 'bag-line__qty');
const minus = make('button', 'bag-line__step', 'โ');
minus.type = 'button';
minus.setAttribute('aria-label', `Decrease quantity of ${item.name}`);
minus.addEventListener('click', () => cart.setQty(item.id, item.qty - 1));
const count = make('span', 'bag-line__count', String(item.qty));
const plus = make('button', 'bag-line__step', '+');
plus.type = 'button';
plus.setAttribute('aria-label', `Increase quantity of ${item.name}`);
plus.addEventListener('click', () => cart.setQty(item.id, item.qty + 1));
qty.append(minus, count, plus);
li.append(media, info, qty, make('p', 'bag-line__total', formatMoney(item.price * item.qty, money)));
return li;
}
function render() {
const items = cart.items();
const focused = document.activeElement?.getAttribute('aria-label');
list.replaceChildren(...items.map((it, i) => row(it, i, items)));
const has = items.length > 0;
empty.hidden = has;
summary.hidden = !has;
cols.hidden = !has;
noteWrap.hidden = !has;
const count = items.reduce((n, i) => n + i.qty, 0);
root.querySelector('[data-count]')!.textContent = has ? `(${count})` : '';
const subtotal = items.reduce((n, i) => n + i.qty * i.price, 0);
const shipping = !has || (free > 0 && subtotal >= free) ? 0 : flat;
out('subtotal').textContent = formatMoney(subtotal, money);
out('shipping').textContent = shipping === 0 ? 'Free' : formatMoney(shipping, money);
out('total').textContent = formatMoney(subtotal + shipping, money);
const shipWrap = root.querySelector<HTMLElement>('[data-ship-wrap]')!;
shipWrap.hidden = free <= 0;
const left = Math.max(0, free - subtotal);
root.querySelector('[data-ship-text]')!.textContent = left > 0 ? `${formatMoney(left, money)} away from free shipping` : 'Free shipping unlocked';
root.querySelector<HTMLElement>('[data-ship-bar]')!.style.width = `${free ? Math.min(100, (subtotal / free) * 100) : 100}%`;
if (focused && !root.contains(document.activeElement)) {
root.querySelector<HTMLElement>(`[aria-label="${CSS.escape(focused)}"]`)?.focus();
}
}
render();
cart.subscribe(render);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<section className={cx(['bag', `bag--${variant}`, className])} aria-labelledby="bag-title" data-bag data-key={cartKey} data-currency={currency} data-locale={locale} data-free={freeShippingThreshold} data-ship={shippingFlat} data-seed={demoItems.length ? JSON.stringify(demoItems) : undefined} data-rk-48a7b8="">
<header className="bag__head" data-rk-48a7b8="">
<h1 className="bag__title" id="bag-title" tabIndex={-1} data-rk-48a7b8="">{title} <sup data-count data-rk-48a7b8=""></sup></h1>
<a className="bag__continue" href={continueHref} data-rk-48a7b8="">โ Continue shopping</a>
</header>
<div className="bag__layout" data-rk-48a7b8="">
<div className="bag__main" data-rk-48a7b8="">
<div className="bag__cols" aria-hidden="true" data-rk-48a7b8=""><span data-rk-48a7b8="">Item</span><span data-rk-48a7b8="">Quantity</span><span data-rk-48a7b8="">Total</span></div>
<ul className="bag__lines" data-lines aria-label="Items in your bag" data-rk-48a7b8=""></ul>
<div className="bag__empty" data-empty hidden data-rk-48a7b8="">
<p className="bag__empty-title" data-rk-48a7b8="">Your bag is empty.</p>
<p data-rk-48a7b8="">Pieces you add will wait here for you.</p>
<a className="bag__btn" href={continueHref} data-rk-48a7b8="">Discover the collection</a>
</div>
<details className="bag__note" data-note-wrap data-rk-48a7b8="">
<summary data-rk-48a7b8="">Add a gift note</summary>
<label className="sr-only" htmlFor="bag-note" data-rk-48a7b8="">Gift note</label>
<textarea id="bag-note" rows={3} maxLength={200} placeholder="Write a short message โ we'll print it on a card." data-note data-rk-48a7b8=""></textarea>
</details>
</div>
<aside className="bag__summary" aria-label="Order summary" data-summary data-rk-48a7b8="">
<div className="bag__ship" data-ship-wrap data-rk-48a7b8="">
<p data-ship-text data-rk-48a7b8=""></p>
<div className="bag__ship-bar" aria-hidden="true" data-rk-48a7b8=""><span data-ship-bar data-rk-48a7b8=""></span></div>
</div>
<dl className="bag__totals" aria-live="polite" data-rk-48a7b8="">
<div data-rk-48a7b8=""><dt data-rk-48a7b8="">Subtotal</dt><dd data-t="subtotal" data-rk-48a7b8="">โ</dd></div>
<div data-rk-48a7b8=""><dt data-rk-48a7b8="">Shipping</dt><dd data-t="shipping" data-rk-48a7b8="">โ</dd></div>
<div className="bag__total" data-rk-48a7b8=""><dt data-rk-48a7b8="">Estimated total</dt><dd data-t="total" data-rk-48a7b8="">โ</dd></div>
</dl>
<p className="bag__taxes" data-rk-48a7b8="">Taxes included. Discount codes are applied at checkout.</p>
<a className="bag__btn bag__btn--block" href={checkoutHref} data-checkout data-rk-48a7b8="">Checkout</a>
<ul className="bag__assurance" data-rk-48a7b8="">
<li data-rk-48a7b8="">Free returns within 30 days</li>
<li data-rk-48a7b8="">Secure payment ยท Cards, PayPal, Apple Pay</li>
</ul>
</aside>
</div>
</section>
</>
);
}
export default CartPageList;CartPageList.css
/* CartPageList โ ported from astro-component-kit (ecommerce/CartPageList.astro) */
.bag:where([data-rk-48a7b8]) { container-type: inline-size; min-width: 0; }
.bag__head:where([data-rk-48a7b8]) { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-4, 1rem); padding-bottom: var(--sp-5, 1.25rem); border-bottom: 1px solid var(--c-text-1, #fff); }
.bag__title:where([data-rk-48a7b8]) { margin: 0; font-family: var(--font-display, Georgia, 'Times New Roman', serif); font-size: clamp(2.2rem, 5vw, 3.6rem); font-weight: 400; letter-spacing: -0.03em; line-height: 1; color: var(--c-text-1, #fff); outline: none; }
.bag__title:where([data-rk-48a7b8]) sup:where([data-rk-48a7b8]) { font-family: var(--font-sans, system-ui); font-size: 0.9rem; letter-spacing: 0; vertical-align: super; color: var(--c-text-2, #a3a3a3); }
.bag__continue:where([data-rk-48a7b8]) { min-height: 44px; display: inline-flex; align-items: center; font-size: 0.88rem; color: var(--c-text-1, #fff); text-decoration: underline; text-underline-offset: 4px; }
.bag__layout:where([data-rk-48a7b8]) { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: clamp(2rem, 5vw, 4.5rem); align-items: start; padding-top: var(--sp-6, 1.5rem); }
.bag__cols:where([data-rk-48a7b8]) { display: grid; grid-template-columns: 1fr 140px 110px; padding-bottom: var(--sp-3, 0.75rem); border-bottom: 1px solid var(--c-border, rgba(255, 255, 255, 0.1)); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--c-text-2, #a3a3a3); }
.bag__cols:where([data-rk-48a7b8]) span:nth-child(3):where([data-rk-48a7b8]) { text-align: right; }
.bag__cols[hidden]:where([data-rk-48a7b8]) { display: none; }
.bag__lines:where([data-rk-48a7b8]) { margin: 0; padding: 0; list-style: none; }
.bag__lines:where([data-rk-48a7b8]) .bag-line {
display: grid;
grid-template-columns: 96px 1fr 140px 110px;
gap: var(--sp-4, 1rem);
align-items: start;
padding: var(--sp-5, 1.25rem) 0;
border-bottom: 1px solid var(--c-border, rgba(255, 255, 255, 0.1));
}
.bag__lines:where([data-rk-48a7b8]) .bag-line__media {
display: grid;
place-items: center;
aspect-ratio: 3 / 4;
color: hsl(var(--h) var(--pm-ink-s, 80%) var(--pm-ink-l, 80%));
background: linear-gradient(160deg, hsl(var(--h) 40% 50% / 0.18), var(--c-bg-2, #111));
border-radius: var(--r-sm, 2px);
}
.bag__lines:where([data-rk-48a7b8]) .bag-line__media svg { width: 58%; }
.bag__lines:where([data-rk-48a7b8]) .bag-line__media img { width: 100%; height: 100%; object-fit: cover; }
.bag__lines:where([data-rk-48a7b8]) .bag-line__info { display: grid; gap: 2px; min-width: 0; }
.bag__lines:where([data-rk-48a7b8]) .bag-line__name { margin: 0; font-family: var(--font-display, Georgia, serif); font-size: 1.1rem; color: var(--c-text-1, #fff); }
.bag__lines:where([data-rk-48a7b8]) .bag-line__meta, .bag__lines:where([data-rk-48a7b8]) .bag-line__unit { margin: 0; font-size: 0.82rem; color: var(--c-text-2, #a3a3a3); }
.bag__lines:where([data-rk-48a7b8]) .bag-line__remove { justify-self: start; min-height: 36px; margin-top: 6px; padding: 0; border: 0; background: none; color: var(--c-text-1, #fff); font: inherit; font-size: 0.8rem; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
.bag__lines:where([data-rk-48a7b8]) .bag-line__qty { display: inline-flex; align-items: center; width: fit-content; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.15)); border-radius: var(--r-sm, 2px); }
.bag__lines:where([data-rk-48a7b8]) .bag-line__step { width: 40px; height: 40px; border: 0; background: none; color: var(--c-text-1, #fff); font-size: 1rem; cursor: pointer; }
.bag__lines:where([data-rk-48a7b8]) .bag-line__step:hover { background: rgb(var(--ink-rgb, 255 255 255) / 0.06); }
.bag__lines:where([data-rk-48a7b8]) .bag-line__count { min-width: 28px; text-align: center; font-variant-numeric: tabular-nums; color: var(--c-text-1, #fff); }
.bag__lines:where([data-rk-48a7b8]) .bag-line__total { margin: 0; text-align: right; font-variant-numeric: tabular-nums; color: var(--c-text-1, #fff); font-weight: 600; }
.bag:where([data-rk-48a7b8]) button:focus-visible, .bag:where([data-rk-48a7b8]) a:focus-visible:where([data-rk-48a7b8]), .bag:where([data-rk-48a7b8]) summary:focus-visible:where([data-rk-48a7b8]), .bag:where([data-rk-48a7b8]) textarea:focus-visible:where([data-rk-48a7b8]) { outline: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); outline-offset: 2px; }
.bag__empty:where([data-rk-48a7b8]) { display: grid; justify-items: start; gap: var(--sp-2, 0.5rem); padding: var(--sp-10, 2.5rem) 0; }
.bag__empty[hidden]:where([data-rk-48a7b8]) { display: none; }
.bag__empty:where([data-rk-48a7b8]) p:where([data-rk-48a7b8]) { margin: 0; color: var(--c-text-2, #a3a3a3); }
.bag__empty-title:where([data-rk-48a7b8]) { font-family: var(--font-display, Georgia, serif); font-size: 1.6rem; color: var(--c-text-1, #fff) !important; }
.bag__note:where([data-rk-48a7b8]) { margin-top: var(--sp-5, 1.25rem); }
.bag__note[hidden]:where([data-rk-48a7b8]) { display: none; }
.bag__note:where([data-rk-48a7b8]) summary:where([data-rk-48a7b8]) { min-height: 44px; display: flex; align-items: center; font-size: 0.88rem; color: var(--c-text-1, #fff); cursor: pointer; }
.bag__note:where([data-rk-48a7b8]) textarea:where([data-rk-48a7b8]) { width: 100%; padding: var(--sp-3, 0.75rem); border: 1px solid var(--c-border, rgba(255, 255, 255, 0.15)); border-radius: var(--r-sm, 2px); background: transparent; color: var(--c-text-1, #fff); font: inherit; font-size: 0.9rem; resize: vertical; }
.bag__summary:where([data-rk-48a7b8]) { position: sticky; top: calc(var(--shop-header-h, 0px) + 1.5rem); display: grid; gap: var(--sp-4, 1rem); padding: var(--sp-6, 1.5rem); background: var(--c-bg-1, #0a0a0a); border: 1px solid var(--c-border, rgba(255, 255, 255, 0.1)); }
.bag--card:where([data-rk-48a7b8]) .bag__summary:where([data-rk-48a7b8]) { border-radius: var(--r-lg, 20px); }
.bag__summary[hidden]:where([data-rk-48a7b8]) { display: none; }
.bag__ship:where([data-rk-48a7b8]) p:where([data-rk-48a7b8]) { margin: 0 0 var(--sp-2, 0.5rem); font-size: 0.85rem; color: var(--c-text-1, #fff); }
.bag__ship[hidden]:where([data-rk-48a7b8]) { display: none; }
.bag__ship-bar:where([data-rk-48a7b8]) { height: 3px; background: rgb(var(--ink-rgb, 255 255 255) / 0.12); }
.bag__ship-bar:where([data-rk-48a7b8]) span:where([data-rk-48a7b8]) { display: block; height: 100%; width: 0; background: var(--shop-accent, var(--c-primary-light, #d4a017)); transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1); }
.bag__totals:where([data-rk-48a7b8]) { display: grid; gap: var(--sp-2, 0.5rem); margin: 0; }
.bag__totals:where([data-rk-48a7b8]) div:where([data-rk-48a7b8]) { display: flex; justify-content: space-between; font-size: 0.9rem; }
.bag__totals:where([data-rk-48a7b8]) dt:where([data-rk-48a7b8]) { color: var(--c-text-2, #a3a3a3); }
.bag__totals:where([data-rk-48a7b8]) dd:where([data-rk-48a7b8]) { margin: 0; color: var(--c-text-1, #fff); font-variant-numeric: tabular-nums; }
.bag__total:where([data-rk-48a7b8]) { padding-top: var(--sp-3, 0.75rem); border-top: 1px solid var(--c-border, rgba(255, 255, 255, 0.1)); }
.bag__totals:where([data-rk-48a7b8]) .bag__total:where([data-rk-48a7b8]) dt:where([data-rk-48a7b8]) { color: var(--c-text-1, #fff); font-weight: 600; }
.bag__totals:where([data-rk-48a7b8]) .bag__total:where([data-rk-48a7b8]) dd:where([data-rk-48a7b8]) { font-size: 1.25rem; font-weight: 700; }
.bag__taxes:where([data-rk-48a7b8]) { margin: 0; font-size: 0.78rem; color: var(--c-text-2, #a3a3a3); }
.bag__btn:where([data-rk-48a7b8]) { display: inline-flex; align-items: center; justify-content: center; min-height: 52px; padding: 0 1.5rem; border-radius: var(--r-sm, 2px); background: var(--c-text-1, #fff); color: var(--c-bg, #050505); font-weight: 700; font-size: 0.9rem; letter-spacing: 0.04em; text-decoration: none; transition: background 0.2s ease, color 0.2s ease; }
.bag__btn:hover:where([data-rk-48a7b8]) { background: var(--shop-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); }
.bag__btn--block:where([data-rk-48a7b8]) { width: 100%; }
.bag__assurance:where([data-rk-48a7b8]) { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; font-size: 0.78rem; color: var(--c-text-2, #a3a3a3); }
.bag__assurance:where([data-rk-48a7b8]) li:where([data-rk-48a7b8])::before { content: 'โ '; }
.sr-only:where([data-rk-48a7b8]) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@container (max-width: 900px) {
.bag__layout:where([data-rk-48a7b8]) { grid-template-columns: 1fr; }
.bag__summary:where([data-rk-48a7b8]) { position: static; }
}
@container (max-width: 600px) {
.bag__cols:where([data-rk-48a7b8]) { display: none; }
.bag__lines:where([data-rk-48a7b8]) .bag-line { grid-template-columns: 84px 1fr; }
.bag__lines:where([data-rk-48a7b8]) .bag-line__qty { grid-column: 2; }
.bag__lines:where([data-rk-48a7b8]) .bag-line__total { grid-column: 2; text-align: left; }
}Quick Info
- Category
- E-commerce
- Filename
CartPageList.tsx- Dependencies
- None โ pure React + CSS
- Tags
- ecommerceshopcartbagcheckoutpage