<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Products · Shop · REALM examples</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
<style>
body { margin: 0; min-height: 100svh; }
main { width: min(70rem, 100%); margin: 0 auto; padding: 2rem 1rem 4rem; box-sizing: border-box; }
</style>
</head>
<body>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/toaster.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/unicons@alpha/dist/unicons.html"></import-element>
<import-element from="./components/shop-header.html"></import-element>
<import-element from="./components/product-list.html"></import-element>
<shop-header page="products"></shop-header>
<main><product-list></product-list></main>
<ui-toaster></ui-toaster>
</body>
</html>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/badge.html"></import-element>
<custom-element name="shop-header">
<script type="module">
import { cart } from "../shop.js";
const props = defineProps({ page: prop.enum(["products", "product", "cart"]).default("products") });
const nav = useState({
get products() {
return props.page !== "cart";
},
get cart() {
return props.page === "cart";
},
});
</script>
<style>
:host {
display: block;
border-bottom: 1px solid var(--border);
background: var(--background);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(var(--spacing) * 4);
width: min(calc(var(--spacing) * 280), 100%);
margin: 0 auto;
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
box-sizing: border-box;
}
a {
display: inline-flex;
align-items: center;
gap: calc(var(--spacing) * 2);
color: inherit;
text-decoration: none;
}
.brand {
font-weight: var(--font-weight-semibold);
}
.logo {
display: grid;
place-content: center;
width: calc(var(--spacing) * 8);
height: calc(var(--spacing) * 8);
border-radius: var(--radius-md);
background: var(--primary);
color: var(--primary-foreground);
}
nav {
display: flex;
gap: calc(var(--spacing) * 1);
}
nav a {
height: calc(var(--spacing) * 9);
padding: 0 calc(var(--spacing) * 3);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
}
nav a:hover {
background: var(--accent);
}
nav a[aria-current="true"] {
background: var(--secondary);
}
a:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
ui-badge:not([data-count]) {
display: none;
}
</style>
<header>
<a class="brand" href="./"><span class="logo" aria-hidden="true"><u-icon name="store"></u-icon></span>REALM Goods</a>
<nav aria-label="Shop">
<a href="./" _aria-current="#nav.products">Products</a>
<a href="cart.html" _aria-current="#nav.cart" _aria-label="#cart.countLabel">
<u-icon name="shopping-bag"></u-icon> Cart
<ui-badge shape="round" _data-count="#cart.hasItems"><slot name="#cart.count"></slot></ui-badge>
</a>
</nav>
</header>
</custom-element>
import { defineStore } from "https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js";
const currency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
export const money = (amount) => currency.format(amount);
export const CATEGORIES = [
{ id: "kitchen", label: "Kitchen" },
{ id: "home", label: "Home" },
{ id: "desk", label: "Desk" },
{ id: "outdoors", label: "Outdoors" },
];
const LABELS = Object.fromEntries(CATEGORIES.map((category) => [category.id, category.label]));
export const PRODUCTS = [
[
1,
"Stoneware mug",
"kitchen",
18,
4.8,
24,
"coffee",
"A heavy, glazed mug that keeps coffee warm for longer.",
],
[
2,
"Pour-over kettle",
"kitchen",
64,
4.6,
8,
"flame-kindling",
"A gooseneck kettle for a slow, even pour.",
],
[
3,
"Chef's knife",
"kitchen",
89,
4.9,
0,
"utensils",
"A 20 cm carbon steel blade, sharpened by hand.",
],
[
4,
"Linen throw",
"home",
72,
4.5,
15,
"bed-double",
"Washed linen in a soft oat colour, 130 by 170 cm.",
],
[5, "Table lamp", "home", 120, 4.7, 6, "lamp", "A dimmable lamp with a frosted glass shade."],
[
6,
"Plant pot",
"home",
26,
4.3,
40,
"sprout",
"Terracotta with a saucer, for plants up to 20 cm.",
],
[
7,
"Dot grid notebook",
"desk",
14,
4.4,
60,
"notebook",
"A5, 160 pages of 100 gsm paper that takes fountain pens.",
],
[
8,
"Desk organiser",
"desk",
38,
4.1,
12,
"layout-grid",
"Oak trays for pens, cards and the things that pile up.",
],
[
9,
"Mechanical pencil",
"desk",
22,
4.6,
33,
"pencil",
"A brass pencil with 0.5 mm leads and a knurled grip.",
],
[
10,
"Insulated bottle",
"outdoors",
32,
4.8,
50,
"cup-soda",
"Keeps drinks cold for 24 hours or hot for 12.",
],
[
11,
"Camping lantern",
"outdoors",
45,
4.2,
9,
"lamp-desk",
"A rechargeable lantern with three brightness levels.",
],
[
12,
"Trail backpack",
"outdoors",
110,
4.7,
4,
"backpack",
"A 24 litre pack with a rain cover and hip belt.",
],
].map(([id, name, category, price, rating, stock, icon, description]) => ({
id,
name,
category,
categoryLabel: LABELS[category],
price,
priceLabel: money(price),
rating,
ratingLabel: `${rating} out of 5`,
stock,
inStock: stock > 0,
stockLabel: stock === 0 ? "Sold out" : stock < 10 ? `Only ${stock} left` : "In stock",
icon,
description,
href: `product.html?id=${id}`,
addLabel: `Add ${name} to cart`,
}));
export const plural = (count, word) => `${count} ${count === 1 ? word : `${word}s`}`;
export const findProduct = (id) => PRODUCTS.find((product) => product.id === Number(id)) ?? null;
export const productFromUrl = (search) => findProduct(new URLSearchParams(search).get("id"));
const SORTS = {
featured: (a, b) => a.id - b.id,
"price-asc": (a, b) => a.price - b.price,
"price-desc": (a, b) => b.price - a.price,
rating: (a, b) => b.rating - a.rating,
name: (a, b) => a.name.localeCompare(b.name),
};
export const SORT_OPTIONS = [
{ id: "featured", label: "Featured" },
{ id: "price-asc", label: "Price: low to high" },
{ id: "price-desc", label: "Price: high to low" },
{ id: "rating", label: "Top rated" },
{ id: "name", label: "Name" },
];
export const catalogue = ({ query, category, sort, inStock }) => {
const text = query.trim().toLowerCase();
return PRODUCTS.filter(
(product) =>
(category === "all" || product.category === category) &&
(!inStock || product.inStock) &&
`${product.name} ${product.description}`.toLowerCase().includes(text),
).toSorted(SORTS[sort]);
};
const FREE_SHIPPING = 75;
const SHIPPING = 6;
const TAX = 0.08;
export const cart = defineStore(
"example-shop-cart",
{
lines: [],
order: null,
get items() {
return this.lines.map((line) => {
const product = findProduct(line.id);
return {
...product,
quantity: line.quantity,
lineTotal: money(product.price * line.quantity),
max: product.stock,
atMax: line.quantity >= product.stock,
atMin: line.quantity <= 1,
decreaseLabel: `Remove one ${product.name}`,
increaseLabel: `Add one ${product.name}`,
removeLabel: `Remove ${product.name} from cart`,
quantityLabel: `${line.quantity} × ${product.name}`,
};
});
},
get count() {
return this.lines.reduce((total, line) => total + line.quantity, 0);
},
get hasItems() {
return this.count > 0;
},
get countLabel() {
return `Cart, ${plural(this.count, "item")}`;
},
get subtotal() {
return this.lines.reduce(
(total, line) => total + findProduct(line.id).price * line.quantity,
0,
);
},
get shipping() {
return this.subtotal >= FREE_SHIPPING || this.subtotal === 0 ? 0 : SHIPPING;
},
get totals() {
const tax = Math.round(this.subtotal * TAX * 100) / 100;
return {
subtotal: money(this.subtotal),
shipping: this.shipping ? money(this.shipping) : "Free",
tax: money(tax),
total: money(this.subtotal + this.shipping + tax),
freeShippingHint:
this.shipping > 0
? `Add ${money(FREE_SHIPPING - this.subtotal)} more for free shipping.`
: "",
};
},
},
{ persist: "local" },
);
export const quantityIn = (id) => cart.lines.find((line) => line.id === id)?.quantity ?? 0;
// Adds up to the stock that is left and returns how many were added.
export const addToCart = (product, quantity = 1) => {
const line = cart.lines.find((item) => item.id === product.id);
const added = Math.max(0, Math.min(quantity, product.stock - quantityIn(product.id)));
if (!added) return 0;
if (line) line.quantity += added;
else cart.lines.push({ id: product.id, quantity: added });
cart.order = null;
return added;
};
export const addedToast = (product, added) =>
added
? {
title: "Added to cart",
description: `${plural(added, product.name)}. You have ${plural(cart.count, "item")} in your cart.`,
}
: {
title: "No more in stock",
description: `Your cart already holds every ${product.name} we have.`,
variant: "destructive",
};
export const titleFor = (product) =>
`${product ? product.name : "Product not found"} · Shop · REALM examples`;
export const changeQuantity = (id, delta) => {
const line = cart.lines.find((item) => item.id === id);
line.quantity = Math.min(Math.max(line.quantity + delta, 1), findProduct(id).stock);
};
export const removeFromCart = (id) => {
cart.lines = cart.lines.filter((line) => line.id !== id);
};
const CHECKS = {
name: (value) => (value.trim() ? "" : "Enter your name."),
email: (value) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? "" : "Enter a valid email address.",
address: (value) => (value.trim().length >= 5 ? "" : "Enter your street address."),
postcode: (value) =>
/^[A-Za-z0-9 -]{3,10}$/.test(value.trim()) ? "" : "Enter a valid postcode.",
card: (value) =>
/^\d{16}$/.test(value.replace(/\s/g, "")) ? "" : "Card numbers have 16 digits.",
expiry: (value) => (/^(0[1-9]|1[0-2])\/\d{2}$/.test(value.trim()) ? "" : "Use the MM/YY format."),
};
export const validate = (data) =>
Object.fromEntries(
Object.entries(CHECKS)
.map(([field, check]) => [field, check(String(data.get(field) ?? ""))])
.filter(([, message]) => message),
);
export const placeOrder = (data) => {
cart.order = {
number: `R-${String(Math.floor(Math.random() * 90_000) + 10_000)}`,
email: String(data.get("email")),
total: cart.totals.total,
count: cart.count,
};
cart.lines = [];
};
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/badge.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/empty.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/input-group.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/select.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/switch.html"></import-element>
<import-element from="./product-art.html"></import-element>
<custom-element name="product-list">
<script type="module">
import { addedToast, addToCart, CATEGORIES, catalogue, plural, SORT_OPTIONS } from "../shop.js";
const filters = useState({
query: "",
category: "all",
sort: "featured",
inStock: false,
categories: CATEGORIES,
sorts: SORT_OPTIONS,
get products() {
return catalogue(this);
},
get summary() {
return plural(this.products.length, "product");
},
});
function search(field) {
filters.query = field.value;
}
function pickCategory(select) {
filters.category = select.value;
}
function pickSort(select) {
filters.sort = select.value;
}
function toggleStock(toggle) {
filters.inStock = toggle.checked;
}
function add(product) {
dispatch("toast", addedToast(product, addToCart(product)));
}
</script>
<style>
:host {
display: grid;
gap: calc(var(--spacing) * 6);
}
h1 {
margin: 0;
font-size: var(--text-2xl);
letter-spacing: -0.02em;
}
.intro p {
margin: calc(var(--spacing) * 1) 0 0;
color: var(--muted-foreground);
font-size: var(--text-sm);
}
.filters {
display: grid;
grid-template-columns: minmax(0, 2fr) repeat(2, minmax(0, 1fr)) auto;
align-items: center;
gap: calc(var(--spacing) * 3);
}
.count {
margin: 0;
color: var(--muted-foreground);
font-size: var(--text-sm);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(calc(var(--spacing) * 56), 1fr));
gap: calc(var(--spacing) * 5);
margin: 0;
padding: 0;
list-style: none;
}
article {
display: grid;
grid-template-rows: auto 1fr auto;
gap: calc(var(--spacing) * 3);
height: 100%;
padding: calc(var(--spacing) * 3);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--card);
box-sizing: border-box;
}
h2 {
margin: 0;
font-size: var(--text-base);
}
h2 a {
color: inherit;
text-decoration: none;
}
h2 a:hover {
text-decoration: underline;
}
h2 a:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.details {
display: grid;
align-content: start;
gap: calc(var(--spacing) * 1);
font-size: var(--text-sm);
}
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: calc(var(--spacing) * 2);
color: var(--muted-foreground);
}
.rating {
display: inline-flex;
align-items: center;
gap: calc(var(--spacing) * 1);
}
.buy {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(var(--spacing) * 2);
}
.price {
font-size: var(--text-lg);
font-weight: var(--font-weight-semibold);
}
@media (max-width: 720px) {
.filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filters search {
grid-column: 1 / -1;
}
}
</style>
<div class="intro">
<h1>Products</h1>
<p>Small things for the kitchen, home, desk and outdoors.</p>
</div>
<div class="filters">
<search>
<ui-input-group type="search" aria-label="Search products" placeholder="Search products…" oninput="search(this)">
<ui-input-group-addon><u-icon name="search"></u-icon></ui-input-group-addon>
</ui-input-group>
</search>
<ui-select aria-label="Category" onchange="pickCategory(this)">
<option value="all">All categories</option>
<option repeat="#filters.categories" as="category" key="id" _value="#category.id" _label="#category.label"></option>
</ui-select>
<ui-select aria-label="Sort by" onchange="pickSort(this)">
<option repeat="#filters.sorts" as="sort" key="id" _value="#sort.id" _label="#sort.label"></option>
</ui-select>
<ui-switch onchange="toggleStock(this)">In stock only</ui-switch>
</div>
<p class="count" role="status"><slot name="#filters.summary"></slot></p>
<ul class="grid" aria-label="Products">
<li repeat="#filters.products" as="product" key="id">
<article>
<product-art _icon="#product.icon" _category="#product.category"></product-art>
<div class="details">
<h2><a _href="#product.href"><slot name="#product.name"></slot></a></h2>
<div class="meta">
<span><slot name="#product.categoryLabel"></slot></span>
<span class="rating"><u-icon name="star" label="Rating"></u-icon><slot name="#product.rating"></slot></span>
<ui-badge variant="outline"><slot name="#product.stockLabel"></slot></ui-badge>
</div>
</div>
<div class="buy">
<span class="price"><slot name="#product.priceLabel"></slot></span>
<ui-button size="sm" _aria-label="#product.addLabel" _disabled="!#product.inStock" onclick="add(product)"><u-icon name="plus"></u-icon> Add</ui-button>
</div>
</article>
</li>
</ul>
<is-hidden when="#filters.products.length">
<ui-empty>
<ui-empty-header>
<ui-empty-media variant="icon"><u-icon name="search"></u-icon></ui-empty-media>
<ui-empty-title level="2">No products found</ui-empty-title>
<ui-empty-description>Try another search or category.</ui-empty-description>
</ui-empty-header>
</ui-empty>
</is-hidden>
</custom-element>
<custom-element name="product-art">
<script>
defineProps({
icon: prop.string().default("package"),
category: prop.string().default(""),
});
</script>
<style>
:host {
--tone: var(--chart-1);
display: grid;
place-items: center;
aspect-ratio: 4 / 3;
border-radius: var(--radius-lg);
background: color-mix(in oklab, var(--tone) 14%, var(--background));
color: var(--tone);
}
:host(:state(attr-category-is-home)) {
--tone: var(--chart-2);
}
:host(:state(attr-category-is-desk)) {
--tone: var(--chart-3);
}
:host(:state(attr-category-is-outdoors)) {
--tone: var(--chart-4);
}
u-icon {
width: 36%;
height: 36%;
}
</style>
<u-icon _name="@icon" stroke-width="1.5"></u-icon>
</custom-element>