Das Schaufenster des Shops
Kurz gesagt: Ein Projekt für eine Web-App: das Schaufenster eines Shops mit einem Katalog aus einer API, einer Produktseite nach Adresse, einem gemeinsamen Warenkorb, einer Bestellung mit Prüfungen und „Meine Bestellungen“.
Problem
Das Schaufenster des Shops
Der Shop hat eine API: Produkte, Kategorien, Bestellungen. Was fehlt, ist das Schaufenster, durch das der Käufer einkauft. Der Weg des Käufers ist:
- Den Katalog durchsuchen oder eine Kategorie wählen.
- Ein Produkt öffnen und in den Warenkorb legen.
- Im Warenkorb die Mengen ändern.
- Bestellen: Name, E-Mail, Adresse. Ein Fehler zeigt sich dort, wo er ist.
- Die Bestellung unter „Meine Bestellungen“ sehen.
Jeden Teil hast du in einer eigenen Lektion gebaut. Hier fügst du sie in einer App zusammen und baust sie in fünf Schritten: Jede Aufgabe beginnt mit der Lösung der vorigen.
Die Idee
Das Gerüst
Das ist die App bei Schritt null: das Menü und Platzhalterseiten. Klick durch das Menü.
Konsole
Die Dateien sind angelegt wie in einem echten Projekt:
api.jsenthält jede Anfrage. Die Seiten rufengetProducts,placeOrderauf und kennen die Adressen nicht. Ein Fehler vom Server kommt alsErrormitstatusunderrors(die Fehler pro Feld).format.jsformatiert Geld, Datum und Status.pages/sind die Seiten, eine pro Adresse.components/sind die Teile, die die Seiten benutzen:ProductCard,Filter,Field.store/cartist der Warenkorb, den die ganze App teilt.
Der Router steht in main.jsx: MemoryRouter um CartProvider und App. Die Adressen stehen in App.jsx mit Routes und Route, wie in der Routing-Lektion.
Die Sandbox hat keine Adressleiste, deshalb liegt der Verlauf im Speicher. In einem echten Projekt ist es BrowserRouter. Der Rest ist gleich.
Filter und Field sind aus den Lektionen fertig: Felder, die an den Zustand gebunden sind, und ein Feld mit Label und Fehlermeldung. Du schreibst die Seiten und den Warenkorb.
Übung
Schritt 1: der Katalog
Der Katalog lädt die Produkte mit getProducts({ q, category }) und zeigt sie mit ProductCard in einer Liste mit der Klasse grid. Oben steht der Filter. Jede Änderung der Suche oder der Kategorie startet eine neue Anfrage, und die alte wird abgebrochen. Solange sie wartet, sagt die Seite „Lädt…“. Passt nichts, sagt sie „Keine passenden Produkte.“
Achte auf zwei Dinge aus der Lektion über Daten aus einer API:
- Die Liste folgt dem Filter. Der Effekt hat
[q, category]in seinen Abhängigkeiten. - Die alte Antwort gewinnt nicht. Wenn „Milchprodukte“ langsam antwortet und du schon „Getränke“ gewählt hast, darf die langsame Antwort die neue nicht auslöschen. Ein
AbortControllerbricht die alte Anfrage ab im Aufräumen des Effekts.
Schreib CatalogPage: die Produkte aus der API nach dem Filter, „Lädt…“, „Keine passenden Produkte.“ und das Abbrechen der alten Anfrage. Die Prüfung sucht nach „brot“, wählt dann „Milchprodukte“ und gleich danach „Getränke“ (Milchprodukte antworten nach 900 ms) und sucht dann innerhalb einer Kategorie.
Konsole
Lösung
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';
// Die Sandbox hat keine Adressleiste, deshalb liegt der Verlauf im Speicher.
// Im echten Projekt: 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>Der Shop</strong>
<nav aria-label="Hauptmenü">
<NavLink to="/" end>Katalog</NavLink>
<NavLink to="/cart">Warenkorb ({cart.count})</NavLink>
<NavLink to="/orders">Meine Bestellungen</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>Katalog</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Lädt…</p>}
{products && products.length === 0 && <p>Keine passenden Produkte.</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>Produkt</h1>
<p>Hier kommt das Produkt hin.</p>
</>
);
}
pages/CartPage.jsxexport default function CartPage() {
return (
<>
<h1>Warenkorb</h1>
<p>Hier kommt der Warenkorb hin.</p>
</>
);
}
pages/CheckoutPage.jsxexport default function CheckoutPage() {
return (
<>
<h1>Kasse</h1>
<p>Hier kommt die Bestellung hin.</p>
</>
);
}
pages/OrdersPage.jsxexport default function OrdersPage() {
return (
<>
<h1>Meine Bestellungen</h1>
<p>Hier kommen deine Bestellungen hin.</p>
</>
);
}
pages/NotFoundPage.jsximport { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<h1>Diese Seite gibt es nicht</h1>
<p>
<Link to="/">Zurück zum Katalog</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 }) {
// Die Produkte liegen in den Unterkategorien: „Lebensmittel“ und „Haushalt“ sind nur Gruppen.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Suche
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Kategorie
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">Alle</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// Der Warenkorb kommt in Schritt 3. Bis dahin ist er leer und ändert sich nicht.
export function CartProvider({ children }) {
return children;
}
export function useCart() {
return { items: [], count: 0, total: 0, add() {}, setQty() {}, remove() {}, clear() {} };
}
api.js// Alle Anfragen an die API des Shops stehen hier: Die Seiten kennen die Adressen nicht.
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 ?? `Der Server antwortete ${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),
});
}
// Die Lern-API hat keine Anmeldung und liefert die Bestellungen aller Kunden. Du bist Kunde 1.
// Ein echter Server liefert nur deine: Er weiß aus der Sitzung, wer du bist.
export const ME = 1;
export function getOrders(signal) {
return request('/api/orders', { signal });
}
format.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export const date = (iso) => new Date(iso).toLocaleDateString('de-DE');
const STATUS = { new: 'Neu', paid: 'Bezahlt', shipped: 'Versendet', delivered: 'Geliefert' };
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; }
Die Prüfung findet drei Fehler: eine Anfrage nur einmal, wenn die Seite erscheint; eine Suche, die nicht in den Abhängigkeiten steht; und eine Anfrage ohne Abbrechen, bei der die Antwort für „Milchprodukte“ zuletzt kommt und Käse zeigt, während das Feld „Getränke“ sagt.
Übung
Schritt 2: die Produktseite
Der Name in ProductCard wird zu einem Link auf /product/:id. ProductPage nimmt id aus der Adresse, lädt das Produkt mit getProduct(id) und zeigt Name, Kategorie, Preis und Bestand: „Auf Lager: 18 Stk.“ oder „Ausverkauft“. Darunter steht „Mehr aus dieser Kategorie“: bis zu drei andere Produkte derselben Kategorie, mit Links.
Achte auf:
- Der Link gehört dem Router:
<Link to={…}>, nicht<a href>. Ein normaler Link lädt die ganze Seite noch einmal. In der Sandbox führt er nirgendwohin. - Dieselbe Seite, ein anderer Parameter. Von „Weißkäse“ zu „Kaschkawal“ erzeugt der Router keine neue
ProductPage, er ändert nurid. Der Effekt hat[id]. - Die
idaus der Adresse ist ein String:"7", nicht7.getProduct(id)setzt sie in die Adresse, und das ist in Ordnung. Wenn du sie in einem Array mit===suchst, vergleichst du einen String mit einer Zahl.
Die Einzelheiten stehen in der Routing-Lektion.
Mach den Namen in ProductCard zu einem Link und schreib ProductPage. Die Prüfung öffnet „Weißkäse“, dann „Kaschkawal“ aus „Mehr aus dieser Kategorie“, geht zurück zum Katalog und öffnet die ausverkaufte „Banitsa mit Käse“.
Konsole
Lösung
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';
// Die Sandbox hat keine Adressleiste, deshalb liegt der Verlauf im Speicher.
// Im echten Projekt: 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>Der Shop</strong>
<nav aria-label="Hauptmenü">
<NavLink to="/" end>Katalog</NavLink>
<NavLink to="/cart">Warenkorb ({cart.count})</NavLink>
<NavLink to="/orders">Meine Bestellungen</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>Katalog</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Lädt…</p>}
{products && products.length === 0 && <p>Keine passenden Produkte.</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 ? 'Kein solches Produkt.' : e.message);
});
return () => controller.abort();
}, [id]);
if (error) return <p role="alert">{error}</p>;
if (!product) return <p role="status">Lädt…</p>;
return (
<article>
<p>
<Link to="/">← Zurück zum Katalog</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 ? `Auf Lager: ${product.stock} Stk.` : 'Ausverkauft'}</p>
{related.length > 0 && (
<section aria-labelledby="related-title">
<h2 id="related-title">Mehr aus dieser Kategorie</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>Warenkorb</h1>
<p>Hier kommt der Warenkorb hin.</p>
</>
);
}
pages/CheckoutPage.jsxexport default function CheckoutPage() {
return (
<>
<h1>Kasse</h1>
<p>Hier kommt die Bestellung hin.</p>
</>
);
}
pages/OrdersPage.jsxexport default function OrdersPage() {
return (
<>
<h1>Meine Bestellungen</h1>
<p>Hier kommen deine Bestellungen hin.</p>
</>
);
}
pages/NotFoundPage.jsximport { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<h1>Diese Seite gibt es nicht</h1>
<p>
<Link to="/">Zurück zum Katalog</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 }) {
// Die Produkte liegen in den Unterkategorien: „Lebensmittel“ und „Haushalt“ sind nur Gruppen.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Suche
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Kategorie
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">Alle</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// Der Warenkorb kommt in Schritt 3. Bis dahin ist er leer und ändert sich nicht.
export function CartProvider({ children }) {
return children;
}
export function useCart() {
return { items: [], count: 0, total: 0, add() {}, setQty() {}, remove() {}, clear() {} };
}
api.js// Alle Anfragen an die API des Shops stehen hier: Die Seiten kennen die Adressen nicht.
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 ?? `Der Server antwortete ${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),
});
}
// Die Lern-API hat keine Anmeldung und liefert die Bestellungen aller Kunden. Du bist Kunde 1.
// Ein echter Server liefert nur deine: Er weiß aus der Sitzung, wer du bist.
export const ME = 1;
export function getOrders(signal) {
return request('/api/orders', { signal });
}
format.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export const date = (iso) => new Date(iso).toLocaleDateString('de-DE');
const STATUS = { new: 'Neu', paid: 'Bezahlt', shipped: 'Versendet', delivered: 'Geliefert' };
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; }
Die Prüfung findet <a href> (die Konsole sagt „Der Link führt zu /product/7“), eine Seite, die das Produkt nur beim ersten Erscheinen lädt (sie bleibt beim Weißkäse), und find((x) => x.id === id), das nichts findet, weil "7" !== 7.
Übung
Schritt 3: der Warenkorb
ProductPage und das Menü rufen schon useCart() auf: ein Button „In den Warenkorb“, „Im Warenkorb: 2“ und „Warenkorb (2)“. Vorerst ist useCart ein Platzhalter. Schreib den echten Warenkorb und CartPage.
store/cart.jsx hat einen CartProvider mit useState und einen CartContext, wie in der Lektion über gemeinsamen Zustand. useCart() gibt useContext(CartContext) zurück: items, count, total, add, setQty, remove, clear. count und total werden aus items berechnet, nicht getrennt gehalten. Jede Änderung erzeugt ein neues Array: [...items, …], items.map(…), items.filter(…).
CartPage zeigt eine Zeile pro Produkt: den Namen, ein Dropdown „Menge: Weißkäse“ (von 1 bis zum Bestand, höchstens 10), die Summe der Zeile und einen Button „Entfernen“ mit dem Namen „Entfernen Weißkäse“. Darunter stehen „Gesamt: …“ und ein Link „Zur Kasse“. Ein leerer Warenkorb sagt „Der Warenkorb ist leer.“
Schreib store/cart und CartPage. Die Prüfung legt zwei Weißkäse und Äpfel hinein, öffnet den Warenkorb, ändert den Käse auf 3 und entfernt die Äpfel, und achtet dabei auf das Menü und die Summe.
Konsole
Lösung
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';
// Die Sandbox hat keine Adressleiste, deshalb liegt der Verlauf im Speicher.
// Im echten Projekt: 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>Der Shop</strong>
<nav aria-label="Hauptmenü">
<NavLink to="/" end>Katalog</NavLink>
<NavLink to="/cart">Warenkorb ({cart.count})</NavLink>
<NavLink to="/orders">Meine Bestellungen</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>Katalog</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Lädt…</p>}
{products && products.length === 0 && <p>Keine passenden Produkte.</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 ? 'Kein solches Produkt.' : e.message);
});
return () => controller.abort();
}, [id]);
if (error) return <p role="alert">{error}</p>;
if (!product) return <p role="status">Lädt…</p>;
const inCart = cart.items.find((i) => i.id === product.id)?.qty ?? 0;
return (
<article>
<p>
<Link to="/">← Zurück zum Katalog</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 ? `Auf Lager: ${product.stock} Stk.` : 'Ausverkauft'}</p>
<button type="button" disabled={product.stock === 0} onClick={() => cart.add(product)}>
In den Warenkorb
</button>
{inCart > 0 && <p role="status">Im Warenkorb: {inCart}</p>}
{related.length > 0 && (
<section aria-labelledby="related-title">
<h2 id="related-title">Mehr aus dieser Kategorie</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>Warenkorb</h1>
<p>Der Warenkorb ist leer.</p>
<p>
<Link to="/">Zurück zum Katalog</Link>
</p>
</>
);
return (
<>
<h1>Warenkorb</h1>
<ul className="cart">
{cart.items.map((item) => (
<li key={item.id}>
<span>{item.name}</span>
<select
aria-label={`Menge: ${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={`Entfernen ${item.name}`} onClick={() => cart.remove(item.id)}>
Entfernen
</button>
</li>
))}
</ul>
<p className="price">
Gesamt: {money(cart.total)}
</p>
<Link to="/checkout">Zur Kasse</Link>
</>
);
}
pages/CheckoutPage.jsxexport default function CheckoutPage() {
return (
<>
<h1>Kasse</h1>
<p>Hier kommt die Bestellung hin.</p>
</>
);
}
pages/OrdersPage.jsxexport default function OrdersPage() {
return (
<>
<h1>Meine Bestellungen</h1>
<p>Hier kommen deine Bestellungen hin.</p>
</>
);
}
pages/NotFoundPage.jsximport { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<h1>Diese Seite gibt es nicht</h1>
<p>
<Link to="/">Zurück zum Katalog</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 }) {
// Die Produkte liegen in den Unterkategorien: „Lebensmittel“ und „Haushalt“ sind nur Gruppen.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Suche
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Kategorie
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">Alle</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// Alle Anfragen an die API des Shops stehen hier: Die Seiten kennen die Adressen nicht.
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 ?? `Der Server antwortete ${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),
});
}
// Die Lern-API hat keine Anmeldung und liefert die Bestellungen aller Kunden. Du bist Kunde 1.
// Ein echter Server liefert nur deine: Er weiß aus der Sitzung, wer du bist.
export const ME = 1;
export function getOrders(signal) {
return request('/api/orders', { signal });
}
format.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export const date = (iso) => new Date(iso).toLocaleDateString('de-DE');
const STATUS = { new: 'Neu', paid: 'Bezahlt', shipped: 'Versendet', delivered: 'Geliefert' };
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 ohne Context (jede Komponente hat ihren eigenen Warenkorb, und das Menü sagt 0), eine Summe in einem eigenen useState, die das Ändern einer Menge nicht aktualisiert, und ein add, das das Array an Ort und Stelle ändert und setItems dasselbe Array gibt: React sieht dasselbe Array und rendert nicht neu.Übung
Schritt 4: die Kasse
CheckoutPage hat ein Formular mit drei Fields: „Name“, „E-Mail“, „Adresse“. Bei „Bestellen“:
- Eine Prüfung im Browser. Ein leerer Name: „Gib deinen Namen ein.“ Eine E-Mail ohne
@: „Gib deine E-Mail-Adresse ein.“ Eine leere Adresse: „Gib deine Adresse ein.“ Gibt es einen Fehler, geht keine Anfrage raus. - Die Anfrage.
placeOrder({ name, email, address, items }), wobeiitemsaus dem Warenkorb[{ productId, qty }]ist. - Die Antwort. Bei 201 sagt die Seite zum Beispiel „Bestellung Nr. 61 über 15,80 €.“, und der Warenkorb wird geleert. Bei 400 ist
err.errorsein Objekt pro Feld, zum Beispiel{ email: "Die E-Mail-Adresse ist ungültig" }, und jede Meldung kommt neben ihr Feld.
Field zeigt error schon neben dem Feld, mit aria-invalid und aria-describedby. Das Formular hat noValidate, damit deine Meldungen sprechen und nicht die Sprechblasen des Browsers. Die Prüfung im Browser ist eine Bequemlichkeit: Sie fängt die leeren Felder ab, bevor eine Anfrage rausgeht. Der Server hat das letzte Wort, deshalb geht „maria@shop“ im Browser durch, und der Server schickt es zurück. Wie man ein Formular baut, steht in der Formular-Lektion.
Schreib CheckoutPage. Die Prüfung legt zwei Weißkäse hinein, klickt auf „Bestellen“ mit einem leeren Formular, dann mit der E-Mail „maria@shop“ (der Server lehnt sie ab) und zuletzt mit einer gültigen E-Mail.
Konsole
Lösung
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';
// Die Sandbox hat keine Adressleiste, deshalb liegt der Verlauf im Speicher.
// Im echten Projekt: 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>Der Shop</strong>
<nav aria-label="Hauptmenü">
<NavLink to="/" end>Katalog</NavLink>
<NavLink to="/cart">Warenkorb ({cart.count})</NavLink>
<NavLink to="/orders">Meine Bestellungen</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>Katalog</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Lädt…</p>}
{products && products.length === 0 && <p>Keine passenden Produkte.</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 ? 'Kein solches Produkt.' : e.message);
});
return () => controller.abort();
}, [id]);
if (error) return <p role="alert">{error}</p>;
if (!product) return <p role="status">Lädt…</p>;
const inCart = cart.items.find((i) => i.id === product.id)?.qty ?? 0;
return (
<article>
<p>
<Link to="/">← Zurück zum Katalog</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 ? `Auf Lager: ${product.stock} Stk.` : 'Ausverkauft'}</p>
<button type="button" disabled={product.stock === 0} onClick={() => cart.add(product)}>
In den Warenkorb
</button>
{inCart > 0 && <p role="status">Im Warenkorb: {inCart}</p>}
{related.length > 0 && (
<section aria-labelledby="related-title">
<h2 id="related-title">Mehr aus dieser Kategorie</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>Warenkorb</h1>
<p>Der Warenkorb ist leer.</p>
<p>
<Link to="/">Zurück zum Katalog</Link>
</p>
</>
);
return (
<>
<h1>Warenkorb</h1>
<ul className="cart">
{cart.items.map((item) => (
<li key={item.id}>
<span>{item.name}</span>
<select
aria-label={`Menge: ${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={`Entfernen ${item.name}`} onClick={() => cart.remove(item.id)}>
Entfernen
</button>
</li>
))}
</ul>
<p className="price">
Gesamt: {money(cart.total)}
</p>
<Link to="/checkout">Zur Kasse</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 = 'Gib deinen Namen ein.';
if (!form.email.includes('@')) errors.email = 'Gib deine E-Mail-Adresse ein.';
if (!form.address.trim()) errors.address = 'Gib deine Adresse ein.';
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>Bestellung eingegangen</h1>
<p role="status">
Bestellung Nr. {order.id} über {money(order.total)}.
</p>
<p>
<Link to="/orders">Meine Bestellungen</Link>
</p>
</>
);
if (cart.items.length === 0)
return (
<>
<h1>Kasse</h1>
<p>Der Warenkorb ist leer.</p>
</>
);
return (
<>
<h1>Kasse</h1>
<p className="price">
Gesamt: {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="E-Mail" type="email" value={form.email} error={errors.email} onChange={change} />
<Field name="address" label="Adresse" 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 ? 'Wird gesendet…' : 'Bestellen'}</button>
</form>
</>
);
}
pages/OrdersPage.jsxexport default function OrdersPage() {
return (
<>
<h1>Meine Bestellungen</h1>
<p>Hier kommen deine Bestellungen hin.</p>
</>
);
}
pages/NotFoundPage.jsximport { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<h1>Diese Seite gibt es nicht</h1>
<p>
<Link to="/">Zurück zum Katalog</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 }) {
// Die Produkte liegen in den Unterkategorien: „Lebensmittel“ und „Haushalt“ sind nur Gruppen.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Suche
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Kategorie
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">Alle</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// Alle Anfragen an die API des Shops stehen hier: Die Seiten kennen die Adressen nicht.
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 ?? `Der Server antwortete ${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),
});
}
// Die Lern-API hat keine Anmeldung und liefert die Bestellungen aller Kunden. Du bist Kunde 1.
// Ein echter Server liefert nur deine: Er weiß aus der Sitzung, wer du bist.
export const ME = 1;
export function getOrders(signal) {
return request('/api/orders', { signal });
}
format.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export const date = (iso) => new Date(iso).toLocaleDateString('de-DE');
const STATUS = { new: 'Neu', paid: 'Bezahlt', shipped: 'Versendet', delivered: 'Geliefert' };
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; }
Die Prüfung findet einen Serverfehler, der über dem Formular statt neben dem Feld steht; items: cart.items, wobei der Server kein productId findet; und einen Warenkorb, der vor der Antwort geleert wird: Bei 400 verschwindet das Formular, und der Warenkorb des Käufers mit ihm.
Übung
Schritt 5: meine Bestellungen und der ganze Weg
OrdersPage lädt bei jedem Öffnen getOrders() und zeigt die Bestellungen des Kunden ME, die neueste oben: „Bestellung Nr. 61“, das Datum, den Status, die Summe und die Zeilen („Weißkäse × 3“). Solange sie wartet, „Lädt…“, und ohne Bestellungen „Du hast noch keine Bestellungen.“
Die Lern-API hat keine Anmeldung und liefert die Bestellungen jedes Kunden. Deshalb filtert die Seite nach customerId === ME. Ein echter Server liefert nur deine.
Jetzt ist die App ganz. Die Prüfung geht den Weg vom Anfang der Lektion: Sie sieht sich die alten Bestellungen an, filtert „Milchprodukte“, öffnet den Weißkäse, legt 2 hinein, macht im Warenkorb 3 daraus, bestellt mit einer falschen E-Mail, sieht den Fehler neben dem Feld, korrigiert ihn, bestellt und findet die neue Bestellung oben bei „Meine Bestellungen“.
Schreib OrdersPage. Die Prüfung geht den ganzen Weg des Käufers und sucht am Ende die neue Bestellung oben bei „Meine Bestellungen“.
Konsole
Lösung
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';
// Die Sandbox hat keine Adressleiste, deshalb liegt der Verlauf im Speicher.
// Im echten Projekt: 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>Der Shop</strong>
<nav aria-label="Hauptmenü">
<NavLink to="/" end>Katalog</NavLink>
<NavLink to="/cart">Warenkorb ({cart.count})</NavLink>
<NavLink to="/orders">Meine Bestellungen</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>Katalog</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Lädt…</p>}
{products && products.length === 0 && <p>Keine passenden Produkte.</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 ? 'Kein solches Produkt.' : e.message);
});
return () => controller.abort();
}, [id]);
if (error) return <p role="alert">{error}</p>;
if (!product) return <p role="status">Lädt…</p>;
const inCart = cart.items.find((i) => i.id === product.id)?.qty ?? 0;
return (
<article>
<p>
<Link to="/">← Zurück zum Katalog</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 ? `Auf Lager: ${product.stock} Stk.` : 'Ausverkauft'}</p>
<button type="button" disabled={product.stock === 0} onClick={() => cart.add(product)}>
In den Warenkorb
</button>
{inCart > 0 && <p role="status">Im Warenkorb: {inCart}</p>}
{related.length > 0 && (
<section aria-labelledby="related-title">
<h2 id="related-title">Mehr aus dieser Kategorie</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>Warenkorb</h1>
<p>Der Warenkorb ist leer.</p>
<p>
<Link to="/">Zurück zum Katalog</Link>
</p>
</>
);
return (
<>
<h1>Warenkorb</h1>
<ul className="cart">
{cart.items.map((item) => (
<li key={item.id}>
<span>{item.name}</span>
<select
aria-label={`Menge: ${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={`Entfernen ${item.name}`} onClick={() => cart.remove(item.id)}>
Entfernen
</button>
</li>
))}
</ul>
<p className="price">
Gesamt: {money(cart.total)}
</p>
<Link to="/checkout">Zur Kasse</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 = 'Gib deinen Namen ein.';
if (!form.email.includes('@')) errors.email = 'Gib deine E-Mail-Adresse ein.';
if (!form.address.trim()) errors.address = 'Gib deine Adresse ein.';
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>Bestellung eingegangen</h1>
<p role="status">
Bestellung Nr. {order.id} über {money(order.total)}.
</p>
<p>
<Link to="/orders">Meine Bestellungen</Link>
</p>
</>
);
if (cart.items.length === 0)
return (
<>
<h1>Kasse</h1>
<p>Der Warenkorb ist leer.</p>
</>
);
return (
<>
<h1>Kasse</h1>
<p className="price">
Gesamt: {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="E-Mail" type="email" value={form.email} error={errors.email} onChange={change} />
<Field name="address" label="Adresse" 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 ? 'Wird gesendet…' : 'Bestellen'}</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">Meine Bestellungen</h1>
{error && <p role="alert">{error}</p>}
{!error && !orders && <p role="status">Lädt…</p>}
{orders && orders.length === 0 && <p>Du hast noch keine Bestellungen.</p>}
{orders && orders.length > 0 && (
<ul className="orders" aria-labelledby="orders-title">
{orders.map((o) => (
<li key={o.id}>
<h2>Bestellung Nr. {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>Diese Seite gibt es nicht</h1>
<p>
<Link to="/">Zurück zum Katalog</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 }) {
// Die Produkte liegen in den Unterkategorien: „Lebensmittel“ und „Haushalt“ sind nur Gruppen.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Suche
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Kategorie
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">Alle</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// Alle Anfragen an die API des Shops stehen hier: Die Seiten kennen die Adressen nicht.
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 ?? `Der Server antwortete ${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),
});
}
// Die Lern-API hat keine Anmeldung und liefert die Bestellungen aller Kunden. Du bist Kunde 1.
// Ein echter Server liefert nur deine: Er weiß aus der Sitzung, wer du bist.
export const ME = 1;
export function getOrders(signal) {
return request('/api/orders', { signal });
}
format.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export const date = (iso) => new Date(iso).toLocaleDateString('de-DE');
const STATUS = { new: 'Neu', paid: 'Bezahlt', shipped: 'Versendet', delivered: 'Geliefert' };
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; }
Die Prüfung findet eine api.js, die die erste Antwort von /api/orders behält und nicht noch einmal fragt (die neue Bestellung erscheint nicht), eine Liste ohne Sortierung (die neue steht unten) und eine Liste ohne den Kundenfilter (60 Bestellungen anderer Leute).
Die Idee
Was hier vereinfacht ist
Die App ist echt, aber manches drumherum ist vereinfacht. In einem echten Shop:
- Der Server weiß, wer du bist. Du meldest dich mit einem Passwort an, und der Server liefert nur deine Bestellungen, anhand der Sitzung. Ein Filter im Browser schützt nichts: Jeder kann
MEändern. - Der Preis liegt auf dem Server. Die Bestellung schickt nur
productIdundqty. Die Summe kommt in der Antwort zurück, berechnet aus den Preisen in der Datenbank. - Der Warenkorb bleibt erhalten. Hier liegt er im Speicher und ist beim Neuladen weg. Ein echter liegt in
localStorageoder auf dem Server. - Die Adressen sind echt.
BrowserRouterstatt des Verlaufs im Speicher, und ein Hosting, das für jede Adresseindex.htmlliefert.
Genauer, für Neugierige
Die zwei Bibliotheken. Der Weg ist derselbe: Adressen, Daten aus einer API, ein gemeinsamer Warenkorb, ein Formular mit Fehlern pro Feld. React macht es mit Hooks: useEffect mit Abhängigkeiten, Context für den Warenkorb, kontrollierte Felder mit value und onChange. Die andere Bibliothek löst dieselben Aufgaben auf ihre Weise.
Tests. Die Prüfungen der Schritte klicken und tippen wie ein Mensch. Dasselbe schreibst du mit Testing Library: render, screen.getByRole, userEvent. Wie das geht, steht in der Test-Lektion.
Geschwindigkeit. Der Katalog lädt bei jedem Tastendruck. Mit abgebrochener alter Anfrage ist das korrekt, aber verschwenderisch. In einem echten Projekt wartet die Suche 300 ms Ruhe ab, bevor sie rausgeht (Debounce).
Die Idee
Wie es weitergeht
Bring das Schaufenster in ein echtes Projekt, wie in der Lektion „Von der Sandbox zum Projekt“:
npm create vite@latest shop -- --template reactcd shopnpm install react-routerKopiere die Ordner pages/, components/, store/ und die Dateien api.js, format.js, style.css nach src/. Tausche in main.jsx MemoryRouter gegen BrowserRouter.
Die API ist nicht mehr nur gespielt. Starte einen echten Server mit den Adressen /api/products, /api/orders, oder leite /api mit server.proxy in vite.config.js darauf um. Dann npm run build und dist/ veröffentlichen, mit einer Regel, die für jede Adresse index.html liefert.
Weitere Aufgaben
- Bestelldetails.
/orders/:idmitGET /api/orders/:idund einem Link von „Meine Bestellungen“. Eine unbekannte Nummer zeigt „Keine solche Bestellung.“ - Ein Warenkorb, der erhalten bleibt. Speichere
itemsinlocalStorageund lies sie beim Start. - Tests. Ein Test für
CartPage: Eine Mengenänderung ändert die Summe. Ein Test fürCheckoutPage: Ein leeres Formular zeigt die drei Fehler und sendet keine Anfrage.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Gemeinsamer Zustand · Routing · Komponenten testen · Von der Sandbox zum Projekt
- Aufgaben zum Thema: Projekt: Das Schaufenster des Shops