Commerce

ProductCard

A product in a shop's grid: picture, name, price with the discount, stars, a badge and Add to cart, as a plain form or a callback.

#commerce#card#price#image#button#cart

Demo

Preview
SaleTrail runner
Northwind

Trail runner

$89.00was $120.00, −26% off
NewInsulated bottle, 750 ml

Insulated bottle, 750 ml

32,50 €
Rain jacket

Rain jacket

Out of stock
$149.00
Wool beanie
Northwind

Wool beanie

Only a few left
$24.00

Nothing added yet.

Get it

It needs theme.qwe, as every component does, and price.qwe; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/price.qwe$ curl -O https://quillolang.org/components/ui/product_card.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/product_card.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/product_card.qwe
component ProductCardDemoLive() {    state added = 0    <div.demo-stack>        <div style="max-width: 240px">            <ProductCard id="beanie" name="Wool beanie" brand="Northwind" price={24} image="https://picsum.photos/seed/beanie/400/300"                         rating={4} reviews={37} stock="low" on_add={() => { added += 1 }}/>        </div>        <p.demo-note>{added == 0 ? "Nothing added yet." : `Added ${added} ${added == 1 ? "time" : "times"} (on_add).`}</p>    </div>}view ProductCardDemo() =    <div.demo-stack>        <div.demo-row style="align-items: stretch">            <div style="width: 240px">                <ProductCard id="runner" name="Trail runner" brand="Northwind" price={89} old={120} currency="USD"                             image="https://picsum.photos/seed/runner/400/300" rating={4.5} reviews={128} badge="Sale" badge_tone="danger"                             href="#product_card" action="/cart"/>            </div>            <div style="width: 240px">                <ProductCard id="bottle" name="Insulated bottle, 750 ml" price={32.5} currency="EUR" locale="de-DE"                             image="https://picsum.photos/seed/bottle/400/300" rating={3.8} reviews={9} badge="New" ratio="1"/>            </div>            <div style="width: 240px">                <ProductCard id="jacket" name="Rain jacket" price={149} image="https://picsum.photos/seed/jacket/400/300" stock="out" action="/cart"/>            </div>        </div>        <ProductCardDemoLive/>    </div>

Source

