The shop front
In short: A web app project: a shop front with a catalogue from an API, a product page by address, a shared cart, a checkout with checks, and “My orders”.
Problem
The shop front
The shop has an API: products, categories, orders. What is missing is the shop front the buyer shops through. The buyer's path is:
- Search the catalogue or pick a category.
- Open a product and add it to the cart.
- Change the quantities in the cart.
- Order: name, email, address. A mistake shows where it is.
- See the order in "My orders".
You have built every part in a lesson of its own. Here you put them together in one app and build it in five steps: every task starts from the solution of the one before.
The idea
The skeleton
This is the app at step zero: the menu and placeholder pages. Click through the menu.
Console
The files are laid out like in a real project:
api.jsholds every request. The pages callgetProducts,placeOrderand do not know the addresses. An error from the server comes as anErrorwithstatusanderrors(the errors per field).format.jsformats money, dates and status.pages/are the pages, one per address.components/are the parts the pages use:ProductCard,Filter,Field.store/cartis the cart, shared by the whole app.
The router is in router.js with createMemoryHistory(), and main.js puts createPinia() and router on the app. The page goes in <RouterView /> in App.vue, as in the routing lesson.
The sandbox has no address bar, so the history is kept in memory. In a real project it is createWebHistory(). The rest is the same.
Filter and Field are ready from the lessons: fields bound to state, and a field with a label and an error message. You write the pages and the cart.
Exercise
Step 1: the catalogue
The catalogue loads the products with getProducts({ q, category }) and shows them with ProductCard in a list with the class grid. The filter is on top. Every change of the search or the category starts a new request, and the old one is cancelled. While it waits, the page says "Loading…". When nothing matches, it says "No such products."
Watch for two things from the lesson on data from an API:
- The list follows the filter. The request is in
watch([q, category], …, { immediate: true }), not inonMounted. - The old answer does not win. If "Dairy" answers slowly and you have already picked "Drinks", the slow answer must not wipe out the new one. An
AbortControllercancels the old request in theonCleanupof thewatch.
Write CatalogPage: the products from the API by the filter, "Loading…", "No such products." and cancelling the old request. The check searches for "bread", then picks "Dairy" and right away "Drinks" (Dairy answers in 900 ms), then searches inside a category.
Console
Solution
main.jsimport { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router.js';
import './style.css';
createApp(App).use(createPinia()).use(router).mount('#app');
router.jsimport { createMemoryHistory, createRouter } from 'vue-router';
import CartPage from './pages/CartPage.vue';
import CatalogPage from './pages/CatalogPage.vue';
import CheckoutPage from './pages/CheckoutPage.vue';
import NotFoundPage from './pages/NotFoundPage.vue';
import OrdersPage from './pages/OrdersPage.vue';
import ProductPage from './pages/ProductPage.vue';
export const router = createRouter({
// The sandbox has no address bar, so the history is kept in memory.
// In a real project: createWebHistory().
history: createMemoryHistory(),
routes: [
{ path: '/', component: CatalogPage },
{ path: '/product/:id', component: ProductPage },
{ path: '/cart', component: CartPage },
{ path: '/checkout', component: CheckoutPage },
{ path: '/orders', component: OrdersPage },
{ path: '/:pathMatch(.*)*', component: NotFoundPage },
],
});
App.vue<script setup>
import { RouterLink, RouterView } from 'vue-router';
import { useCart } from './store/cart.js';
const cart = useCart();
</script>
<template>
<header>
<strong>The shop</strong>
<nav aria-label="Main menu">
<RouterLink to="/">Catalogue</RouterLink>
<RouterLink to="/cart">Cart ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">My orders</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Catalogue</h1>
<Filter v-model:q="q" v-model:category="category" :categories="categories" />
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!products" role="status">Loading…</p>
<p v-else-if="products.length === 0">No such products.</p>
<ul v-else class="grid">
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</ul>
</template>
pages/ProductPage.vue<template>
<h1>Product</h1>
<p>The product comes here.</p>
</template>
pages/CartPage.vue<template>
<h1>Cart</h1>
<p>The cart comes here.</p>
</template>
pages/CheckoutPage.vue<template>
<h1>Checkout</h1>
<p>The checkout comes here.</p>
</template>
pages/OrdersPage.vue<template>
<h1>My orders</h1>
<p>Your orders come here.</p>
</template>
pages/NotFoundPage.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>No such page</h1>
<p>
<RouterLink to="/">Back to the catalogue</RouterLink>
</p>
</template>
components/ProductCard.vue<script setup>
import { money } from '../format.js';
defineProps(['product']);
</script>
<template>
<li class="card">
<h2>{{ product.name }}</h2>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
</li>
</template>
components/Filter.vue<script setup>
import { computed } from 'vue';
const props = defineProps(['categories']);
const q = defineModel('q');
const category = defineModel('category');
// Products live in the subcategories: "Food" and "Home" are only groups.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Search
<input v-model="q" type="search" />
</label>
<label>
Category
<select v-model="category">
<option value="">All</option>
<option v-for="c in groups" :key="c.id" :value="String(c.id)">{{ c.name }}</option>
</select>
</label>
</form>
</template>
components/Field.vue<script setup>
const value = defineModel();
defineProps({
name: String,
label: String,
type: { type: String, default: 'text' },
error: String,
});
</script>
<template>
<div class="field">
<label :for="name">{{ label }}</label>
<input
:id="name"
v-model="value"
:name="name"
:type="type"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error ? `${name}-error` : undefined"
/>
<span v-if="error" :id="`${name}-error`" class="error">{{ error }}</span>
</div>
</template>
store/cart.js// The cart comes in step 3. Until then it is empty and does not change.
export function useCart() {
return { items: [], count: 0, total: 0, add() {}, setQty() {}, remove() {}, clear() {} };
}
api.js// Every request to the shop's API is here: the pages do not know the addresses.
async function request(path, options = {}) {
const res = await fetch(path, options);
const data = await res.json();
if (!res.ok) {
const error = new Error(data.error ?? `The server answered ${res.status}`);
error.status = res.status;
error.errors = data.errors;
throw error;
}
return data;
}
export function getCategories() {
return request('/api/categories');
}
export function getProducts({ q = '', category = '' } = {}, signal) {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (category) params.set('category', category);
return request(`/api/products?${params}`, { signal });
}
export function getProduct(id, signal) {
return request(`/api/products/${id}`, { signal });
}
export function placeOrder(order) {
return request('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order),
});
}
// The training API has no login and returns the orders of every customer. You are customer 1.
// A real server returns only yours: it knows who you are from the session.
export const ME = 1;
export function getOrders(signal) {
return request('/api/orders', { signal });
}
format.jsexport const money = (x) => '€' + x.toFixed(2);
export const date = (iso) => new Date(iso).toLocaleDateString('en-GB');
const STATUS = { new: 'New', paid: 'Paid', shipped: 'Shipped', delivered: 'Delivered' };
export const statusText = (status) => STATUS[status] ?? status;
style.css* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; color: #1f2328; background: #fbfaf7; }
header { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; background: #f3efe6; }
header strong { margin-right: auto; }
nav { display: flex; flex-wrap: wrap; gap: 12px; }
nav a { color: inherit; }
nav a[aria-current="page"] { font-weight: 600; text-decoration-thickness: 2px; }
main { padding: 0 16px 24px; }
h1 { font-size: 1.4rem; }
input, select, button { font: inherit; padding: 4px 8px; }
.filter { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 12px; }
.filter label { display: flex; flex-direction: column; gap: 2px; font-size: 0.9rem; }
.grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.card { background: #fff; border: 1px solid #ddd6c8; border-radius: 8px; padding: 8px 10px; }
.card h2 { font-size: 1rem; margin: 0 0 4px; }
.card p { margin: 0; }
.muted { color: #5c6168; font-size: 0.85rem; }
.price { font-weight: 600; }
.cart, .orders { list-style: none; padding: 0; }
.cart li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.cart li span:first-child { flex: 1; }
.orders li { padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.orders h2 { font-size: 1rem; margin: 0; }
.orders p { margin: 2px 0; }
.field { display: flex; flex-direction: column; gap: 2px; max-width: 320px; margin-bottom: 10px; }
.field input[aria-invalid="true"] { border-color: #b42318; }
.error { color: #b42318; font-size: 0.85rem; }
The check catches three mistakes: a request only once, when the page shows; a search that is not in the watch; and a request without cancelling, where the answer for "Dairy" comes last and shows cheese while the field says "Drinks".
Exercise
Step 2: the product page
The name in ProductCard becomes a link to /product/:id. ProductPage takes id from the address, loads the product with getProduct(id) and shows the name, category, price and stock: "In stock: 18 pcs" or "Sold out". Below it is "More from this category": up to three other products from the same category, with links.
Watch for:
- The link belongs to the router:
<RouterLink :to="…">, not<a href>. A plain link loads the whole page again. In the sandbox it goes nowhere. - Same page, another parameter. From "White cheese" to "Kashkaval cheese" the router does not make a new
ProductPage, it only changesid. The request is inwatch(() => route.params.id, …, { immediate: true }). - The
idfrom the address is a string:"7", not7.getProduct(id)puts it in the address, and that is fine. If you look it up in an array with===, you compare a string with a number.
The details are in the routing lesson.
Make the name in ProductCard a link and write ProductPage. The check opens "White cheese", then "Kashkaval cheese" from "More from this category", goes back to the catalogue and opens the sold-out "Cheese banitsa".
Console
Solution
main.jsimport { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router.js';
import './style.css';
createApp(App).use(createPinia()).use(router).mount('#app');
router.jsimport { createMemoryHistory, createRouter } from 'vue-router';
import CartPage from './pages/CartPage.vue';
import CatalogPage from './pages/CatalogPage.vue';
import CheckoutPage from './pages/CheckoutPage.vue';
import NotFoundPage from './pages/NotFoundPage.vue';
import OrdersPage from './pages/OrdersPage.vue';
import ProductPage from './pages/ProductPage.vue';
export const router = createRouter({
// The sandbox has no address bar, so the history is kept in memory.
// In a real project: createWebHistory().
history: createMemoryHistory(),
routes: [
{ path: '/', component: CatalogPage },
{ path: '/product/:id', component: ProductPage },
{ path: '/cart', component: CartPage },
{ path: '/checkout', component: CheckoutPage },
{ path: '/orders', component: OrdersPage },
{ path: '/:pathMatch(.*)*', component: NotFoundPage },
],
});
App.vue<script setup>
import { RouterLink, RouterView } from 'vue-router';
import { useCart } from './store/cart.js';
const cart = useCart();
</script>
<template>
<header>
<strong>The shop</strong>
<nav aria-label="Main menu">
<RouterLink to="/">Catalogue</RouterLink>
<RouterLink to="/cart">Cart ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">My orders</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Catalogue</h1>
<Filter v-model:q="q" v-model:category="category" :categories="categories" />
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!products" role="status">Loading…</p>
<p v-else-if="products.length === 0">No such products.</p>
<ul v-else class="grid">
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</ul>
</template>
pages/ProductPage.vue<script setup>
import { watch, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
const route = useRoute();
const product = ref(null);
const related = ref([]);
const error = ref(null);
async function load(id, signal) {
const p = await getProduct(id, signal);
product.value = p;
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, signal);
related.value = same.filter((x) => x.id !== p.id).slice(0, 3);
}
watch(
() => route.params.id,
(id, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
product.value = null;
related.value = [];
error.value = null;
load(id, controller.signal).catch((e) => {
if (e.name !== 'AbortError') error.value = e.status === 404 ? 'No such product.' : e.message;
});
},
{ immediate: true },
);
</script>
<template>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!product" role="status">Loading…</p>
<article v-else>
<p>
<RouterLink to="/">← Back to the catalogue</RouterLink>
</p>
<h1>{{ product.name }}</h1>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
<p v-if="product.stock > 0">In stock: {{ product.stock }} pcs</p>
<p v-else>Sold out</p>
<section v-if="related.length > 0" aria-labelledby="related-title">
<h2 id="related-title">More from this category</h2>
<ul>
<li v-for="p in related" :key="p.id">
<RouterLink :to="`/product/${p.id}`">{{ p.name }}</RouterLink>
</li>
</ul>
</section>
</article>
</template>
pages/CartPage.vue<template>
<h1>Cart</h1>
<p>The cart comes here.</p>
</template>
pages/CheckoutPage.vue<template>
<h1>Checkout</h1>
<p>The checkout comes here.</p>
</template>
pages/OrdersPage.vue<template>
<h1>My orders</h1>
<p>Your orders come here.</p>
</template>
pages/NotFoundPage.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>No such page</h1>
<p>
<RouterLink to="/">Back to the catalogue</RouterLink>
</p>
</template>
components/ProductCard.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
defineProps(['product']);
</script>
<template>
<li class="card">
<h2>
<RouterLink :to="`/product/${product.id}`">{{ product.name }}</RouterLink>
</h2>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
</li>
</template>
components/Filter.vue<script setup>
import { computed } from 'vue';
const props = defineProps(['categories']);
const q = defineModel('q');
const category = defineModel('category');
// Products live in the subcategories: "Food" and "Home" are only groups.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Search
<input v-model="q" type="search" />
</label>
<label>
Category
<select v-model="category">
<option value="">All</option>
<option v-for="c in groups" :key="c.id" :value="String(c.id)">{{ c.name }}</option>
</select>
</label>
</form>
</template>
components/Field.vue<script setup>
const value = defineModel();
defineProps({
name: String,
label: String,
type: { type: String, default: 'text' },
error: String,
});
</script>
<template>
<div class="field">
<label :for="name">{{ label }}</label>
<input
:id="name"
v-model="value"
:name="name"
:type="type"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error ? `${name}-error` : undefined"
/>
<span v-if="error" :id="`${name}-error`" class="error">{{ error }}</span>
</div>
</template>
store/cart.js// The cart comes in step 3. Until then it is empty and does not change.
export function useCart() {
return { items: [], count: 0, total: 0, add() {}, setQty() {}, remove() {}, clear() {} };
}
api.js// Every request to the shop's API is here: the pages do not know the addresses.
async function request(path, options = {}) {
const res = await fetch(path, options);
const data = await res.json();
if (!res.ok) {
const error = new Error(data.error ?? `The server answered ${res.status}`);
error.status = res.status;
error.errors = data.errors;
throw error;
}
return data;
}
export function getCategories() {
return request('/api/categories');
}
export function getProducts({ q = '', category = '' } = {}, signal) {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (category) params.set('category', category);
return request(`/api/products?${params}`, { signal });
}
export function getProduct(id, signal) {
return request(`/api/products/${id}`, { signal });
}
export function placeOrder(order) {
return request('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order),
});
}
// The training API has no login and returns the orders of every customer. You are customer 1.
// A real server returns only yours: it knows who you are from the session.
export const ME = 1;
export function getOrders(signal) {
return request('/api/orders', { signal });
}
format.jsexport const money = (x) => '€' + x.toFixed(2);
export const date = (iso) => new Date(iso).toLocaleDateString('en-GB');
const STATUS = { new: 'New', paid: 'Paid', shipped: 'Shipped', delivered: 'Delivered' };
export const statusText = (status) => STATUS[status] ?? status;
style.css* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; color: #1f2328; background: #fbfaf7; }
header { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; background: #f3efe6; }
header strong { margin-right: auto; }
nav { display: flex; flex-wrap: wrap; gap: 12px; }
nav a { color: inherit; }
nav a[aria-current="page"] { font-weight: 600; text-decoration-thickness: 2px; }
main { padding: 0 16px 24px; }
h1 { font-size: 1.4rem; }
input, select, button { font: inherit; padding: 4px 8px; }
.filter { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 12px; }
.filter label { display: flex; flex-direction: column; gap: 2px; font-size: 0.9rem; }
.grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.card { background: #fff; border: 1px solid #ddd6c8; border-radius: 8px; padding: 8px 10px; }
.card h2 { font-size: 1rem; margin: 0 0 4px; }
.card p { margin: 0; }
.muted { color: #5c6168; font-size: 0.85rem; }
.price { font-weight: 600; }
.cart, .orders { list-style: none; padding: 0; }
.cart li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.cart li span:first-child { flex: 1; }
.orders li { padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.orders h2 { font-size: 1rem; margin: 0; }
.orders p { margin: 2px 0; }
.field { display: flex; flex-direction: column; gap: 2px; max-width: 320px; margin-bottom: 10px; }
.field input[aria-invalid="true"] { border-color: #b42318; }
.error { color: #b42318; font-size: 0.85rem; }
The check catches <a href> (the console says "The link goes to /product/7"), a page that loads the product only the first time it shows (it stays on the white cheese), and find((x) => x.id === id), which finds nothing because "7" !== 7.
Exercise
Step 3: the cart
ProductPage and the menu already call useCart(): an "Add to cart" button, "In the cart: 2" and "Cart (2)". For now useCart is a placeholder. Write the real cart and CartPage.
store/cart.js is a Pinia store made with defineStore('cart', () => { … }), as in the shared state lesson. It returns items, count, total, add, setQty, remove, clear. count and total are computed from items, not kept separately.
CartPage shows a row per product: the name, a drop-down "Quantity: White cheese" (from 1 up to the stock, at most 10), the row's sum and a "Remove" button named "Remove White cheese". Below them is "Total: …" and a "To checkout" link. An empty cart says "The cart is empty."
Write store/cart and CartPage. The check adds two white cheeses and apples, opens the cart, changes the cheese to 3 and removes the apples, watching the menu and the total.
Console
Solution
main.jsimport { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router.js';
import './style.css';
createApp(App).use(createPinia()).use(router).mount('#app');
router.jsimport { createMemoryHistory, createRouter } from 'vue-router';
import CartPage from './pages/CartPage.vue';
import CatalogPage from './pages/CatalogPage.vue';
import CheckoutPage from './pages/CheckoutPage.vue';
import NotFoundPage from './pages/NotFoundPage.vue';
import OrdersPage from './pages/OrdersPage.vue';
import ProductPage from './pages/ProductPage.vue';
export const router = createRouter({
// The sandbox has no address bar, so the history is kept in memory.
// In a real project: createWebHistory().
history: createMemoryHistory(),
routes: [
{ path: '/', component: CatalogPage },
{ path: '/product/:id', component: ProductPage },
{ path: '/cart', component: CartPage },
{ path: '/checkout', component: CheckoutPage },
{ path: '/orders', component: OrdersPage },
{ path: '/:pathMatch(.*)*', component: NotFoundPage },
],
});
App.vue<script setup>
import { RouterLink, RouterView } from 'vue-router';
import { useCart } from './store/cart.js';
const cart = useCart();
</script>
<template>
<header>
<strong>The shop</strong>
<nav aria-label="Main menu">
<RouterLink to="/">Catalogue</RouterLink>
<RouterLink to="/cart">Cart ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">My orders</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Catalogue</h1>
<Filter v-model:q="q" v-model:category="category" :categories="categories" />
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!products" role="status">Loading…</p>
<p v-else-if="products.length === 0">No such products.</p>
<ul v-else class="grid">
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</ul>
</template>
pages/ProductPage.vue<script setup>
import { computed, watch, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const route = useRoute();
const cart = useCart();
const product = ref(null);
const related = ref([]);
const error = ref(null);
const inCart = computed(() => cart.items.find((i) => i.id === product.value?.id)?.qty ?? 0);
async function load(id, signal) {
const p = await getProduct(id, signal);
product.value = p;
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, signal);
related.value = same.filter((x) => x.id !== p.id).slice(0, 3);
}
watch(
() => route.params.id,
(id, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
product.value = null;
related.value = [];
error.value = null;
load(id, controller.signal).catch((e) => {
if (e.name !== 'AbortError') error.value = e.status === 404 ? 'No such product.' : e.message;
});
},
{ immediate: true },
);
</script>
<template>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!product" role="status">Loading…</p>
<article v-else>
<p>
<RouterLink to="/">← Back to the catalogue</RouterLink>
</p>
<h1>{{ product.name }}</h1>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
<p v-if="product.stock > 0">In stock: {{ product.stock }} pcs</p>
<p v-else>Sold out</p>
<button type="button" :disabled="product.stock === 0" @click="cart.add(product)">
Add to cart
</button>
<p v-if="inCart > 0" role="status">In the cart: {{ inCart }}</p>
<section v-if="related.length > 0" aria-labelledby="related-title">
<h2 id="related-title">More from this category</h2>
<ul>
<li v-for="p in related" :key="p.id">
<RouterLink :to="`/product/${p.id}`">{{ p.name }}</RouterLink>
</li>
</ul>
</section>
</article>
</template>
pages/CartPage.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const cart = useCart();
const upTo = (n) => Array.from({ length: Math.min(n, 10) }, (_, i) => i + 1);
</script>
<template>
<h1>Cart</h1>
<template v-if="cart.items.length === 0">
<p>The cart is empty.</p>
<p>
<RouterLink to="/">Back to the catalogue</RouterLink>
</p>
</template>
<template v-else>
<ul class="cart">
<li v-for="item in cart.items" :key="item.id">
<span>{{ item.name }}</span>
<select
:aria-label="`Quantity: ${item.name}`"
:value="item.qty"
@change="cart.setQty(item.id, Number($event.target.value))"
>
<option v-for="n in upTo(item.stock)" :key="n">{{ n }}</option>
</select>
<span>{{ money(item.price * item.qty) }}</span>
<button type="button" :aria-label="`Remove ${item.name}`" @click="cart.remove(item.id)">
Remove
</button>
</li>
</ul>
<p class="price">Total: {{ money(cart.total) }}</p>
<RouterLink to="/checkout">To checkout</RouterLink>
</template>
</template>
pages/CheckoutPage.vue<template>
<h1>Checkout</h1>
<p>The checkout comes here.</p>
</template>
pages/OrdersPage.vue<template>
<h1>My orders</h1>
<p>Your orders come here.</p>
</template>
pages/NotFoundPage.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>No such page</h1>
<p>
<RouterLink to="/">Back to the catalogue</RouterLink>
</p>
</template>
components/ProductCard.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
defineProps(['product']);
</script>
<template>
<li class="card">
<h2>
<RouterLink :to="`/product/${product.id}`">{{ product.name }}</RouterLink>
</h2>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
</li>
</template>
components/Filter.vue<script setup>
import { computed } from 'vue';
const props = defineProps(['categories']);
const q = defineModel('q');
const category = defineModel('category');
// Products live in the subcategories: "Food" and "Home" are only groups.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Search
<input v-model="q" type="search" />
</label>
<label>
Category
<select v-model="category">
<option value="">All</option>
<option v-for="c in groups" :key="c.id" :value="String(c.id)">{{ c.name }}</option>
</select>
</label>
</form>
</template>
components/Field.vue<script setup>
const value = defineModel();
defineProps({
name: String,
label: String,
type: { type: String, default: 'text' },
error: String,
});
</script>
<template>
<div class="field">
<label :for="name">{{ label }}</label>
<input
:id="name"
v-model="value"
:name="name"
:type="type"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error ? `${name}-error` : undefined"
/>
<span v-if="error" :id="`${name}-error`" class="error">{{ error }}</span>
</div>
</template>
store/cart.jsimport { computed, ref } from 'vue';
import { defineStore } from 'pinia';
export const useCart = defineStore('cart', () => {
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.qty, 0));
function add(product) {
const found = items.value.find((i) => i.id === product.id);
if (found) found.qty += 1;
else items.value.push({ id: product.id, name: product.name, price: product.price, stock: product.stock, qty: 1 });
}
function setQty(id, qty) {
const found = items.value.find((i) => i.id === id);
if (found) found.qty = qty;
}
function remove(id) {
items.value = items.value.filter((i) => i.id !== id);
}
function clear() {
items.value = [];
}
return { items, count, total, add, setQty, remove, clear };
});
api.js// Every request to the shop's API is here: the pages do not know the addresses.
async function request(path, options = {}) {
const res = await fetch(path, options);
const data = await res.json();
if (!res.ok) {
const error = new Error(data.error ?? `The server answered ${res.status}`);
error.status = res.status;
error.errors = data.errors;
throw error;
}
return data;
}
export function getCategories() {
return request('/api/categories');
}
export function getProducts({ q = '', category = '' } = {}, signal) {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (category) params.set('category', category);
return request(`/api/products?${params}`, { signal });
}
export function getProduct(id, signal) {
return request(`/api/products/${id}`, { signal });
}
export function placeOrder(order) {
return request('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order),
});
}
// The training API has no login and returns the orders of every customer. You are customer 1.
// A real server returns only yours: it knows who you are from the session.
export const ME = 1;
export function getOrders(signal) {
return request('/api/orders', { signal });
}
format.jsexport const money = (x) => '€' + x.toFixed(2);
export const date = (iso) => new Date(iso).toLocaleDateString('en-GB');
const STATUS = { new: 'New', paid: 'Paid', shipped: 'Shipped', delivered: 'Delivered' };
export const statusText = (status) => STATUS[status] ?? status;
style.css* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; color: #1f2328; background: #fbfaf7; }
header { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; background: #f3efe6; }
header strong { margin-right: auto; }
nav { display: flex; flex-wrap: wrap; gap: 12px; }
nav a { color: inherit; }
nav a[aria-current="page"] { font-weight: 600; text-decoration-thickness: 2px; }
main { padding: 0 16px 24px; }
h1 { font-size: 1.4rem; }
input, select, button { font: inherit; padding: 4px 8px; }
.filter { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 12px; }
.filter label { display: flex; flex-direction: column; gap: 2px; font-size: 0.9rem; }
.grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.card { background: #fff; border: 1px solid #ddd6c8; border-radius: 8px; padding: 8px 10px; }
.card h2 { font-size: 1rem; margin: 0 0 4px; }
.card p { margin: 0; }
.muted { color: #5c6168; font-size: 0.85rem; }
.price { font-weight: 600; }
.cart, .orders { list-style: none; padding: 0; }
.cart li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.cart li span:first-child { flex: 1; }
.orders li { padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.orders h2 { font-size: 1rem; margin: 0; }
.orders p { margin: 2px 0; }
.field { display: flex; flex-direction: column; gap: 2px; max-width: 320px; margin-bottom: 10px; }
.field input[aria-invalid="true"] { border-color: #b42318; }
.error { color: #b42318; font-size: 0.85rem; }
useCart without Pinia, as a function with ref([]) (every component has its own cart and the menu says 0), and a total in a separate ref that changing a quantity does not update.Exercise
Step 4: the checkout
CheckoutPage has a form with three Fields: "Name", "Email", "Address". On "Place order":
- A check in the browser. An empty name: "Enter your name." An email without
@: "Enter your email." An empty address: "Enter your address." If there is an error, no request goes out. - The request.
placeOrder({ name, email, address, items }), whereitemsis[{ productId, qty }]from the cart. - The answer. On 201 the page says, for example, "Order #61 for €15.80." and the cart is emptied. On 400
err.errorsis an object per field, for example{ email: "The email is not valid" }, and every message goes next to its field.
Field already shows error next to the field, with aria-invalid and aria-describedby. The form has novalidate, so that your messages speak, not the browser's bubbles. The check in the browser is a convenience: it catches the empty fields before a request goes out. The server has the last word, so "maria@shop" passes in the browser and the server sends it back. How to build a form is in the forms lesson.
Write CheckoutPage. The check adds two white cheeses, presses "Place order" with an empty form, then with the email "maria@shop" (the server rejects it) and last with a valid email.
Console
Solution
main.jsimport { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router.js';
import './style.css';
createApp(App).use(createPinia()).use(router).mount('#app');
router.jsimport { createMemoryHistory, createRouter } from 'vue-router';
import CartPage from './pages/CartPage.vue';
import CatalogPage from './pages/CatalogPage.vue';
import CheckoutPage from './pages/CheckoutPage.vue';
import NotFoundPage from './pages/NotFoundPage.vue';
import OrdersPage from './pages/OrdersPage.vue';
import ProductPage from './pages/ProductPage.vue';
export const router = createRouter({
// The sandbox has no address bar, so the history is kept in memory.
// In a real project: createWebHistory().
history: createMemoryHistory(),
routes: [
{ path: '/', component: CatalogPage },
{ path: '/product/:id', component: ProductPage },
{ path: '/cart', component: CartPage },
{ path: '/checkout', component: CheckoutPage },
{ path: '/orders', component: OrdersPage },
{ path: '/:pathMatch(.*)*', component: NotFoundPage },
],
});
App.vue<script setup>
import { RouterLink, RouterView } from 'vue-router';
import { useCart } from './store/cart.js';
const cart = useCart();
</script>
<template>
<header>
<strong>The shop</strong>
<nav aria-label="Main menu">
<RouterLink to="/">Catalogue</RouterLink>
<RouterLink to="/cart">Cart ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">My orders</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Catalogue</h1>
<Filter v-model:q="q" v-model:category="category" :categories="categories" />
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!products" role="status">Loading…</p>
<p v-else-if="products.length === 0">No such products.</p>
<ul v-else class="grid">
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</ul>
</template>
pages/ProductPage.vue<script setup>
import { computed, watch, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const route = useRoute();
const cart = useCart();
const product = ref(null);
const related = ref([]);
const error = ref(null);
const inCart = computed(() => cart.items.find((i) => i.id === product.value?.id)?.qty ?? 0);
async function load(id, signal) {
const p = await getProduct(id, signal);
product.value = p;
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, signal);
related.value = same.filter((x) => x.id !== p.id).slice(0, 3);
}
watch(
() => route.params.id,
(id, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
product.value = null;
related.value = [];
error.value = null;
load(id, controller.signal).catch((e) => {
if (e.name !== 'AbortError') error.value = e.status === 404 ? 'No such product.' : e.message;
});
},
{ immediate: true },
);
</script>
<template>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!product" role="status">Loading…</p>
<article v-else>
<p>
<RouterLink to="/">← Back to the catalogue</RouterLink>
</p>
<h1>{{ product.name }}</h1>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
<p v-if="product.stock > 0">In stock: {{ product.stock }} pcs</p>
<p v-else>Sold out</p>
<button type="button" :disabled="product.stock === 0" @click="cart.add(product)">
Add to cart
</button>
<p v-if="inCart > 0" role="status">In the cart: {{ inCart }}</p>
<section v-if="related.length > 0" aria-labelledby="related-title">
<h2 id="related-title">More from this category</h2>
<ul>
<li v-for="p in related" :key="p.id">
<RouterLink :to="`/product/${p.id}`">{{ p.name }}</RouterLink>
</li>
</ul>
</section>
</article>
</template>
pages/CartPage.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const cart = useCart();
const upTo = (n) => Array.from({ length: Math.min(n, 10) }, (_, i) => i + 1);
</script>
<template>
<h1>Cart</h1>
<template v-if="cart.items.length === 0">
<p>The cart is empty.</p>
<p>
<RouterLink to="/">Back to the catalogue</RouterLink>
</p>
</template>
<template v-else>
<ul class="cart">
<li v-for="item in cart.items" :key="item.id">
<span>{{ item.name }}</span>
<select
:aria-label="`Quantity: ${item.name}`"
:value="item.qty"
@change="cart.setQty(item.id, Number($event.target.value))"
>
<option v-for="n in upTo(item.stock)" :key="n">{{ n }}</option>
</select>
<span>{{ money(item.price * item.qty) }}</span>
<button type="button" :aria-label="`Remove ${item.name}`" @click="cart.remove(item.id)">
Remove
</button>
</li>
</ul>
<p class="price">Total: {{ money(cart.total) }}</p>
<RouterLink to="/checkout">To checkout</RouterLink>
</template>
</template>
pages/CheckoutPage.vue<script setup>
import { reactive, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { placeOrder } from '../api.js';
import Field from '../components/Field.vue';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const cart = useCart();
const form = reactive({ name: '', email: '', address: '' });
const errors = ref({});
const sending = ref(false);
const order = ref(null);
function check() {
const found = {};
if (!form.name.trim()) found.name = 'Enter your name.';
if (!form.email.includes('@')) found.email = 'Enter your email.';
if (!form.address.trim()) found.address = 'Enter your address.';
return found;
}
async function submit() {
errors.value = check();
if (Object.keys(errors.value).length > 0) return;
sending.value = true;
try {
const items = cart.items.map((i) => ({ productId: i.id, qty: i.qty }));
order.value = await placeOrder({ ...form, items });
cart.clear();
} catch (err) {
errors.value = err.errors ?? { form: err.message };
} finally {
sending.value = false;
}
}
</script>
<template>
<template v-if="order">
<h1>Order received</h1>
<p role="status">Order #{{ order.id }} for {{ money(order.total) }}.</p>
<p>
<RouterLink to="/orders">My orders</RouterLink>
</p>
</template>
<template v-else-if="cart.items.length === 0">
<h1>Checkout</h1>
<p>The cart is empty.</p>
</template>
<template v-else>
<h1>Checkout</h1>
<p class="price">Total: {{ money(cart.total) }}</p>
<form novalidate @submit.prevent="submit">
<Field v-model="form.name" name="name" label="Name" :error="errors.name" />
<Field v-model="form.email" name="email" label="Email" type="email" :error="errors.email" />
<Field v-model="form.address" name="address" label="Address" :error="errors.address" />
<p v-if="errors.items" role="alert">{{ errors.items }}</p>
<p v-if="errors.form" role="alert">{{ errors.form }}</p>
<button :disabled="sending">{{ sending ? 'Sending…' : 'Place order' }}</button>
</form>
</template>
</template>
pages/OrdersPage.vue<template>
<h1>My orders</h1>
<p>Your orders come here.</p>
</template>
pages/NotFoundPage.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>No such page</h1>
<p>
<RouterLink to="/">Back to the catalogue</RouterLink>
</p>
</template>
components/ProductCard.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
defineProps(['product']);
</script>
<template>
<li class="card">
<h2>
<RouterLink :to="`/product/${product.id}`">{{ product.name }}</RouterLink>
</h2>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
</li>
</template>
components/Filter.vue<script setup>
import { computed } from 'vue';
const props = defineProps(['categories']);
const q = defineModel('q');
const category = defineModel('category');
// Products live in the subcategories: "Food" and "Home" are only groups.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Search
<input v-model="q" type="search" />
</label>
<label>
Category
<select v-model="category">
<option value="">All</option>
<option v-for="c in groups" :key="c.id" :value="String(c.id)">{{ c.name }}</option>
</select>
</label>
</form>
</template>
components/Field.vue<script setup>
const value = defineModel();
defineProps({
name: String,
label: String,
type: { type: String, default: 'text' },
error: String,
});
</script>
<template>
<div class="field">
<label :for="name">{{ label }}</label>
<input
:id="name"
v-model="value"
:name="name"
:type="type"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error ? `${name}-error` : undefined"
/>
<span v-if="error" :id="`${name}-error`" class="error">{{ error }}</span>
</div>
</template>
store/cart.jsimport { computed, ref } from 'vue';
import { defineStore } from 'pinia';
export const useCart = defineStore('cart', () => {
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.qty, 0));
function add(product) {
const found = items.value.find((i) => i.id === product.id);
if (found) found.qty += 1;
else items.value.push({ id: product.id, name: product.name, price: product.price, stock: product.stock, qty: 1 });
}
function setQty(id, qty) {
const found = items.value.find((i) => i.id === id);
if (found) found.qty = qty;
}
function remove(id) {
items.value = items.value.filter((i) => i.id !== id);
}
function clear() {
items.value = [];
}
return { items, count, total, add, setQty, remove, clear };
});
api.js// Every request to the shop's API is here: the pages do not know the addresses.
async function request(path, options = {}) {
const res = await fetch(path, options);
const data = await res.json();
if (!res.ok) {
const error = new Error(data.error ?? `The server answered ${res.status}`);
error.status = res.status;
error.errors = data.errors;
throw error;
}
return data;
}
export function getCategories() {
return request('/api/categories');
}
export function getProducts({ q = '', category = '' } = {}, signal) {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (category) params.set('category', category);
return request(`/api/products?${params}`, { signal });
}
export function getProduct(id, signal) {
return request(`/api/products/${id}`, { signal });
}
export function placeOrder(order) {
return request('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order),
});
}
// The training API has no login and returns the orders of every customer. You are customer 1.
// A real server returns only yours: it knows who you are from the session.
export const ME = 1;
export function getOrders(signal) {
return request('/api/orders', { signal });
}
format.jsexport const money = (x) => '€' + x.toFixed(2);
export const date = (iso) => new Date(iso).toLocaleDateString('en-GB');
const STATUS = { new: 'New', paid: 'Paid', shipped: 'Shipped', delivered: 'Delivered' };
export const statusText = (status) => STATUS[status] ?? status;
style.css* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; color: #1f2328; background: #fbfaf7; }
header { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; background: #f3efe6; }
header strong { margin-right: auto; }
nav { display: flex; flex-wrap: wrap; gap: 12px; }
nav a { color: inherit; }
nav a[aria-current="page"] { font-weight: 600; text-decoration-thickness: 2px; }
main { padding: 0 16px 24px; }
h1 { font-size: 1.4rem; }
input, select, button { font: inherit; padding: 4px 8px; }
.filter { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 12px; }
.filter label { display: flex; flex-direction: column; gap: 2px; font-size: 0.9rem; }
.grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.card { background: #fff; border: 1px solid #ddd6c8; border-radius: 8px; padding: 8px 10px; }
.card h2 { font-size: 1rem; margin: 0 0 4px; }
.card p { margin: 0; }
.muted { color: #5c6168; font-size: 0.85rem; }
.price { font-weight: 600; }
.cart, .orders { list-style: none; padding: 0; }
.cart li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.cart li span:first-child { flex: 1; }
.orders li { padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.orders h2 { font-size: 1rem; margin: 0; }
.orders p { margin: 2px 0; }
.field { display: flex; flex-direction: column; gap: 2px; max-width: 320px; margin-bottom: 10px; }
.field input[aria-invalid="true"] { border-color: #b42318; }
.error { color: #b42318; font-size: 0.85rem; }
The check catches a server error shown above the form instead of next to the field; items: cart.items, where the server finds no productId; and a cart emptied before the answer: on 400 the form disappears, and the buyer's cart with it.
Exercise
Step 5: my orders and the whole path
OrdersPage loads getOrders() every time it opens and shows the orders of customer ME, newest on top: "Order #61", the date, the status, the total and the lines ("White cheese × 3"). While it waits, "Loading…", and with no orders, "You have no orders yet."
The training API has no login and returns the orders of every customer. So the page filters by customerId === ME. A real server returns only yours.
Now the app is whole. The check walks the path from the start of the lesson: it looks at the old orders, filters "Dairy", opens the white cheese, adds 2, makes them 3 in the cart, orders with a wrong email, sees the error next to the field, fixes it, orders and finds the new order on top of "My orders".
Write OrdersPage. The check walks the buyer's whole path and at the end looks for the new order on top of "My orders".
Console
Solution
main.jsimport { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router.js';
import './style.css';
createApp(App).use(createPinia()).use(router).mount('#app');
router.jsimport { createMemoryHistory, createRouter } from 'vue-router';
import CartPage from './pages/CartPage.vue';
import CatalogPage from './pages/CatalogPage.vue';
import CheckoutPage from './pages/CheckoutPage.vue';
import NotFoundPage from './pages/NotFoundPage.vue';
import OrdersPage from './pages/OrdersPage.vue';
import ProductPage from './pages/ProductPage.vue';
export const router = createRouter({
// The sandbox has no address bar, so the history is kept in memory.
// In a real project: createWebHistory().
history: createMemoryHistory(),
routes: [
{ path: '/', component: CatalogPage },
{ path: '/product/:id', component: ProductPage },
{ path: '/cart', component: CartPage },
{ path: '/checkout', component: CheckoutPage },
{ path: '/orders', component: OrdersPage },
{ path: '/:pathMatch(.*)*', component: NotFoundPage },
],
});
App.vue<script setup>
import { RouterLink, RouterView } from 'vue-router';
import { useCart } from './store/cart.js';
const cart = useCart();
</script>
<template>
<header>
<strong>The shop</strong>
<nav aria-label="Main menu">
<RouterLink to="/">Catalogue</RouterLink>
<RouterLink to="/cart">Cart ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">My orders</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Catalogue</h1>
<Filter v-model:q="q" v-model:category="category" :categories="categories" />
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!products" role="status">Loading…</p>
<p v-else-if="products.length === 0">No such products.</p>
<ul v-else class="grid">
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</ul>
</template>
pages/ProductPage.vue<script setup>
import { computed, watch, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const route = useRoute();
const cart = useCart();
const product = ref(null);
const related = ref([]);
const error = ref(null);
const inCart = computed(() => cart.items.find((i) => i.id === product.value?.id)?.qty ?? 0);
async function load(id, signal) {
const p = await getProduct(id, signal);
product.value = p;
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, signal);
related.value = same.filter((x) => x.id !== p.id).slice(0, 3);
}
watch(
() => route.params.id,
(id, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
product.value = null;
related.value = [];
error.value = null;
load(id, controller.signal).catch((e) => {
if (e.name !== 'AbortError') error.value = e.status === 404 ? 'No such product.' : e.message;
});
},
{ immediate: true },
);
</script>
<template>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!product" role="status">Loading…</p>
<article v-else>
<p>
<RouterLink to="/">← Back to the catalogue</RouterLink>
</p>
<h1>{{ product.name }}</h1>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
<p v-if="product.stock > 0">In stock: {{ product.stock }} pcs</p>
<p v-else>Sold out</p>
<button type="button" :disabled="product.stock === 0" @click="cart.add(product)">
Add to cart
</button>
<p v-if="inCart > 0" role="status">In the cart: {{ inCart }}</p>
<section v-if="related.length > 0" aria-labelledby="related-title">
<h2 id="related-title">More from this category</h2>
<ul>
<li v-for="p in related" :key="p.id">
<RouterLink :to="`/product/${p.id}`">{{ p.name }}</RouterLink>
</li>
</ul>
</section>
</article>
</template>
pages/CartPage.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const cart = useCart();
const upTo = (n) => Array.from({ length: Math.min(n, 10) }, (_, i) => i + 1);
</script>
<template>
<h1>Cart</h1>
<template v-if="cart.items.length === 0">
<p>The cart is empty.</p>
<p>
<RouterLink to="/">Back to the catalogue</RouterLink>
</p>
</template>
<template v-else>
<ul class="cart">
<li v-for="item in cart.items" :key="item.id">
<span>{{ item.name }}</span>
<select
:aria-label="`Quantity: ${item.name}`"
:value="item.qty"
@change="cart.setQty(item.id, Number($event.target.value))"
>
<option v-for="n in upTo(item.stock)" :key="n">{{ n }}</option>
</select>
<span>{{ money(item.price * item.qty) }}</span>
<button type="button" :aria-label="`Remove ${item.name}`" @click="cart.remove(item.id)">
Remove
</button>
</li>
</ul>
<p class="price">Total: {{ money(cart.total) }}</p>
<RouterLink to="/checkout">To checkout</RouterLink>
</template>
</template>
pages/CheckoutPage.vue<script setup>
import { reactive, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { placeOrder } from '../api.js';
import Field from '../components/Field.vue';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const cart = useCart();
const form = reactive({ name: '', email: '', address: '' });
const errors = ref({});
const sending = ref(false);
const order = ref(null);
function check() {
const found = {};
if (!form.name.trim()) found.name = 'Enter your name.';
if (!form.email.includes('@')) found.email = 'Enter your email.';
if (!form.address.trim()) found.address = 'Enter your address.';
return found;
}
async function submit() {
errors.value = check();
if (Object.keys(errors.value).length > 0) return;
sending.value = true;
try {
const items = cart.items.map((i) => ({ productId: i.id, qty: i.qty }));
order.value = await placeOrder({ ...form, items });
cart.clear();
} catch (err) {
errors.value = err.errors ?? { form: err.message };
} finally {
sending.value = false;
}
}
</script>
<template>
<template v-if="order">
<h1>Order received</h1>
<p role="status">Order #{{ order.id }} for {{ money(order.total) }}.</p>
<p>
<RouterLink to="/orders">My orders</RouterLink>
</p>
</template>
<template v-else-if="cart.items.length === 0">
<h1>Checkout</h1>
<p>The cart is empty.</p>
</template>
<template v-else>
<h1>Checkout</h1>
<p class="price">Total: {{ money(cart.total) }}</p>
<form novalidate @submit.prevent="submit">
<Field v-model="form.name" name="name" label="Name" :error="errors.name" />
<Field v-model="form.email" name="email" label="Email" type="email" :error="errors.email" />
<Field v-model="form.address" name="address" label="Address" :error="errors.address" />
<p v-if="errors.items" role="alert">{{ errors.items }}</p>
<p v-if="errors.form" role="alert">{{ errors.form }}</p>
<button :disabled="sending">{{ sending ? 'Sending…' : 'Place order' }}</button>
</form>
</template>
</template>
pages/OrdersPage.vue<script setup>
import { onMounted, onUnmounted, ref } from 'vue';
import { ME, getOrders } from '../api.js';
import { date, money, statusText } from '../format.js';
const orders = ref(null);
const error = ref(null);
const controller = new AbortController();
onMounted(async () => {
try {
const all = await getOrders(controller.signal);
orders.value = all.filter((o) => o.customerId === ME).sort((a, b) => b.id - a.id);
} catch (e) {
if (e.name !== 'AbortError') error.value = e.message;
}
});
onUnmounted(() => controller.abort());
</script>
<template>
<h1 id="orders-title">My orders</h1>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!orders" role="status">Loading…</p>
<p v-else-if="orders.length === 0">You have no orders yet.</p>
<ul v-else class="orders" aria-labelledby="orders-title">
<li v-for="o in orders" :key="o.id">
<h2>Order #{{ o.id }}</h2>
<p class="muted">{{ date(o.date) }} · {{ statusText(o.status) }} · {{ money(o.total) }}</p>
<p>{{ o.items.map((i) => `${i.name} × ${i.qty}`).join(', ') }}</p>
</li>
</ul>
</template>
pages/NotFoundPage.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>No such page</h1>
<p>
<RouterLink to="/">Back to the catalogue</RouterLink>
</p>
</template>
components/ProductCard.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
defineProps(['product']);
</script>
<template>
<li class="card">
<h2>
<RouterLink :to="`/product/${product.id}`">{{ product.name }}</RouterLink>
</h2>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
</li>
</template>
components/Filter.vue<script setup>
import { computed } from 'vue';
const props = defineProps(['categories']);
const q = defineModel('q');
const category = defineModel('category');
// Products live in the subcategories: "Food" and "Home" are only groups.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Search
<input v-model="q" type="search" />
</label>
<label>
Category
<select v-model="category">
<option value="">All</option>
<option v-for="c in groups" :key="c.id" :value="String(c.id)">{{ c.name }}</option>
</select>
</label>
</form>
</template>
components/Field.vue<script setup>
const value = defineModel();
defineProps({
name: String,
label: String,
type: { type: String, default: 'text' },
error: String,
});
</script>
<template>
<div class="field">
<label :for="name">{{ label }}</label>
<input
:id="name"
v-model="value"
:name="name"
:type="type"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error ? `${name}-error` : undefined"
/>
<span v-if="error" :id="`${name}-error`" class="error">{{ error }}</span>
</div>
</template>
store/cart.jsimport { computed, ref } from 'vue';
import { defineStore } from 'pinia';
export const useCart = defineStore('cart', () => {
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.qty, 0));
function add(product) {
const found = items.value.find((i) => i.id === product.id);
if (found) found.qty += 1;
else items.value.push({ id: product.id, name: product.name, price: product.price, stock: product.stock, qty: 1 });
}
function setQty(id, qty) {
const found = items.value.find((i) => i.id === id);
if (found) found.qty = qty;
}
function remove(id) {
items.value = items.value.filter((i) => i.id !== id);
}
function clear() {
items.value = [];
}
return { items, count, total, add, setQty, remove, clear };
});
api.js// Every request to the shop's API is here: the pages do not know the addresses.
async function request(path, options = {}) {
const res = await fetch(path, options);
const data = await res.json();
if (!res.ok) {
const error = new Error(data.error ?? `The server answered ${res.status}`);
error.status = res.status;
error.errors = data.errors;
throw error;
}
return data;
}
export function getCategories() {
return request('/api/categories');
}
export function getProducts({ q = '', category = '' } = {}, signal) {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (category) params.set('category', category);
return request(`/api/products?${params}`, { signal });
}
export function getProduct(id, signal) {
return request(`/api/products/${id}`, { signal });
}
export function placeOrder(order) {
return request('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order),
});
}
// The training API has no login and returns the orders of every customer. You are customer 1.
// A real server returns only yours: it knows who you are from the session.
export const ME = 1;
export function getOrders(signal) {
return request('/api/orders', { signal });
}
format.jsexport const money = (x) => '€' + x.toFixed(2);
export const date = (iso) => new Date(iso).toLocaleDateString('en-GB');
const STATUS = { new: 'New', paid: 'Paid', shipped: 'Shipped', delivered: 'Delivered' };
export const statusText = (status) => STATUS[status] ?? status;
style.css* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; color: #1f2328; background: #fbfaf7; }
header { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; background: #f3efe6; }
header strong { margin-right: auto; }
nav { display: flex; flex-wrap: wrap; gap: 12px; }
nav a { color: inherit; }
nav a[aria-current="page"] { font-weight: 600; text-decoration-thickness: 2px; }
main { padding: 0 16px 24px; }
h1 { font-size: 1.4rem; }
input, select, button { font: inherit; padding: 4px 8px; }
.filter { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 12px; }
.filter label { display: flex; flex-direction: column; gap: 2px; font-size: 0.9rem; }
.grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.card { background: #fff; border: 1px solid #ddd6c8; border-radius: 8px; padding: 8px 10px; }
.card h2 { font-size: 1rem; margin: 0 0 4px; }
.card p { margin: 0; }
.muted { color: #5c6168; font-size: 0.85rem; }
.price { font-weight: 600; }
.cart, .orders { list-style: none; padding: 0; }
.cart li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.cart li span:first-child { flex: 1; }
.orders li { padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.orders h2 { font-size: 1rem; margin: 0; }
.orders p { margin: 2px 0; }
.field { display: flex; flex-direction: column; gap: 2px; max-width: 320px; margin-bottom: 10px; }
.field input[aria-invalid="true"] { border-color: #b42318; }
.error { color: #b42318; font-size: 0.85rem; }
The check catches an api.js that keeps the first answer of /api/orders and does not ask again (the new order does not show), a list without sorting (the new one is at the bottom) and a list without the customer filter (60 orders of other people).
The idea
What is simplified here
The app is real, but some things around it are simplified. In a real shop:
- The server knows who you are. You log in with a password and the server returns only your orders, by the session. A filter in the browser protects nothing: anyone can change
ME. - The price lives on the server. The order sends only
productIdandqty. The total comes back in the answer, worked out from the prices in the database. - The cart survives. Here it lives in memory and is gone on reload. A real one is in
localStorageor on the server. - The addresses are real.
createWebHistory()instead of the memory history, and hosting that returnsindex.htmlfor every address.
More precisely, if you are curious
The two libraries. The path is the same: addresses, data from an API, a shared cart, a form with errors per field. Vue does it with reactivity: watch on the parameter and the filter, Pinia for the cart, v-model and defineModel for the fields. The other library solves the same tasks its own way.
Tests. The checks of the steps click and type like a person. You write the same with Testing Library: render, screen.getByRole, userEvent. How is in the testing lesson.
Speed. The catalogue loads on every key press. With the old request cancelled this is correct, but wasteful. In a real project the search waits for 300 ms of quiet before it goes out (debounce).
The idea
What next
Move the shop front into a real project, as in the lesson "From the sandbox to a project":
npm create vite@latest shop -- --template vuecd shopnpm install vue-router piniaCopy the folders pages/, components/, store/ and the files api.js, format.js, router.js, style.css into src/. In router.js swap createMemoryHistory() for createWebHistory().
The API is no longer pretend. Run a real server with the addresses /api/products, /api/orders, or point /api at it with server.proxy in vite.config.js. Then npm run build and publish dist/ with a rule that returns index.html for every address.
More tasks
- Order details.
/orders/:idwithGET /api/orders/:idand a link from "My orders". An unknown number shows "No such order." - A cart that survives. Keep
itemsinlocalStorageand read them on start. - Tests. A test for
CartPage: changing a quantity changes the total. A test forCheckoutPage: an empty form shows the three errors and sends no request.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Shared state · Routing · Testing components · From the sandbox to a project
- Tasks on this topic: Project: the shop front