Cart
The items in a cart with quantities to change, a discount code field, the sums, a checkout button and, if you like, a link to share the cart.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the block:
npx ermineui add cartpnpm dlx ermineui add cartyarn dlx ermineui add cartbunx ermineui add cartThat copies 4 files into src/components/blocks/cart/, and the 8 components it is built from. Files you already have are kept.
Use
import { Cart } from '@/components/blocks/cart'<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Cart, readCartLink, type CartItem } from '@/components/blocks/cart'
const items = ref<CartItem[]>([])
// Opened from a shared cart link: each line is looked up again in your own catalogue.
onMounted(() => {
const shared = readCartLink(window.location.href, (id) => products.find((product) => product.id === id))
if (shared) items.value = shared.items
})
</script>
<template>
<Cart v-model:items="items" shareable :shipping="shipping" :discount="discount" :code-error="codeError" @apply-code="checkCode" @checkout="goToCheckout" />
</template>In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
Cart.vue | src/components/blocks/cart/Cart.vue |
index.ts | src/components/blocks/cart/index.ts |
sample-data.ts | src/components/blocks/cart/sample-data.ts |
share.ts | src/components/blocks/cart/share.ts |
It is built from: Button, Card, Cart line item, Empty state, Field, Input, Order summary, Share button. These are added with it.
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
Cart.vue 241 lines
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { Button } from '../../ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'
import { CartLineItem } from '../../ui/cart-line-item'
import { EmptyState } from '../../ui/empty-state'
import { Field } from '../../ui/field'
import { Input } from '../../ui/input'
import { OrderSummary } from '../../ui/order-summary'
import { ShareButton } from '../../ui/share-button'
import { sampleCart, type CartItem } from './sample-data'
import { cartLink } from './share'
const props = withDefaults(
defineProps<{
title?: string
currency?: string
locale?: string
/** Money taken off, as a positive number. */
discount?: number
/** What the discount is, e.g. the code that was used. */
discountLabel?: string
/** Leave out until it is known; the summary then says it comes later. */
shipping?: number
tax?: number
/** Show a field for a discount code. */
codes?: boolean
/** Why the last code was turned down, e.g. "That code has expired." */
codeError?: string
/** Set while the checkout is being opened: the button shows a spinner and cannot be pressed twice. */
loading?: boolean
/** Where "Continue shopping" goes. */
shopHref?: string
/** Heading level of the title. */
titleAs?: string
/** Show a button that shares a link to this cart. Read the link back with readCartLink() from share.ts. */
shareable?: boolean
/** The page the shared link opens, which should read the cart from it. Defaults to the page the cart is on. */
shareUrl?: string
/** The query parameter the link keeps the cart in. */
shareParam?: string
}>(),
{ title: 'Your cart', currency: 'USD', locale: 'en-US', codes: true, shopHref: '/', titleAs: 'h2', shareParam: 'cart' },
)
const emit = defineEmits<{
/** "Checkout" was pressed. */
checkout: [items: CartItem[]]
/** The cart's link was shared, through the share sheet or by copying it. */
share: [url: string]
/** A discount code was entered. Check it on your server, then set `discount` or `codeError`. */
applyCode: [code: string]
}>()
/** What is in the cart. The made-up products from sample-data.ts are shown until you pass your own. */
const items = defineModel<CartItem[]>('items', { default: () => sampleCart })
const subtotal = computed(() => items.value.reduce((sum, item) => sum + item.price * item.quantity, 0))
const pieces = computed(() => items.value.reduce((sum, item) => sum + item.quantity, 0))
const heading = ref<HTMLElement | null>(null)
/** Said aloud after an item is removed: the row disappears, and nothing else would tell a screen reader user so. */
const message = ref('')
const code = ref('')
// The page's address is only known in the browser, so the link is made once the cart is on the page.
const page = ref<string>()
onMounted(() => (page.value = window.location.href))
const link = computed(() => (page.value ? cartLink(items.value, new URL(props.shareUrl ?? page.value, page.value), props.shareParam) : undefined))
function setQuantity(id: string, quantity: number) {
items.value = items.value.map((item) => (item.id === id ? { ...item, quantity } : item))
}
async function remove(item: CartItem) {
items.value = items.value.filter((other) => other.id !== item.id)
message.value = `${item.title} removed from your cart.`
// The button that was pressed is gone, so focus would be lost; it goes to the heading instead.
await nextTick()
heading.value?.focus()
}
</script>
<template>
<section class="er-cart">
<component :is="titleAs" ref="heading" class="er-cart-title" tabindex="-1">
{{ title }}
<span v-if="items.length" class="er-cart-count">{{ pieces }} {{ pieces === 1 ? 'item' : 'items' }}</span>
</component>
<p class="er-cart-status" role="status">{{ message }}</p>
<EmptyState v-if="!items.length" title="Your cart is empty" description="Things you add will be listed here.">
<Button as="a" :href="shopHref">Continue shopping</Button>
</EmptyState>
<div v-else class="er-cart-layout">
<ul class="er-cart-items">
<li v-for="item in items" :key="item.id">
<CartLineItem
:title="item.title"
:href="item.href"
:image="item.image"
:options="item.options"
:price="item.price"
:compare-at="item.compareAt"
:max="item.max"
:currency="currency"
:locale="locale"
:quantity="item.quantity"
@update:quantity="setQuantity(item.id, $event)"
@remove="remove(item)"
/>
</li>
</ul>
<Card class="er-cart-summary">
<CardHeader><CardTitle>Order summary</CardTitle></CardHeader>
<CardContent>
<OrderSummary :subtotal="subtotal" :discount="discount" :discount-label="discountLabel" :shipping="shipping" :tax="tax" :currency="currency" :locale="locale">
<form v-if="codes" class="er-cart-code" novalidate @submit.prevent="code.trim() && emit('applyCode', code.trim())">
<Field v-slot="{ control }" label="Discount code" :error="codeError">
<div class="er-cart-code-row">
<Input v-bind="control" v-model="code" name="code" autocomplete="off" />
<Button type="submit" variant="outline">Apply</Button>
</div>
</Field>
</form>
<template #footer>
<Button size="lg" :loading="loading" @click="emit('checkout', items)">Checkout</Button>
<a class="er-cart-link" :href="shopHref">Continue shopping</a>
<ShareButton v-if="shareable" class="er-cart-share" variant="ghost" size="sm" :url="link" :title="title" @share="link && emit('share', link)" @copy="emit('share', $event)">
Share cart
</ShareButton>
</template>
</OrderSummary>
</CardContent>
</Card>
</div>
</section>
</template>
<style>
@layer components {
.er-cart {
display: grid;
gap: 1.25rem;
width: 100%;
min-width: 0;
}
.er-cart-title {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.25rem 0.75rem;
margin: 0;
font-size: 1.25rem;
font-weight: var(--er-weight-semibold);
line-height: 1.75rem;
outline: none;
}
.er-cart-count {
color: var(--er-muted-foreground);
font-size: 0.875rem;
font-weight: 400;
}
.er-cart-status {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.er-cart-layout {
display: grid;
align-items: start;
gap: 1.5rem;
}
/* Side by side once there is room for both; the block measures itself, not the screen. */
.er-cart {
container-type: inline-size;
}
@container (min-width: 44rem) {
.er-cart-layout {
grid-template-columns: minmax(0, 1fr) 20rem;
gap: 2rem;
}
}
.er-cart-items {
display: grid;
margin: 0;
padding: 0;
list-style: none;
}
.er-cart-items li {
padding-block: 1rem;
border-bottom: 1px solid var(--er-border);
}
.er-cart-items li:first-child {
padding-top: 0;
}
.er-cart-code-row {
display: flex;
gap: 0.5rem;
}
.er-cart-link {
color: var(--er-muted-foreground);
font-size: 0.875rem;
text-align: center;
text-decoration: underline;
text-underline-offset: 0.2em;
}
.er-cart-share {
justify-self: center;
}
.er-cart-link:hover {
color: var(--er-foreground);
}
.er-cart-link:focus-visible {
border-radius: var(--er-radius-sm);
outline: 2px solid var(--er-ring);
outline-offset: 2px;
}
}
</style>index.ts 5 lines
/** Cart — the items in a cart with quantities to change, a discount code field, the sums, a checkout button and, if you like, a link to share the cart. */
export { default as Cart } from './Cart.vue'
export * from './sample-data'
export * from './share'sample-data.ts 38 lines
/**
* Made-up products so the cart has something to show. Replace this file with your own data,
* or pass your own through the props of Cart.vue.
*/
export interface CartItem {
/** Different for every line: a product id, or product and variant together. */
id: string
title: string
/** Where the product's own page is. */
href?: string
image?: string
/** The variant that was chosen, e.g. "Sand / Large". */
options?: string
/** Price of one, in whole units of the currency. */
price: number
/** The price of one before a reduction. */
compareAt?: number
quantity: number
/** The most that can be bought, e.g. what is left in stock. */
max?: number
}
// Stand-in pictures drawn as SVG, so the block needs no image files and no network.
const picture = (background: string, shape: string) =>
`data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120"><rect width="120" height="120" fill="${background}"/><rect x="34" y="28" width="52" height="64" rx="10" fill="${shape}"/></svg>`,
)}`
export const sampleCart: CartItem[] = [
{ id: 'mug-sand', title: 'Stoneware mug', options: 'Sand', price: 24, quantity: 2, image: picture('#efe7da', '#c9b79c') },
{ id: 'throw-moss', title: 'Wool throw', options: 'Moss / 130 × 180 cm', price: 96, compareAt: 120, quantity: 1, max: 3, image: picture('#dde5d6', '#6f8a5b') },
{ id: 'lamp-ink', title: 'Desk lamp', options: 'Ink', price: 148.5, quantity: 1, image: picture('#dfe3ea', '#2f3a4f') },
]
/** The same made-up products as a catalogue, for looking up the lines of a shared cart link. */
export const sampleProducts = sampleCart.map(({ quantity: _quantity, ...product }) => product)share.ts 95 lines
/**
* A link to a cart: the address of a page with the cart's lines in one query parameter, e.g.
* https://shop.example/cart?cart=mug-sand:2,lamp-ink:1
*
* The link carries product ids and quantities only. Titles, pictures and prices are looked up again by whoever opens
* it, from your own catalogue, so a link that has been edited cannot change a price, and an old link shows today's.
*/
import type { CartItem } from './sample-data'
/** What a cart line is, apart from how many: what your catalogue knows about a product. */
export type CartProduct = Omit<CartItem, 'quantity'>
export interface SharedCart {
/** The lines that could be put back, in the order the link lists them. */
items: CartItem[]
/** Ids in the link that the catalogue does not have, or lines that were not written properly. */
skipped: string[]
/** Titles of the lines that asked for more than `max` and were brought down to it. */
limited: string[]
}
/** More lines than any real cart holds: a link with more is cut short rather than read in full. */
const MOST_LINES = 100
/** The most of one thing a link can ask for. */
const MOST_OF_ONE = 999
/**
* The link to share: `base` (by default the page's own address) with the cart's lines in the `param` query
* parameter, replacing any lines already there. Other parameters and the #fragment are kept.
*/
export function cartLink(items: readonly Pick<CartItem, 'id' | 'quantity'>[], base: string | URL, param = 'cart'): string {
const url = new URL(base)
url.searchParams.delete(param)
const lines = items
.filter((item) => item.quantity > 0)
.map((item) => {
// A comma would read back as the start of the next line.
if (item.id.includes(',')) throw new Error(`[ermine] A cart link cannot hold the id "${item.id}": ids must not contain a comma.`)
return `${encodeURIComponent(item.id)}:${Math.round(item.quantity)}`
})
if (lines.length === 0) return url.href
// Written by hand rather than through searchParams, which would turn every comma and colon into %2C and %3A.
url.search = `${url.search ? `${url.search}&` : '?'}${encodeURIComponent(param)}=${lines.join(',')}`
return url.href
}
/**
* The cart a link holds, with each line looked up by `find`, or null when the link has no cart in it.
* Lines `find` does not know, and lines that are not an id and a whole number, are left out and listed in
* `skipped`; an id listed twice is one line with both quantities; a quantity over the product's `max` is lowered.
*/
export function readCartLink(link: string | URL, find: (id: string) => CartProduct | undefined, param = 'cart'): SharedCart | null {
let value: string | null
try {
value = new URL(link).searchParams.get(param)
} catch {
return null
}
if (value === null) return null
const quantities = new Map<string, number>()
const skipped: string[] = []
for (const line of value.split(',').slice(0, MOST_LINES)) {
if (!line) continue
// The quantity is after the last colon, so an id may have colons of its own.
const split = line.lastIndexOf(':')
const id = split > 0 ? line.slice(0, split) : line
const count = split > 0 ? line.slice(split + 1) : ''
if (!/^\d{1,4}$/.test(count) || Number(count) === 0) {
skipped.push(id)
continue
}
quantities.set(id, Math.min(MOST_OF_ONE, (quantities.get(id) ?? 0) + Number(count)))
}
const items: CartItem[] = []
const limited: string[] = []
for (const [id, wanted] of quantities) {
const product = find(id)
if (!product) {
skipped.push(id)
continue
}
const quantity = product.max === undefined ? wanted : Math.min(wanted, product.max)
// Sold out: there is nothing of it to put in the cart.
if (quantity < 1) {
skipped.push(id)
continue
}
if (quantity < wanted) limited.push(product.title)
items.push({ ...product, id, quantity })
}
return { items, skipped, limited }
}