ui/product_card.qwe
// ProductCard: a product in a shop's grid: its picture, name, price (with the// old one and the discount), rating, a badge and an Add to cart button. The// price is written as Price writes it (this file needs price.qwe).////   <ProductCard id="sku-1" name="Trail runner" price={89} old={120} image="/p/1.jpg"//                rating={4.5} reviews={128} badge="New" href="/products/sku-1" action="/cart"/>//   <ProductCard name="Wool beanie" price={24} image="/p/2.jpg" stock="low" on_add={() => { added += 1 }}/>//// id: the product's id. name: its name; href makes it a link. brand: a quieter// line above the name. image, alt: its picture (alt is the name when left out).// price, old, currency, locale, cents: as for Price. rating: stars out of 5, any// fraction; reviews: how many, shown "(128)". badge: a word on the picture// ("New", "Sale"); badge_tone: "accent" (default), "success", "warning",// "danger" or "neutral". stock: "in" (default), "low" ("Only a few left") or// "out" (the button is disabled and says "Sold out").// action: a URL the button posts to as a plain <form method="post"> with the// product's id as `product`; without one the button calls on_add(), inside one// of your components. add_label: the button's text ("Add to cart").// corner: HTML at the picture's top-right (a WishlistButton). ratio: the// picture's shape ("4/3"; "1", "3/4"). size: "sm" or "md" (default).import ui/price.qwe use Priceimport ui/theme.qwe use ui_clslet product_card_css = css`.ui-product { display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius);  background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.4 var(--ui-font); overflow: hidden; transition: border-color .15s, box-shadow .15s; }.ui-product:hover { border-color: var(--ui-strong); box-shadow: var(--ui-shadow); }.ui-product-media { position: relative; display: block; aspect-ratio: var(--r, 4 / 3); background: var(--ui-subtle); overflow: hidden; }.ui-product-media img { display: block; width: 100%; height: 100%; object-fit: cover; transition: transform .25s; }.ui-product:hover .ui-product-media img { transform: scale(1.03); }.ui-product-badge { position: absolute; top: 10px; left: 10px; z-index: 1; height: 22px; padding: 0 8px; border-radius: 999px;  font: 600 11px/22px var(--ui-font); letter-spacing: .02em; text-transform: uppercase; color: var(--ui-accent-fg); background: var(--ui-accent); }.ui-product-badge-success { background: var(--ui-success); color: #fff; }.ui-product-badge-warning { background: var(--ui-warning); color: #fff; }.ui-product-badge-danger { background: var(--ui-danger); color: #fff; }.ui-product-badge-neutral { background: var(--ui-fg); color: var(--ui-bg); }.ui-product-corner { position: absolute; top: 8px; right: 8px; z-index: 1; }.ui-product-out .ui-product-media img { opacity: .55; filter: grayscale(.6); }.ui-product-body { display: grid; gap: 6px; padding: 12px 14px 14px; flex: 1; align-content: start; }.ui-product-brand { font-size: 12px; color: var(--ui-muted); text-transform: uppercase; letter-spacing: .04em; }.ui-product-name { margin: 0; font-size: 15px; font-weight: 600; line-height: 1.3; }.ui-product-name a { color: inherit; text-decoration: none; }.ui-product-name a::after { content: ""; position: absolute; inset: 0; }.ui-product-name a:hover { color: var(--ui-accent); }.ui-product-name a:focus-visible { outline: none; }.ui-product:has(.ui-product-name a:focus-visible) { box-shadow: var(--ui-focus); border-color: var(--ui-accent); }.ui-product { position: relative; }.ui-product-rating { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ui-muted); }.ui-product-stars { display: inline-flex; gap: 1px; }.ui-product-star { position: relative; width: 14px; height: 14px; color: var(--ui-strong); }.ui-product-star svg { display: block; width: 14px; height: 14px; }.ui-product-star-fill { position: absolute; inset: 0 auto 0 0; overflow: hidden; color: var(--ui-warning); }.ui-product-stock { font-size: 12px; font-weight: 500; color: var(--ui-warning); }.ui-product-out .ui-product-stock { color: var(--ui-danger); }.ui-product-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: auto; padding-top: 6px; }.ui-product-form { position: relative; z-index: 1; display: contents; }.ui-product-btn { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 12px; border: 1px solid transparent;  border-radius: var(--ui-radius-sm); background: var(--ui-accent); color: var(--ui-accent-fg); font: 500 13px/1 var(--ui-font); white-space: nowrap; cursor: pointer;  transition: background .15s, box-shadow .15s; }.ui-product-btn svg { width: 16px; height: 16px; }.ui-product-btn:hover { background: color-mix(in srgb, var(--ui-accent) 88%, var(--ui-fg)); }.ui-product-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-product-btn:disabled { background: var(--ui-subtle); color: var(--ui-muted); cursor: not-allowed; }.ui-product-sm .ui-product-body { padding: 10px 12px 12px; gap: 4px; }.ui-product-sm .ui-product-name { font-size: 14px; }.ui-product-sm .ui-product-btn { height: 30px; padding: 0 10px; font-size: 12px; }@media (prefers-reduced-motion: reduce) { .ui-product, .ui-product-media img, .ui-product-btn { transition: none; } .ui-product:hover .ui-product-media img { transform: none; } }`let product_card_star = <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">    <path d="M12 2.6l2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 17.4l-5.8 3.1 1.1-6.5L2.6 9.4l6.5-.9z"/></svg>// Five stars with `value` of them filled (any fraction), as a picture screen readers hear.fn product_card_stars(value: num, reviews: int? = null) =    <span.ui-product-rating>        <span.ui-product-stars role="img" aria-label={`${value} out of 5 stars${reviews != null ? `, ${reviews} reviews` : ""}`}>            {for i in range(5) => {                let part = value - i < 0 ? 0 : value - i > 1 ? 1 : value - i                <span.ui-product-star>{product_card_star}<span.ui-product-star-fill style={`width: ${round(part * 100)}%`}>{product_card_star}</span></span>            }}        </span>        {reviews != null && <span aria-hidden="true">({reviews})</span>}    </span>let product_card_cart = <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">    <path d="M3 4h2l2.4 11.2a1 1 0 0 0 1 .8h8.9a1 1 0 0 0 1-.8L20 8H6.5M10 20a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm7 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></svg>view ProductCard(name: str, price, old = null, id: str? = null, brand: str? = null, image: str? = null, alt: str? = null,                 href: str? = null, currency: str = "USD", locale: str? = null, cents: bool = true, rating: num? = null,                 reviews: int? = null, badge: str? = null, badge_tone: str = "accent", stock: str = "in",                 action: str? = null, add_label: str = "Add to cart", on_add = null, corner = null,                 ratio: str = "4/3", size: str = "md", class: str? = null) {    style global product_card_css    let out = stock == "out"    let button = <button.ui-product-btn type={action != null ? "submit" : "button"} disabled={out}                         aria-label={out ? null : `${add_label}: ${name}`}                         @click={() => { if action == null && on_add != null { on_add() } }}>        {!out && product_card_cart}{out ? "Sold out" : add_label}    </button>    <article class={ui_cls(["ui-product", out && "ui-product-out", size == "sm" && "ui-product-sm", class])} data-product={id}>        <div.ui-product-media style={`--r: ${ratio}`}>            {badge != null && <span class={ui_cls(["ui-product-badge", badge_tone != "accent" && `ui-product-badge-${badge_tone}`])}>{badge}</span>}            {image != null && <img src={image} alt={alt ?? name} loading="lazy"/>}            {corner != null && <span.ui-product-corner>{corner}</span>}        </div>        <div.ui-product-body>            {brand != null && <span.ui-product-brand>{brand}</span>}            <h3.ui-product-name>{href != null ? <a href={href}>{name}</a> : name}</h3>            {rating != null && product_card_stars(rating, reviews)}            {stock == "low" && <span.ui-product-stock>Only a few left</span>}            {out && <span.ui-product-stock>Out of stock</span>}            <div.ui-product-foot>                <Price amount={price} old={old} currency={currency} locale={locale} cents={cents}/>                {action != null                    ? <form.ui-product-form method="post" action={action}>                          {id != null && <input type="hidden" name="product" value={id}/>}                          {button}                      </form>                    : button}            </div>        </div>    </article>}
Thread
66Useful? Like it, or add to its thread below.

Thread 0 posts

On ProductCard: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.