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 main.jsx: MemoryRouter around CartProvider and App. The addresses are in App.jsx with Routes and Route, as in the routing lesson.
The sandbox has no address bar, so the history is kept in memory. In a real project it is BrowserRouter. 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 effect has
[q, category]in its dependencies. - 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 the effect's cleanup.
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.jsximport { createRoot } from 'react-dom/client';
import { MemoryRouter } from 'react-router';
import App from './App.jsx';
import { CartProvider } from './store/cart.jsx';
import './style.css';
// The sandbox has no address bar, so the history is kept in memory.
// In a real project: BrowserRouter.
createRoot(document.getElementById('root')).render(
<MemoryRouter>
<CartProvider>
<App />
</CartProvider>
</MemoryRouter>,
);
App.jsximport { NavLink, Route, Routes } from 'react-router';
import CartPage from './pages/CartPage.jsx';
import CatalogPage from './pages/CatalogPage.jsx';
import CheckoutPage from './pages/CheckoutPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
import OrdersPage from './pages/OrdersPage.jsx';
import ProductPage from './pages/ProductPage.jsx';
import { useCart } from './store/cart.jsx';
export default function App() {
const cart = useCart();
return (
<>
<header>
<strong>The shop</strong>
<nav aria-label="Main menu">
<NavLink to="/" end>Catalogue</NavLink>
<NavLink to="/cart">Cart ({cart.count})</NavLink>
<NavLink to="/orders">My orders</NavLink>
</nav>
</header>
<main>
<Routes>
<Route path="/" element={<CatalogPage />} />
<Route path="/product/:id" element={<ProductPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
</>
);
}
pages/CatalogPage.jsximport { useEffect, useState } from 'react';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.jsx';
import ProductCard from '../components/ProductCard.jsx';
export default function CatalogPage() {
const [q, setQ] = useState('');
const [category, setCategory] = useState('');
const [categories, setCategories] = useState([]);
const [products, setProducts] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
getCategories()
.then(setCategories)
.catch(() => setCategories([]));
}, []);
useEffect(() => {
const controller = new AbortController();
setProducts(null);
setError(null);
getProducts({ q, category }, controller.signal)
.then(setProducts)
.catch((e) => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort();
}, [q, category]);
return (
<>
<h1>Catalogue</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Loading…</p>}
{products && products.length === 0 && <p>No such products.</p>}
{products && products.length > 0 && (
<ul className="grid">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
)}
</>
);
}
pages/ProductPage.jsxexport default function ProductPage() {
return (
<>
<h1>Product</h1>
<p>The product comes here.</p>
</>
);
}
pages/CartPage.jsxexport default function CartPage() {
return (
<>
<h1>Cart</h1>
<p>The cart comes here.</p>
</>
);
}
pages/CheckoutPage.jsxexport default function CheckoutPage() {
return (
<>
<h1>Checkout</h1>
<p>The checkout comes here.</p>
</>
);
}
pages/OrdersPage.jsxexport default function OrdersPage() {
return (
<>
<h1>My orders</h1>
<p>Your orders come here.</p>
</>
);
}
pages/NotFoundPage.jsximport { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<h1>No such page</h1>
<p>
<Link to="/">Back to the catalogue</Link>
</p>
</>
);
}
components/ProductCard.jsximport { money } from '../format.js';
export default function ProductCard({ product }) {
return (
<li className="card">
<h2>{product.name}</h2>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
</li>
);
}
components/Filter.jsxexport default function Filter({ categories, q, category, onQChange, onCategoryChange }) {
// Products live in the subcategories: "Food" and "Home" are only groups.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Search
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Category
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">All</option>
{groups.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
</form>
);
}
components/Field.jsxexport default function Field({ name, label, type = 'text', value, error, onChange }) {
const errorId = `${name}-error`;
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<span id={errorId} className="error">
{error}
</span>
)}
</div>
);
}
store/cart.jsx// The cart comes in step 3. Until then it is empty and does not change.
export function CartProvider({ children }) {
return children;
}
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 dependencies; 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:
<Link 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 effect has[id]. - 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.jsximport { createRoot } from 'react-dom/client';
import { MemoryRouter } from 'react-router';
import App from './App.jsx';
import { CartProvider } from './store/cart.jsx';
import './style.css';
// The sandbox has no address bar, so the history is kept in memory.
// In a real project: BrowserRouter.
createRoot(document.getElementById('root')).render(
<MemoryRouter>
<CartProvider>
<App />
</CartProvider>
</MemoryRouter>,
);
App.jsximport { NavLink, Route, Routes } from 'react-router';
import CartPage from './pages/CartPage.jsx';
import CatalogPage from './pages/CatalogPage.jsx';
import CheckoutPage from './pages/CheckoutPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
import OrdersPage from './pages/OrdersPage.jsx';
import ProductPage from './pages/ProductPage.jsx';
import { useCart } from './store/cart.jsx';
export default function App() {
const cart = useCart();
return (
<>
<header>
<strong>The shop</strong>
<nav aria-label="Main menu">
<NavLink to="/" end>Catalogue</NavLink>
<NavLink to="/cart">Cart ({cart.count})</NavLink>
<NavLink to="/orders">My orders</NavLink>
</nav>
</header>
<main>
<Routes>
<Route path="/" element={<CatalogPage />} />
<Route path="/product/:id" element={<ProductPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
</>
);
}
pages/CatalogPage.jsximport { useEffect, useState } from 'react';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.jsx';
import ProductCard from '../components/ProductCard.jsx';
export default function CatalogPage() {
const [q, setQ] = useState('');
const [category, setCategory] = useState('');
const [categories, setCategories] = useState([]);
const [products, setProducts] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
getCategories()
.then(setCategories)
.catch(() => setCategories([]));
}, []);
useEffect(() => {
const controller = new AbortController();
setProducts(null);
setError(null);
getProducts({ q, category }, controller.signal)
.then(setProducts)
.catch((e) => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort();
}, [q, category]);
return (
<>
<h1>Catalogue</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Loading…</p>}
{products && products.length === 0 && <p>No such products.</p>}
{products && products.length > 0 && (
<ul className="grid">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
)}
</>
);
}
pages/ProductPage.jsximport { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
export default function ProductPage() {
const { id } = useParams();
const [product, setProduct] = useState(null);
const [related, setRelated] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
const p = await getProduct(id, controller.signal);
setProduct(p);
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, controller.signal);
setRelated(same.filter((x) => x.id !== p.id).slice(0, 3));
}
setProduct(null);
setRelated([]);
setError(null);
load().catch((e) => {
if (e.name !== 'AbortError') setError(e.status === 404 ? 'No such product.' : e.message);
});
return () => controller.abort();
}, [id]);
if (error) return <p role="alert">{error}</p>;
if (!product) return <p role="status">Loading…</p>;
return (
<article>
<p>
<Link to="/">← Back to the catalogue</Link>
</p>
<h1>{product.name}</h1>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
<p>{product.stock > 0 ? `In stock: ${product.stock} pcs` : 'Sold out'}</p>
{related.length > 0 && (
<section aria-labelledby="related-title">
<h2 id="related-title">More from this category</h2>
<ul>
{related.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</section>
)}
</article>
);
}
pages/CartPage.jsxexport default function CartPage() {
return (
<>
<h1>Cart</h1>
<p>The cart comes here.</p>
</>
);
}
pages/CheckoutPage.jsxexport default function CheckoutPage() {
return (
<>
<h1>Checkout</h1>
<p>The checkout comes here.</p>
</>
);
}
pages/OrdersPage.jsxexport default function OrdersPage() {
return (
<>
<h1>My orders</h1>
<p>Your orders come here.</p>
</>
);
}
pages/NotFoundPage.jsximport { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<h1>No such page</h1>
<p>
<Link to="/">Back to the catalogue</Link>
</p>
</>
);
}
components/ProductCard.jsximport { Link } from 'react-router';
import { money } from '../format.js';
export default function ProductCard({ product }) {
return (
<li className="card">
<h2>
<Link to={`/product/${product.id}`}>{product.name}</Link>
</h2>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
</li>
);
}
components/Filter.jsxexport default function Filter({ categories, q, category, onQChange, onCategoryChange }) {
// Products live in the subcategories: "Food" and "Home" are only groups.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Search
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Category
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">All</option>
{groups.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
</form>
);
}
components/Field.jsxexport default function Field({ name, label, type = 'text', value, error, onChange }) {
const errorId = `${name}-error`;
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<span id={errorId} className="error">
{error}
</span>
)}
</div>
);
}
store/cart.jsx// The cart comes in step 3. Until then it is empty and does not change.
export function CartProvider({ children }) {
return children;
}
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.jsx has a CartProvider with useState and a CartContext, as in the shared state lesson. useCart() returns useContext(CartContext): items, count, total, add, setQty, remove, clear. count and total are worked out from items, not kept separately. Every change makes a new array: [...items, …], items.map(…), items.filter(…).
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.jsximport { createRoot } from 'react-dom/client';
import { MemoryRouter } from 'react-router';
import App from './App.jsx';
import { CartProvider } from './store/cart.jsx';
import './style.css';
// The sandbox has no address bar, so the history is kept in memory.
// In a real project: BrowserRouter.
createRoot(document.getElementById('root')).render(
<MemoryRouter>
<CartProvider>
<App />
</CartProvider>
</MemoryRouter>,
);
App.jsximport { NavLink, Route, Routes } from 'react-router';
import CartPage from './pages/CartPage.jsx';
import CatalogPage from './pages/CatalogPage.jsx';
import CheckoutPage from './pages/CheckoutPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
import OrdersPage from './pages/OrdersPage.jsx';
import ProductPage from './pages/ProductPage.jsx';
import { useCart } from './store/cart.jsx';
export default function App() {
const cart = useCart();
return (
<>
<header>
<strong>The shop</strong>
<nav aria-label="Main menu">
<NavLink to="/" end>Catalogue</NavLink>
<NavLink to="/cart">Cart ({cart.count})</NavLink>
<NavLink to="/orders">My orders</NavLink>
</nav>
</header>
<main>
<Routes>
<Route path="/" element={<CatalogPage />} />
<Route path="/product/:id" element={<ProductPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
</>
);
}
pages/CatalogPage.jsximport { useEffect, useState } from 'react';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.jsx';
import ProductCard from '../components/ProductCard.jsx';
export default function CatalogPage() {
const [q, setQ] = useState('');
const [category, setCategory] = useState('');
const [categories, setCategories] = useState([]);
const [products, setProducts] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
getCategories()
.then(setCategories)
.catch(() => setCategories([]));
}, []);
useEffect(() => {
const controller = new AbortController();
setProducts(null);
setError(null);
getProducts({ q, category }, controller.signal)
.then(setProducts)
.catch((e) => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort();
}, [q, category]);
return (
<>
<h1>Catalogue</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Loading…</p>}
{products && products.length === 0 && <p>No such products.</p>}
{products && products.length > 0 && (
<ul className="grid">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
)}
</>
);
}
pages/ProductPage.jsximport { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
export default function ProductPage() {
const { id } = useParams();
const cart = useCart();
const [product, setProduct] = useState(null);
const [related, setRelated] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
const p = await getProduct(id, controller.signal);
setProduct(p);
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, controller.signal);
setRelated(same.filter((x) => x.id !== p.id).slice(0, 3));
}
setProduct(null);
setRelated([]);
setError(null);
load().catch((e) => {
if (e.name !== 'AbortError') setError(e.status === 404 ? 'No such product.' : e.message);
});
return () => controller.abort();
}, [id]);
if (error) return <p role="alert">{error}</p>;
if (!product) return <p role="status">Loading…</p>;
const inCart = cart.items.find((i) => i.id === product.id)?.qty ?? 0;
return (
<article>
<p>
<Link to="/">← Back to the catalogue</Link>
</p>
<h1>{product.name}</h1>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
<p>{product.stock > 0 ? `In stock: ${product.stock} pcs` : 'Sold out'}</p>
<button type="button" disabled={product.stock === 0} onClick={() => cart.add(product)}>
Add to cart
</button>
{inCart > 0 && <p role="status">In the cart: {inCart}</p>}
{related.length > 0 && (
<section aria-labelledby="related-title">
<h2 id="related-title">More from this category</h2>
<ul>
{related.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</section>
)}
</article>
);
}
pages/CartPage.jsximport { Link } from 'react-router';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
const upTo = (n) => Array.from({ length: Math.min(n, 10) }, (_, i) => i + 1);
export default function CartPage() {
const cart = useCart();
if (cart.items.length === 0)
return (
<>
<h1>Cart</h1>
<p>The cart is empty.</p>
<p>
<Link to="/">Back to the catalogue</Link>
</p>
</>
);
return (
<>
<h1>Cart</h1>
<ul className="cart">
{cart.items.map((item) => (
<li key={item.id}>
<span>{item.name}</span>
<select
aria-label={`Quantity: ${item.name}`}
value={item.qty}
onChange={(e) => cart.setQty(item.id, Number(e.target.value))}
>
{upTo(item.stock).map((n) => (
<option key={n}>{n}</option>
))}
</select>
<span>{money(item.price * item.qty)}</span>
<button type="button" aria-label={`Remove ${item.name}`} onClick={() => cart.remove(item.id)}>
Remove
</button>
</li>
))}
</ul>
<p className="price">
Total: {money(cart.total)}
</p>
<Link to="/checkout">To checkout</Link>
</>
);
}
pages/CheckoutPage.jsxexport default function CheckoutPage() {
return (
<>
<h1>Checkout</h1>
<p>The checkout comes here.</p>
</>
);
}
pages/OrdersPage.jsxexport default function OrdersPage() {
return (
<>
<h1>My orders</h1>
<p>Your orders come here.</p>
</>
);
}
pages/NotFoundPage.jsximport { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<h1>No such page</h1>
<p>
<Link to="/">Back to the catalogue</Link>
</p>
</>
);
}
components/ProductCard.jsximport { Link } from 'react-router';
import { money } from '../format.js';
export default function ProductCard({ product }) {
return (
<li className="card">
<h2>
<Link to={`/product/${product.id}`}>{product.name}</Link>
</h2>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
</li>
);
}
components/Filter.jsxexport default function Filter({ categories, q, category, onQChange, onCategoryChange }) {
// Products live in the subcategories: "Food" and "Home" are only groups.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Search
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Category
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">All</option>
{groups.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
</form>
);
}
components/Field.jsxexport default function Field({ name, label, type = 'text', value, error, onChange }) {
const errorId = `${name}-error`;
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<span id={errorId} className="error">
{error}
</span>
)}
</div>
);
}
store/cart.jsximport { createContext, useContext, useState } from 'react';
const CartContext = createContext(null);
export function CartProvider({ children }) {
const [items, setItems] = useState([]);
function add(product) {
const found = items.find((i) => i.id === product.id);
if (found) setItems(items.map((i) => (i.id === product.id ? { ...i, qty: i.qty + 1 } : i)));
else setItems([...items, { id: product.id, name: product.name, price: product.price, stock: product.stock, qty: 1 }]);
}
function setQty(id, qty) {
setItems(items.map((i) => (i.id === id ? { ...i, qty } : i)));
}
function remove(id) {
setItems(items.filter((i) => i.id !== id));
}
function clear() {
setItems([]);
}
const count = items.reduce((sum, i) => sum + i.qty, 0);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return <CartContext value={{ items, count, total, add, setQty, remove, clear }}>{children}</CartContext>;
}
export function useCart() {
return useContext(CartContext);
}
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 Context (every component has its own cart and the menu says 0), a total in a separate useState that changing a quantity does not update, and an add that changes the array in place and hands the same array to setItems: React sees the same array and does not render again.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.jsximport { createRoot } from 'react-dom/client';
import { MemoryRouter } from 'react-router';
import App from './App.jsx';
import { CartProvider } from './store/cart.jsx';
import './style.css';
// The sandbox has no address bar, so the history is kept in memory.
// In a real project: BrowserRouter.
createRoot(document.getElementById('root')).render(
<MemoryRouter>
<CartProvider>
<App />
</CartProvider>
</MemoryRouter>,
);
App.jsximport { NavLink, Route, Routes } from 'react-router';
import CartPage from './pages/CartPage.jsx';
import CatalogPage from './pages/CatalogPage.jsx';
import CheckoutPage from './pages/CheckoutPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
import OrdersPage from './pages/OrdersPage.jsx';
import ProductPage from './pages/ProductPage.jsx';
import { useCart } from './store/cart.jsx';
export default function App() {
const cart = useCart();
return (
<>
<header>
<strong>The shop</strong>
<nav aria-label="Main menu">
<NavLink to="/" end>Catalogue</NavLink>
<NavLink to="/cart">Cart ({cart.count})</NavLink>
<NavLink to="/orders">My orders</NavLink>
</nav>
</header>
<main>
<Routes>
<Route path="/" element={<CatalogPage />} />
<Route path="/product/:id" element={<ProductPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
</>
);
}
pages/CatalogPage.jsximport { useEffect, useState } from 'react';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.jsx';
import ProductCard from '../components/ProductCard.jsx';
export default function CatalogPage() {
const [q, setQ] = useState('');
const [category, setCategory] = useState('');
const [categories, setCategories] = useState([]);
const [products, setProducts] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
getCategories()
.then(setCategories)
.catch(() => setCategories([]));
}, []);
useEffect(() => {
const controller = new AbortController();
setProducts(null);
setError(null);
getProducts({ q, category }, controller.signal)
.then(setProducts)
.catch((e) => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort();
}, [q, category]);
return (
<>
<h1>Catalogue</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Loading…</p>}
{products && products.length === 0 && <p>No such products.</p>}
{products && products.length > 0 && (
<ul className="grid">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
)}
</>
);
}
pages/ProductPage.jsximport { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
export default function ProductPage() {
const { id } = useParams();
const cart = useCart();
const [product, setProduct] = useState(null);
const [related, setRelated] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
const p = await getProduct(id, controller.signal);
setProduct(p);
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, controller.signal);
setRelated(same.filter((x) => x.id !== p.id).slice(0, 3));
}
setProduct(null);
setRelated([]);
setError(null);
load().catch((e) => {
if (e.name !== 'AbortError') setError(e.status === 404 ? 'No such product.' : e.message);
});
return () => controller.abort();
}, [id]);
if (error) return <p role="alert">{error}</p>;
if (!product) return <p role="status">Loading…</p>;
const inCart = cart.items.find((i) => i.id === product.id)?.qty ?? 0;
return (
<article>
<p>
<Link to="/">← Back to the catalogue</Link>
</p>
<h1>{product.name}</h1>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
<p>{product.stock > 0 ? `In stock: ${product.stock} pcs` : 'Sold out'}</p>
<button type="button" disabled={product.stock === 0} onClick={() => cart.add(product)}>
Add to cart
</button>
{inCart > 0 && <p role="status">In the cart: {inCart}</p>}
{related.length > 0 && (
<section aria-labelledby="related-title">
<h2 id="related-title">More from this category</h2>
<ul>
{related.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</section>
)}
</article>
);
}
pages/CartPage.jsximport { Link } from 'react-router';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
const upTo = (n) => Array.from({ length: Math.min(n, 10) }, (_, i) => i + 1);
export default function CartPage() {
const cart = useCart();
if (cart.items.length === 0)
return (
<>
<h1>Cart</h1>
<p>The cart is empty.</p>
<p>
<Link to="/">Back to the catalogue</Link>
</p>
</>
);
return (
<>
<h1>Cart</h1>
<ul className="cart">
{cart.items.map((item) => (
<li key={item.id}>
<span>{item.name}</span>
<select
aria-label={`Quantity: ${item.name}`}
value={item.qty}
onChange={(e) => cart.setQty(item.id, Number(e.target.value))}
>
{upTo(item.stock).map((n) => (
<option key={n}>{n}</option>
))}
</select>
<span>{money(item.price * item.qty)}</span>
<button type="button" aria-label={`Remove ${item.name}`} onClick={() => cart.remove(item.id)}>
Remove
</button>
</li>
))}
</ul>
<p className="price">
Total: {money(cart.total)}
</p>
<Link to="/checkout">To checkout</Link>
</>
);
}
pages/CheckoutPage.jsximport { useState } from 'react';
import { Link } from 'react-router';
import { placeOrder } from '../api.js';
import Field from '../components/Field.jsx';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
function check(form) {
const errors = {};
if (!form.name.trim()) errors.name = 'Enter your name.';
if (!form.email.includes('@')) errors.email = 'Enter your email.';
if (!form.address.trim()) errors.address = 'Enter your address.';
return errors;
}
export default function CheckoutPage() {
const cart = useCart();
const [form, setForm] = useState({ name: '', email: '', address: '' });
const [errors, setErrors] = useState({});
const [sending, setSending] = useState(false);
const [order, setOrder] = useState(null);
const change = (e) => setForm({ ...form, [e.target.name]: e.target.value });
async function submit(e) {
e.preventDefault();
const found = check(form);
setErrors(found);
if (Object.keys(found).length > 0) return;
setSending(true);
try {
const items = cart.items.map((i) => ({ productId: i.id, qty: i.qty }));
setOrder(await placeOrder({ ...form, items }));
cart.clear();
} catch (err) {
setErrors(err.errors ?? { form: err.message });
} finally {
setSending(false);
}
}
if (order)
return (
<>
<h1>Order received</h1>
<p role="status">
Order #{order.id} for {money(order.total)}.
</p>
<p>
<Link to="/orders">My orders</Link>
</p>
</>
);
if (cart.items.length === 0)
return (
<>
<h1>Checkout</h1>
<p>The cart is empty.</p>
</>
);
return (
<>
<h1>Checkout</h1>
<p className="price">
Total: {money(cart.total)}
</p>
<form noValidate onSubmit={submit}>
<Field name="name" label="Name" value={form.name} error={errors.name} onChange={change} />
<Field name="email" label="Email" type="email" value={form.email} error={errors.email} onChange={change} />
<Field name="address" label="Address" value={form.address} error={errors.address} onChange={change} />
{errors.items && <p role="alert">{errors.items}</p>}
{errors.form && <p role="alert">{errors.form}</p>}
<button disabled={sending}>{sending ? 'Sending…' : 'Place order'}</button>
</form>
</>
);
}
pages/OrdersPage.jsxexport default function OrdersPage() {
return (
<>
<h1>My orders</h1>
<p>Your orders come here.</p>
</>
);
}
pages/NotFoundPage.jsximport { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<h1>No such page</h1>
<p>
<Link to="/">Back to the catalogue</Link>
</p>
</>
);
}
components/ProductCard.jsximport { Link } from 'react-router';
import { money } from '../format.js';
export default function ProductCard({ product }) {
return (
<li className="card">
<h2>
<Link to={`/product/${product.id}`}>{product.name}</Link>
</h2>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
</li>
);
}
components/Filter.jsxexport default function Filter({ categories, q, category, onQChange, onCategoryChange }) {
// Products live in the subcategories: "Food" and "Home" are only groups.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Search
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Category
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">All</option>
{groups.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
</form>
);
}
components/Field.jsxexport default function Field({ name, label, type = 'text', value, error, onChange }) {
const errorId = `${name}-error`;
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<span id={errorId} className="error">
{error}
</span>
)}
</div>
);
}
store/cart.jsximport { createContext, useContext, useState } from 'react';
const CartContext = createContext(null);
export function CartProvider({ children }) {
const [items, setItems] = useState([]);
function add(product) {
const found = items.find((i) => i.id === product.id);
if (found) setItems(items.map((i) => (i.id === product.id ? { ...i, qty: i.qty + 1 } : i)));
else setItems([...items, { id: product.id, name: product.name, price: product.price, stock: product.stock, qty: 1 }]);
}
function setQty(id, qty) {
setItems(items.map((i) => (i.id === id ? { ...i, qty } : i)));
}
function remove(id) {
setItems(items.filter((i) => i.id !== id));
}
function clear() {
setItems([]);
}
const count = items.reduce((sum, i) => sum + i.qty, 0);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return <CartContext value={{ items, count, total, add, setQty, remove, clear }}>{children}</CartContext>;
}
export function useCart() {
return useContext(CartContext);
}
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.jsximport { createRoot } from 'react-dom/client';
import { MemoryRouter } from 'react-router';
import App from './App.jsx';
import { CartProvider } from './store/cart.jsx';
import './style.css';
// The sandbox has no address bar, so the history is kept in memory.
// In a real project: BrowserRouter.
createRoot(document.getElementById('root')).render(
<MemoryRouter>
<CartProvider>
<App />
</CartProvider>
</MemoryRouter>,
);
App.jsximport { NavLink, Route, Routes } from 'react-router';
import CartPage from './pages/CartPage.jsx';
import CatalogPage from './pages/CatalogPage.jsx';
import CheckoutPage from './pages/CheckoutPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
import OrdersPage from './pages/OrdersPage.jsx';
import ProductPage from './pages/ProductPage.jsx';
import { useCart } from './store/cart.jsx';
export default function App() {
const cart = useCart();
return (
<>
<header>
<strong>The shop</strong>
<nav aria-label="Main menu">
<NavLink to="/" end>Catalogue</NavLink>
<NavLink to="/cart">Cart ({cart.count})</NavLink>
<NavLink to="/orders">My orders</NavLink>
</nav>
</header>
<main>
<Routes>
<Route path="/" element={<CatalogPage />} />
<Route path="/product/:id" element={<ProductPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
</>
);
}
pages/CatalogPage.jsximport { useEffect, useState } from 'react';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.jsx';
import ProductCard from '../components/ProductCard.jsx';
export default function CatalogPage() {
const [q, setQ] = useState('');
const [category, setCategory] = useState('');
const [categories, setCategories] = useState([]);
const [products, setProducts] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
getCategories()
.then(setCategories)
.catch(() => setCategories([]));
}, []);
useEffect(() => {
const controller = new AbortController();
setProducts(null);
setError(null);
getProducts({ q, category }, controller.signal)
.then(setProducts)
.catch((e) => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort();
}, [q, category]);
return (
<>
<h1>Catalogue</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Loading…</p>}
{products && products.length === 0 && <p>No such products.</p>}
{products && products.length > 0 && (
<ul className="grid">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
)}
</>
);
}
pages/ProductPage.jsximport { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
export default function ProductPage() {
const { id } = useParams();
const cart = useCart();
const [product, setProduct] = useState(null);
const [related, setRelated] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
const p = await getProduct(id, controller.signal);
setProduct(p);
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, controller.signal);
setRelated(same.filter((x) => x.id !== p.id).slice(0, 3));
}
setProduct(null);
setRelated([]);
setError(null);
load().catch((e) => {
if (e.name !== 'AbortError') setError(e.status === 404 ? 'No such product.' : e.message);
});
return () => controller.abort();
}, [id]);
if (error) return <p role="alert">{error}</p>;
if (!product) return <p role="status">Loading…</p>;
const inCart = cart.items.find((i) => i.id === product.id)?.qty ?? 0;
return (
<article>
<p>
<Link to="/">← Back to the catalogue</Link>
</p>
<h1>{product.name}</h1>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
<p>{product.stock > 0 ? `In stock: ${product.stock} pcs` : 'Sold out'}</p>
<button type="button" disabled={product.stock === 0} onClick={() => cart.add(product)}>
Add to cart
</button>
{inCart > 0 && <p role="status">In the cart: {inCart}</p>}
{related.length > 0 && (
<section aria-labelledby="related-title">
<h2 id="related-title">More from this category</h2>
<ul>
{related.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</section>
)}
</article>
);
}
pages/CartPage.jsximport { Link } from 'react-router';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
const upTo = (n) => Array.from({ length: Math.min(n, 10) }, (_, i) => i + 1);
export default function CartPage() {
const cart = useCart();
if (cart.items.length === 0)
return (
<>
<h1>Cart</h1>
<p>The cart is empty.</p>
<p>
<Link to="/">Back to the catalogue</Link>
</p>
</>
);
return (
<>
<h1>Cart</h1>
<ul className="cart">
{cart.items.map((item) => (
<li key={item.id}>
<span>{item.name}</span>
<select
aria-label={`Quantity: ${item.name}`}
value={item.qty}
onChange={(e) => cart.setQty(item.id, Number(e.target.value))}
>
{upTo(item.stock).map((n) => (
<option key={n}>{n}</option>
))}
</select>
<span>{money(item.price * item.qty)}</span>
<button type="button" aria-label={`Remove ${item.name}`} onClick={() => cart.remove(item.id)}>
Remove
</button>
</li>
))}
</ul>
<p className="price">
Total: {money(cart.total)}
</p>
<Link to="/checkout">To checkout</Link>
</>
);
}
pages/CheckoutPage.jsximport { useState } from 'react';
import { Link } from 'react-router';
import { placeOrder } from '../api.js';
import Field from '../components/Field.jsx';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
function check(form) {
const errors = {};
if (!form.name.trim()) errors.name = 'Enter your name.';
if (!form.email.includes('@')) errors.email = 'Enter your email.';
if (!form.address.trim()) errors.address = 'Enter your address.';
return errors;
}
export default function CheckoutPage() {
const cart = useCart();
const [form, setForm] = useState({ name: '', email: '', address: '' });
const [errors, setErrors] = useState({});
const [sending, setSending] = useState(false);
const [order, setOrder] = useState(null);
const change = (e) => setForm({ ...form, [e.target.name]: e.target.value });
async function submit(e) {
e.preventDefault();
const found = check(form);
setErrors(found);
if (Object.keys(found).length > 0) return;
setSending(true);
try {
const items = cart.items.map((i) => ({ productId: i.id, qty: i.qty }));
setOrder(await placeOrder({ ...form, items }));
cart.clear();
} catch (err) {
setErrors(err.errors ?? { form: err.message });
} finally {
setSending(false);
}
}
if (order)
return (
<>
<h1>Order received</h1>
<p role="status">
Order #{order.id} for {money(order.total)}.
</p>
<p>
<Link to="/orders">My orders</Link>
</p>
</>
);
if (cart.items.length === 0)
return (
<>
<h1>Checkout</h1>
<p>The cart is empty.</p>
</>
);
return (
<>
<h1>Checkout</h1>
<p className="price">
Total: {money(cart.total)}
</p>
<form noValidate onSubmit={submit}>
<Field name="name" label="Name" value={form.name} error={errors.name} onChange={change} />
<Field name="email" label="Email" type="email" value={form.email} error={errors.email} onChange={change} />
<Field name="address" label="Address" value={form.address} error={errors.address} onChange={change} />
{errors.items && <p role="alert">{errors.items}</p>}
{errors.form && <p role="alert">{errors.form}</p>}
<button disabled={sending}>{sending ? 'Sending…' : 'Place order'}</button>
</form>
</>
);
}
pages/OrdersPage.jsximport { useEffect, useState } from 'react';
import { ME, getOrders } from '../api.js';
import { date, money, statusText } from '../format.js';
export default function OrdersPage() {
const [orders, setOrders] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
getOrders(controller.signal)
.then((all) => setOrders(all.filter((o) => o.customerId === ME).sort((a, b) => b.id - a.id)))
.catch((e) => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort();
}, []);
return (
<>
<h1 id="orders-title">My orders</h1>
{error && <p role="alert">{error}</p>}
{!error && !orders && <p role="status">Loading…</p>}
{orders && orders.length === 0 && <p>You have no orders yet.</p>}
{orders && orders.length > 0 && (
<ul className="orders" aria-labelledby="orders-title">
{orders.map((o) => (
<li key={o.id}>
<h2>Order #{o.id}</h2>
<p className="muted">
{date(o.date)} · {statusText(o.status)} · {money(o.total)}
</p>
<p>{o.items.map((i) => `${i.name} × ${i.qty}`).join(', ')}</p>
</li>
))}
</ul>
)}
</>
);
}
pages/NotFoundPage.jsximport { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<h1>No such page</h1>
<p>
<Link to="/">Back to the catalogue</Link>
</p>
</>
);
}
components/ProductCard.jsximport { Link } from 'react-router';
import { money } from '../format.js';
export default function ProductCard({ product }) {
return (
<li className="card">
<h2>
<Link to={`/product/${product.id}`}>{product.name}</Link>
</h2>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
</li>
);
}
components/Filter.jsxexport default function Filter({ categories, q, category, onQChange, onCategoryChange }) {
// Products live in the subcategories: "Food" and "Home" are only groups.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Search
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Category
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">All</option>
{groups.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
</form>
);
}
components/Field.jsxexport default function Field({ name, label, type = 'text', value, error, onChange }) {
const errorId = `${name}-error`;
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<span id={errorId} className="error">
{error}
</span>
)}
</div>
);
}
store/cart.jsximport { createContext, useContext, useState } from 'react';
const CartContext = createContext(null);
export function CartProvider({ children }) {
const [items, setItems] = useState([]);
function add(product) {
const found = items.find((i) => i.id === product.id);
if (found) setItems(items.map((i) => (i.id === product.id ? { ...i, qty: i.qty + 1 } : i)));
else setItems([...items, { id: product.id, name: product.name, price: product.price, stock: product.stock, qty: 1 }]);
}
function setQty(id, qty) {
setItems(items.map((i) => (i.id === id ? { ...i, qty } : i)));
}
function remove(id) {
setItems(items.filter((i) => i.id !== id));
}
function clear() {
setItems([]);
}
const count = items.reduce((sum, i) => sum + i.qty, 0);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return <CartContext value={{ items, count, total, add, setQty, remove, clear }}>{children}</CartContext>;
}
export function useCart() {
return useContext(CartContext);
}
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.
BrowserRouterinstead 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. React does it with hooks: useEffect with dependencies, Context for the cart, controlled fields with value and onChange. 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 reactcd shopnpm install react-routerCopy the folders pages/, components/, store/ and the files api.js, format.js, style.css into src/. In main.jsx swap MemoryRouter for BrowserRouter.
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