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 router.js mit createMemoryHistory(), und main.js setzt createPinia() und router auf die App. Die Seite kommt in <RouterView /> in App.vue, wie in der Routing-Lektion.
Die Sandbox hat keine Adressleiste, deshalb liegt der Verlauf im Speicher. In einem echten Projekt ist es createWebHistory(). 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. Die Anfrage steht in
watch([q, category], …, { immediate: true }), nicht inonMounted. - 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 imonCleanupdeswatch.
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.jsimport { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router.js';
import './style.css';
createApp(App).use(createPinia()).use(router).mount('#app');
router.jsimport { createMemoryHistory, createRouter } from 'vue-router';
import CartPage from './pages/CartPage.vue';
import CatalogPage from './pages/CatalogPage.vue';
import CheckoutPage from './pages/CheckoutPage.vue';
import NotFoundPage from './pages/NotFoundPage.vue';
import OrdersPage from './pages/OrdersPage.vue';
import ProductPage from './pages/ProductPage.vue';
export const router = createRouter({
// Die Sandbox hat keine Adressleiste, deshalb liegt der Verlauf im Speicher.
// Im echten Projekt: createWebHistory().
history: createMemoryHistory(),
routes: [
{ path: '/', component: CatalogPage },
{ path: '/product/:id', component: ProductPage },
{ path: '/cart', component: CartPage },
{ path: '/checkout', component: CheckoutPage },
{ path: '/orders', component: OrdersPage },
{ path: '/:pathMatch(.*)*', component: NotFoundPage },
],
});
App.vue<script setup>
import { RouterLink, RouterView } from 'vue-router';
import { useCart } from './store/cart.js';
const cart = useCart();
</script>
<template>
<header>
<strong>Der Shop</strong>
<nav aria-label="Hauptmenü">
<RouterLink to="/">Katalog</RouterLink>
<RouterLink to="/cart">Warenkorb ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">Meine Bestellungen</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Katalog</h1>
<Filter v-model:q="q" v-model:category="category" :categories="categories" />
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!products" role="status">Lädt…</p>
<p v-else-if="products.length === 0">Keine passenden Produkte.</p>
<ul v-else class="grid">
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</ul>
</template>
pages/ProductPage.vue<template>
<h1>Produkt</h1>
<p>Hier kommt das Produkt hin.</p>
</template>
pages/CartPage.vue<template>
<h1>Warenkorb</h1>
<p>Hier kommt der Warenkorb hin.</p>
</template>
pages/CheckoutPage.vue<template>
<h1>Kasse</h1>
<p>Hier kommt die Bestellung hin.</p>
</template>
pages/OrdersPage.vue<template>
<h1>Meine Bestellungen</h1>
<p>Hier kommen deine Bestellungen hin.</p>
</template>
pages/NotFoundPage.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>Diese Seite gibt es nicht</h1>
<p>
<RouterLink to="/">Zurück zum Katalog</RouterLink>
</p>
</template>
components/ProductCard.vue<script setup>
import { money } from '../format.js';
defineProps(['product']);
</script>
<template>
<li class="card">
<h2>{{ product.name }}</h2>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
</li>
</template>
components/Filter.vue<script setup>
import { computed } from 'vue';
const props = defineProps(['categories']);
const q = defineModel('q');
const category = defineModel('category');
// Die Produkte liegen in den Unterkategorien: „Lebensmittel“ und „Haushalt“ sind nur Gruppen.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Suche
<input v-model="q" type="search" />
</label>
<label>
Kategorie
<select v-model="category">
<option value="">Alle</option>
<option v-for="c in groups" :key="c.id" :value="String(c.id)">{{ c.name }}</option>
</select>
</label>
</form>
</template>
components/Field.vue<script setup>
const value = defineModel();
defineProps({
name: String,
label: String,
type: { type: String, default: 'text' },
error: String,
});
</script>
<template>
<div class="field">
<label :for="name">{{ label }}</label>
<input
:id="name"
v-model="value"
:name="name"
:type="type"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error ? `${name}-error` : undefined"
/>
<span v-if="error" :id="`${name}-error`" class="error">{{ error }}</span>
</div>
</template>
store/cart.js// Der Warenkorb kommt in Schritt 3. Bis dahin ist er leer und ändert sich nicht.
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 watch 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:
<RouterLink :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. Die Anfrage steht inwatch(() => route.params.id, …, { immediate: true }). - 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.jsimport { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router.js';
import './style.css';
createApp(App).use(createPinia()).use(router).mount('#app');
router.jsimport { createMemoryHistory, createRouter } from 'vue-router';
import CartPage from './pages/CartPage.vue';
import CatalogPage from './pages/CatalogPage.vue';
import CheckoutPage from './pages/CheckoutPage.vue';
import NotFoundPage from './pages/NotFoundPage.vue';
import OrdersPage from './pages/OrdersPage.vue';
import ProductPage from './pages/ProductPage.vue';
export const router = createRouter({
// Die Sandbox hat keine Adressleiste, deshalb liegt der Verlauf im Speicher.
// Im echten Projekt: createWebHistory().
history: createMemoryHistory(),
routes: [
{ path: '/', component: CatalogPage },
{ path: '/product/:id', component: ProductPage },
{ path: '/cart', component: CartPage },
{ path: '/checkout', component: CheckoutPage },
{ path: '/orders', component: OrdersPage },
{ path: '/:pathMatch(.*)*', component: NotFoundPage },
],
});
App.vue<script setup>
import { RouterLink, RouterView } from 'vue-router';
import { useCart } from './store/cart.js';
const cart = useCart();
</script>
<template>
<header>
<strong>Der Shop</strong>
<nav aria-label="Hauptmenü">
<RouterLink to="/">Katalog</RouterLink>
<RouterLink to="/cart">Warenkorb ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">Meine Bestellungen</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Katalog</h1>
<Filter v-model:q="q" v-model:category="category" :categories="categories" />
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!products" role="status">Lädt…</p>
<p v-else-if="products.length === 0">Keine passenden Produkte.</p>
<ul v-else class="grid">
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</ul>
</template>
pages/ProductPage.vue<script setup>
import { watch, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
const route = useRoute();
const product = ref(null);
const related = ref([]);
const error = ref(null);
async function load(id, signal) {
const p = await getProduct(id, signal);
product.value = p;
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, signal);
related.value = same.filter((x) => x.id !== p.id).slice(0, 3);
}
watch(
() => route.params.id,
(id, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
product.value = null;
related.value = [];
error.value = null;
load(id, controller.signal).catch((e) => {
if (e.name !== 'AbortError') error.value = e.status === 404 ? 'Kein solches Produkt.' : e.message;
});
},
{ immediate: true },
);
</script>
<template>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!product" role="status">Lädt…</p>
<article v-else>
<p>
<RouterLink to="/">← Zurück zum Katalog</RouterLink>
</p>
<h1>{{ product.name }}</h1>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
<p v-if="product.stock > 0">Auf Lager: {{ product.stock }} Stk.</p>
<p v-else>Ausverkauft</p>
<section v-if="related.length > 0" aria-labelledby="related-title">
<h2 id="related-title">Mehr aus dieser Kategorie</h2>
<ul>
<li v-for="p in related" :key="p.id">
<RouterLink :to="`/product/${p.id}`">{{ p.name }}</RouterLink>
</li>
</ul>
</section>
</article>
</template>
pages/CartPage.vue<template>
<h1>Warenkorb</h1>
<p>Hier kommt der Warenkorb hin.</p>
</template>
pages/CheckoutPage.vue<template>
<h1>Kasse</h1>
<p>Hier kommt die Bestellung hin.</p>
</template>
pages/OrdersPage.vue<template>
<h1>Meine Bestellungen</h1>
<p>Hier kommen deine Bestellungen hin.</p>
</template>
pages/NotFoundPage.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>Diese Seite gibt es nicht</h1>
<p>
<RouterLink to="/">Zurück zum Katalog</RouterLink>
</p>
</template>
components/ProductCard.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
defineProps(['product']);
</script>
<template>
<li class="card">
<h2>
<RouterLink :to="`/product/${product.id}`">{{ product.name }}</RouterLink>
</h2>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
</li>
</template>
components/Filter.vue<script setup>
import { computed } from 'vue';
const props = defineProps(['categories']);
const q = defineModel('q');
const category = defineModel('category');
// Die Produkte liegen in den Unterkategorien: „Lebensmittel“ und „Haushalt“ sind nur Gruppen.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Suche
<input v-model="q" type="search" />
</label>
<label>
Kategorie
<select v-model="category">
<option value="">Alle</option>
<option v-for="c in groups" :key="c.id" :value="String(c.id)">{{ c.name }}</option>
</select>
</label>
</form>
</template>
components/Field.vue<script setup>
const value = defineModel();
defineProps({
name: String,
label: String,
type: { type: String, default: 'text' },
error: String,
});
</script>
<template>
<div class="field">
<label :for="name">{{ label }}</label>
<input
:id="name"
v-model="value"
:name="name"
:type="type"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error ? `${name}-error` : undefined"
/>
<span v-if="error" :id="`${name}-error`" class="error">{{ error }}</span>
</div>
</template>
store/cart.js// Der Warenkorb kommt in Schritt 3. Bis dahin ist er leer und ändert sich nicht.
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.js ist ein Pinia-Store, erzeugt mit defineStore('cart', () => { … }), wie in der Lektion über gemeinsamen Zustand. Er gibt items, count, total, add, setQty, remove, clear zurück. count und total sind computed aus items, nicht getrennt gehalten.
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.jsimport { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router.js';
import './style.css';
createApp(App).use(createPinia()).use(router).mount('#app');
router.jsimport { createMemoryHistory, createRouter } from 'vue-router';
import CartPage from './pages/CartPage.vue';
import CatalogPage from './pages/CatalogPage.vue';
import CheckoutPage from './pages/CheckoutPage.vue';
import NotFoundPage from './pages/NotFoundPage.vue';
import OrdersPage from './pages/OrdersPage.vue';
import ProductPage from './pages/ProductPage.vue';
export const router = createRouter({
// Die Sandbox hat keine Adressleiste, deshalb liegt der Verlauf im Speicher.
// Im echten Projekt: createWebHistory().
history: createMemoryHistory(),
routes: [
{ path: '/', component: CatalogPage },
{ path: '/product/:id', component: ProductPage },
{ path: '/cart', component: CartPage },
{ path: '/checkout', component: CheckoutPage },
{ path: '/orders', component: OrdersPage },
{ path: '/:pathMatch(.*)*', component: NotFoundPage },
],
});
App.vue<script setup>
import { RouterLink, RouterView } from 'vue-router';
import { useCart } from './store/cart.js';
const cart = useCart();
</script>
<template>
<header>
<strong>Der Shop</strong>
<nav aria-label="Hauptmenü">
<RouterLink to="/">Katalog</RouterLink>
<RouterLink to="/cart">Warenkorb ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">Meine Bestellungen</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Katalog</h1>
<Filter v-model:q="q" v-model:category="category" :categories="categories" />
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!products" role="status">Lädt…</p>
<p v-else-if="products.length === 0">Keine passenden Produkte.</p>
<ul v-else class="grid">
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</ul>
</template>
pages/ProductPage.vue<script setup>
import { computed, watch, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const route = useRoute();
const cart = useCart();
const product = ref(null);
const related = ref([]);
const error = ref(null);
const inCart = computed(() => cart.items.find((i) => i.id === product.value?.id)?.qty ?? 0);
async function load(id, signal) {
const p = await getProduct(id, signal);
product.value = p;
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, signal);
related.value = same.filter((x) => x.id !== p.id).slice(0, 3);
}
watch(
() => route.params.id,
(id, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
product.value = null;
related.value = [];
error.value = null;
load(id, controller.signal).catch((e) => {
if (e.name !== 'AbortError') error.value = e.status === 404 ? 'Kein solches Produkt.' : e.message;
});
},
{ immediate: true },
);
</script>
<template>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!product" role="status">Lädt…</p>
<article v-else>
<p>
<RouterLink to="/">← Zurück zum Katalog</RouterLink>
</p>
<h1>{{ product.name }}</h1>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
<p v-if="product.stock > 0">Auf Lager: {{ product.stock }} Stk.</p>
<p v-else>Ausverkauft</p>
<button type="button" :disabled="product.stock === 0" @click="cart.add(product)">
In den Warenkorb
</button>
<p v-if="inCart > 0" role="status">Im Warenkorb: {{ inCart }}</p>
<section v-if="related.length > 0" aria-labelledby="related-title">
<h2 id="related-title">Mehr aus dieser Kategorie</h2>
<ul>
<li v-for="p in related" :key="p.id">
<RouterLink :to="`/product/${p.id}`">{{ p.name }}</RouterLink>
</li>
</ul>
</section>
</article>
</template>
pages/CartPage.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const cart = useCart();
const upTo = (n) => Array.from({ length: Math.min(n, 10) }, (_, i) => i + 1);
</script>
<template>
<h1>Warenkorb</h1>
<template v-if="cart.items.length === 0">
<p>Der Warenkorb ist leer.</p>
<p>
<RouterLink to="/">Zurück zum Katalog</RouterLink>
</p>
</template>
<template v-else>
<ul class="cart">
<li v-for="item in cart.items" :key="item.id">
<span>{{ item.name }}</span>
<select
:aria-label="`Menge: ${item.name}`"
:value="item.qty"
@change="cart.setQty(item.id, Number($event.target.value))"
>
<option v-for="n in upTo(item.stock)" :key="n">{{ n }}</option>
</select>
<span>{{ money(item.price * item.qty) }}</span>
<button type="button" :aria-label="`Entfernen ${item.name}`" @click="cart.remove(item.id)">
Entfernen
</button>
</li>
</ul>
<p class="price">Gesamt: {{ money(cart.total) }}</p>
<RouterLink to="/checkout">Zur Kasse</RouterLink>
</template>
</template>
pages/CheckoutPage.vue<template>
<h1>Kasse</h1>
<p>Hier kommt die Bestellung hin.</p>
</template>
pages/OrdersPage.vue<template>
<h1>Meine Bestellungen</h1>
<p>Hier kommen deine Bestellungen hin.</p>
</template>
pages/NotFoundPage.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>Diese Seite gibt es nicht</h1>
<p>
<RouterLink to="/">Zurück zum Katalog</RouterLink>
</p>
</template>
components/ProductCard.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
defineProps(['product']);
</script>
<template>
<li class="card">
<h2>
<RouterLink :to="`/product/${product.id}`">{{ product.name }}</RouterLink>
</h2>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
</li>
</template>
components/Filter.vue<script setup>
import { computed } from 'vue';
const props = defineProps(['categories']);
const q = defineModel('q');
const category = defineModel('category');
// Die Produkte liegen in den Unterkategorien: „Lebensmittel“ und „Haushalt“ sind nur Gruppen.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Suche
<input v-model="q" type="search" />
</label>
<label>
Kategorie
<select v-model="category">
<option value="">Alle</option>
<option v-for="c in groups" :key="c.id" :value="String(c.id)">{{ c.name }}</option>
</select>
</label>
</form>
</template>
components/Field.vue<script setup>
const value = defineModel();
defineProps({
name: String,
label: String,
type: { type: String, default: 'text' },
error: String,
});
</script>
<template>
<div class="field">
<label :for="name">{{ label }}</label>
<input
:id="name"
v-model="value"
:name="name"
:type="type"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error ? `${name}-error` : undefined"
/>
<span v-if="error" :id="`${name}-error`" class="error">{{ error }}</span>
</div>
</template>
store/cart.jsimport { computed, ref } from 'vue';
import { defineStore } from 'pinia';
export const useCart = defineStore('cart', () => {
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.qty, 0));
function add(product) {
const found = items.value.find((i) => i.id === product.id);
if (found) found.qty += 1;
else items.value.push({ id: product.id, name: product.name, price: product.price, stock: product.stock, qty: 1 });
}
function setQty(id, qty) {
const found = items.value.find((i) => i.id === id);
if (found) found.qty = qty;
}
function remove(id) {
items.value = items.value.filter((i) => i.id !== id);
}
function clear() {
items.value = [];
}
return { items, count, total, add, setQty, remove, clear };
});
api.js// 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 Pinia, als Funktion mit ref([]) (jede Komponente hat ihren eigenen Warenkorb, und das Menü sagt 0), und eine Summe in einem eigenen ref, die das Ändern einer Menge nicht aktualisiert.Ü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.jsimport { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router.js';
import './style.css';
createApp(App).use(createPinia()).use(router).mount('#app');
router.jsimport { createMemoryHistory, createRouter } from 'vue-router';
import CartPage from './pages/CartPage.vue';
import CatalogPage from './pages/CatalogPage.vue';
import CheckoutPage from './pages/CheckoutPage.vue';
import NotFoundPage from './pages/NotFoundPage.vue';
import OrdersPage from './pages/OrdersPage.vue';
import ProductPage from './pages/ProductPage.vue';
export const router = createRouter({
// Die Sandbox hat keine Adressleiste, deshalb liegt der Verlauf im Speicher.
// Im echten Projekt: createWebHistory().
history: createMemoryHistory(),
routes: [
{ path: '/', component: CatalogPage },
{ path: '/product/:id', component: ProductPage },
{ path: '/cart', component: CartPage },
{ path: '/checkout', component: CheckoutPage },
{ path: '/orders', component: OrdersPage },
{ path: '/:pathMatch(.*)*', component: NotFoundPage },
],
});
App.vue<script setup>
import { RouterLink, RouterView } from 'vue-router';
import { useCart } from './store/cart.js';
const cart = useCart();
</script>
<template>
<header>
<strong>Der Shop</strong>
<nav aria-label="Hauptmenü">
<RouterLink to="/">Katalog</RouterLink>
<RouterLink to="/cart">Warenkorb ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">Meine Bestellungen</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Katalog</h1>
<Filter v-model:q="q" v-model:category="category" :categories="categories" />
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!products" role="status">Lädt…</p>
<p v-else-if="products.length === 0">Keine passenden Produkte.</p>
<ul v-else class="grid">
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</ul>
</template>
pages/ProductPage.vue<script setup>
import { computed, watch, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const route = useRoute();
const cart = useCart();
const product = ref(null);
const related = ref([]);
const error = ref(null);
const inCart = computed(() => cart.items.find((i) => i.id === product.value?.id)?.qty ?? 0);
async function load(id, signal) {
const p = await getProduct(id, signal);
product.value = p;
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, signal);
related.value = same.filter((x) => x.id !== p.id).slice(0, 3);
}
watch(
() => route.params.id,
(id, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
product.value = null;
related.value = [];
error.value = null;
load(id, controller.signal).catch((e) => {
if (e.name !== 'AbortError') error.value = e.status === 404 ? 'Kein solches Produkt.' : e.message;
});
},
{ immediate: true },
);
</script>
<template>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!product" role="status">Lädt…</p>
<article v-else>
<p>
<RouterLink to="/">← Zurück zum Katalog</RouterLink>
</p>
<h1>{{ product.name }}</h1>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
<p v-if="product.stock > 0">Auf Lager: {{ product.stock }} Stk.</p>
<p v-else>Ausverkauft</p>
<button type="button" :disabled="product.stock === 0" @click="cart.add(product)">
In den Warenkorb
</button>
<p v-if="inCart > 0" role="status">Im Warenkorb: {{ inCart }}</p>
<section v-if="related.length > 0" aria-labelledby="related-title">
<h2 id="related-title">Mehr aus dieser Kategorie</h2>
<ul>
<li v-for="p in related" :key="p.id">
<RouterLink :to="`/product/${p.id}`">{{ p.name }}</RouterLink>
</li>
</ul>
</section>
</article>
</template>
pages/CartPage.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const cart = useCart();
const upTo = (n) => Array.from({ length: Math.min(n, 10) }, (_, i) => i + 1);
</script>
<template>
<h1>Warenkorb</h1>
<template v-if="cart.items.length === 0">
<p>Der Warenkorb ist leer.</p>
<p>
<RouterLink to="/">Zurück zum Katalog</RouterLink>
</p>
</template>
<template v-else>
<ul class="cart">
<li v-for="item in cart.items" :key="item.id">
<span>{{ item.name }}</span>
<select
:aria-label="`Menge: ${item.name}`"
:value="item.qty"
@change="cart.setQty(item.id, Number($event.target.value))"
>
<option v-for="n in upTo(item.stock)" :key="n">{{ n }}</option>
</select>
<span>{{ money(item.price * item.qty) }}</span>
<button type="button" :aria-label="`Entfernen ${item.name}`" @click="cart.remove(item.id)">
Entfernen
</button>
</li>
</ul>
<p class="price">Gesamt: {{ money(cart.total) }}</p>
<RouterLink to="/checkout">Zur Kasse</RouterLink>
</template>
</template>
pages/CheckoutPage.vue<script setup>
import { reactive, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { placeOrder } from '../api.js';
import Field from '../components/Field.vue';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const cart = useCart();
const form = reactive({ name: '', email: '', address: '' });
const errors = ref({});
const sending = ref(false);
const order = ref(null);
function check() {
const found = {};
if (!form.name.trim()) found.name = 'Gib deinen Namen ein.';
if (!form.email.includes('@')) found.email = 'Gib deine E-Mail-Adresse ein.';
if (!form.address.trim()) found.address = 'Gib deine Adresse ein.';
return found;
}
async function submit() {
errors.value = check();
if (Object.keys(errors.value).length > 0) return;
sending.value = true;
try {
const items = cart.items.map((i) => ({ productId: i.id, qty: i.qty }));
order.value = await placeOrder({ ...form, items });
cart.clear();
} catch (err) {
errors.value = err.errors ?? { form: err.message };
} finally {
sending.value = false;
}
}
</script>
<template>
<template v-if="order">
<h1>Bestellung eingegangen</h1>
<p role="status">Bestellung Nr. {{ order.id }} über {{ money(order.total) }}.</p>
<p>
<RouterLink to="/orders">Meine Bestellungen</RouterLink>
</p>
</template>
<template v-else-if="cart.items.length === 0">
<h1>Kasse</h1>
<p>Der Warenkorb ist leer.</p>
</template>
<template v-else>
<h1>Kasse</h1>
<p class="price">Gesamt: {{ money(cart.total) }}</p>
<form novalidate @submit.prevent="submit">
<Field v-model="form.name" name="name" label="Name" :error="errors.name" />
<Field v-model="form.email" name="email" label="E-Mail" type="email" :error="errors.email" />
<Field v-model="form.address" name="address" label="Adresse" :error="errors.address" />
<p v-if="errors.items" role="alert">{{ errors.items }}</p>
<p v-if="errors.form" role="alert">{{ errors.form }}</p>
<button :disabled="sending">{{ sending ? 'Wird gesendet…' : 'Bestellen' }}</button>
</form>
</template>
</template>
pages/OrdersPage.vue<template>
<h1>Meine Bestellungen</h1>
<p>Hier kommen deine Bestellungen hin.</p>
</template>
pages/NotFoundPage.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>Diese Seite gibt es nicht</h1>
<p>
<RouterLink to="/">Zurück zum Katalog</RouterLink>
</p>
</template>
components/ProductCard.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
defineProps(['product']);
</script>
<template>
<li class="card">
<h2>
<RouterLink :to="`/product/${product.id}`">{{ product.name }}</RouterLink>
</h2>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
</li>
</template>
components/Filter.vue<script setup>
import { computed } from 'vue';
const props = defineProps(['categories']);
const q = defineModel('q');
const category = defineModel('category');
// Die Produkte liegen in den Unterkategorien: „Lebensmittel“ und „Haushalt“ sind nur Gruppen.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Suche
<input v-model="q" type="search" />
</label>
<label>
Kategorie
<select v-model="category">
<option value="">Alle</option>
<option v-for="c in groups" :key="c.id" :value="String(c.id)">{{ c.name }}</option>
</select>
</label>
</form>
</template>
components/Field.vue<script setup>
const value = defineModel();
defineProps({
name: String,
label: String,
type: { type: String, default: 'text' },
error: String,
});
</script>
<template>
<div class="field">
<label :for="name">{{ label }}</label>
<input
:id="name"
v-model="value"
:name="name"
:type="type"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error ? `${name}-error` : undefined"
/>
<span v-if="error" :id="`${name}-error`" class="error">{{ error }}</span>
</div>
</template>
store/cart.jsimport { computed, ref } from 'vue';
import { defineStore } from 'pinia';
export const useCart = defineStore('cart', () => {
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.qty, 0));
function add(product) {
const found = items.value.find((i) => i.id === product.id);
if (found) found.qty += 1;
else items.value.push({ id: product.id, name: product.name, price: product.price, stock: product.stock, qty: 1 });
}
function setQty(id, qty) {
const found = items.value.find((i) => i.id === id);
if (found) found.qty = qty;
}
function remove(id) {
items.value = items.value.filter((i) => i.id !== id);
}
function clear() {
items.value = [];
}
return { items, count, total, add, setQty, remove, clear };
});
api.js// 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.jsimport { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router.js';
import './style.css';
createApp(App).use(createPinia()).use(router).mount('#app');
router.jsimport { createMemoryHistory, createRouter } from 'vue-router';
import CartPage from './pages/CartPage.vue';
import CatalogPage from './pages/CatalogPage.vue';
import CheckoutPage from './pages/CheckoutPage.vue';
import NotFoundPage from './pages/NotFoundPage.vue';
import OrdersPage from './pages/OrdersPage.vue';
import ProductPage from './pages/ProductPage.vue';
export const router = createRouter({
// Die Sandbox hat keine Adressleiste, deshalb liegt der Verlauf im Speicher.
// Im echten Projekt: createWebHistory().
history: createMemoryHistory(),
routes: [
{ path: '/', component: CatalogPage },
{ path: '/product/:id', component: ProductPage },
{ path: '/cart', component: CartPage },
{ path: '/checkout', component: CheckoutPage },
{ path: '/orders', component: OrdersPage },
{ path: '/:pathMatch(.*)*', component: NotFoundPage },
],
});
App.vue<script setup>
import { RouterLink, RouterView } from 'vue-router';
import { useCart } from './store/cart.js';
const cart = useCart();
</script>
<template>
<header>
<strong>Der Shop</strong>
<nav aria-label="Hauptmenü">
<RouterLink to="/">Katalog</RouterLink>
<RouterLink to="/cart">Warenkorb ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">Meine Bestellungen</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Katalog</h1>
<Filter v-model:q="q" v-model:category="category" :categories="categories" />
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!products" role="status">Lädt…</p>
<p v-else-if="products.length === 0">Keine passenden Produkte.</p>
<ul v-else class="grid">
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</ul>
</template>
pages/ProductPage.vue<script setup>
import { computed, watch, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const route = useRoute();
const cart = useCart();
const product = ref(null);
const related = ref([]);
const error = ref(null);
const inCart = computed(() => cart.items.find((i) => i.id === product.value?.id)?.qty ?? 0);
async function load(id, signal) {
const p = await getProduct(id, signal);
product.value = p;
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, signal);
related.value = same.filter((x) => x.id !== p.id).slice(0, 3);
}
watch(
() => route.params.id,
(id, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
product.value = null;
related.value = [];
error.value = null;
load(id, controller.signal).catch((e) => {
if (e.name !== 'AbortError') error.value = e.status === 404 ? 'Kein solches Produkt.' : e.message;
});
},
{ immediate: true },
);
</script>
<template>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!product" role="status">Lädt…</p>
<article v-else>
<p>
<RouterLink to="/">← Zurück zum Katalog</RouterLink>
</p>
<h1>{{ product.name }}</h1>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
<p v-if="product.stock > 0">Auf Lager: {{ product.stock }} Stk.</p>
<p v-else>Ausverkauft</p>
<button type="button" :disabled="product.stock === 0" @click="cart.add(product)">
In den Warenkorb
</button>
<p v-if="inCart > 0" role="status">Im Warenkorb: {{ inCart }}</p>
<section v-if="related.length > 0" aria-labelledby="related-title">
<h2 id="related-title">Mehr aus dieser Kategorie</h2>
<ul>
<li v-for="p in related" :key="p.id">
<RouterLink :to="`/product/${p.id}`">{{ p.name }}</RouterLink>
</li>
</ul>
</section>
</article>
</template>
pages/CartPage.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const cart = useCart();
const upTo = (n) => Array.from({ length: Math.min(n, 10) }, (_, i) => i + 1);
</script>
<template>
<h1>Warenkorb</h1>
<template v-if="cart.items.length === 0">
<p>Der Warenkorb ist leer.</p>
<p>
<RouterLink to="/">Zurück zum Katalog</RouterLink>
</p>
</template>
<template v-else>
<ul class="cart">
<li v-for="item in cart.items" :key="item.id">
<span>{{ item.name }}</span>
<select
:aria-label="`Menge: ${item.name}`"
:value="item.qty"
@change="cart.setQty(item.id, Number($event.target.value))"
>
<option v-for="n in upTo(item.stock)" :key="n">{{ n }}</option>
</select>
<span>{{ money(item.price * item.qty) }}</span>
<button type="button" :aria-label="`Entfernen ${item.name}`" @click="cart.remove(item.id)">
Entfernen
</button>
</li>
</ul>
<p class="price">Gesamt: {{ money(cart.total) }}</p>
<RouterLink to="/checkout">Zur Kasse</RouterLink>
</template>
</template>
pages/CheckoutPage.vue<script setup>
import { reactive, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { placeOrder } from '../api.js';
import Field from '../components/Field.vue';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const cart = useCart();
const form = reactive({ name: '', email: '', address: '' });
const errors = ref({});
const sending = ref(false);
const order = ref(null);
function check() {
const found = {};
if (!form.name.trim()) found.name = 'Gib deinen Namen ein.';
if (!form.email.includes('@')) found.email = 'Gib deine E-Mail-Adresse ein.';
if (!form.address.trim()) found.address = 'Gib deine Adresse ein.';
return found;
}
async function submit() {
errors.value = check();
if (Object.keys(errors.value).length > 0) return;
sending.value = true;
try {
const items = cart.items.map((i) => ({ productId: i.id, qty: i.qty }));
order.value = await placeOrder({ ...form, items });
cart.clear();
} catch (err) {
errors.value = err.errors ?? { form: err.message };
} finally {
sending.value = false;
}
}
</script>
<template>
<template v-if="order">
<h1>Bestellung eingegangen</h1>
<p role="status">Bestellung Nr. {{ order.id }} über {{ money(order.total) }}.</p>
<p>
<RouterLink to="/orders">Meine Bestellungen</RouterLink>
</p>
</template>
<template v-else-if="cart.items.length === 0">
<h1>Kasse</h1>
<p>Der Warenkorb ist leer.</p>
</template>
<template v-else>
<h1>Kasse</h1>
<p class="price">Gesamt: {{ money(cart.total) }}</p>
<form novalidate @submit.prevent="submit">
<Field v-model="form.name" name="name" label="Name" :error="errors.name" />
<Field v-model="form.email" name="email" label="E-Mail" type="email" :error="errors.email" />
<Field v-model="form.address" name="address" label="Adresse" :error="errors.address" />
<p v-if="errors.items" role="alert">{{ errors.items }}</p>
<p v-if="errors.form" role="alert">{{ errors.form }}</p>
<button :disabled="sending">{{ sending ? 'Wird gesendet…' : 'Bestellen' }}</button>
</form>
</template>
</template>
pages/OrdersPage.vue<script setup>
import { onMounted, onUnmounted, ref } from 'vue';
import { ME, getOrders } from '../api.js';
import { date, money, statusText } from '../format.js';
const orders = ref(null);
const error = ref(null);
const controller = new AbortController();
onMounted(async () => {
try {
const all = await getOrders(controller.signal);
orders.value = all.filter((o) => o.customerId === ME).sort((a, b) => b.id - a.id);
} catch (e) {
if (e.name !== 'AbortError') error.value = e.message;
}
});
onUnmounted(() => controller.abort());
</script>
<template>
<h1 id="orders-title">Meine Bestellungen</h1>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!orders" role="status">Lädt…</p>
<p v-else-if="orders.length === 0">Du hast noch keine Bestellungen.</p>
<ul v-else class="orders" aria-labelledby="orders-title">
<li v-for="o in orders" :key="o.id">
<h2>Bestellung Nr. {{ o.id }}</h2>
<p class="muted">{{ date(o.date) }} · {{ statusText(o.status) }} · {{ money(o.total) }}</p>
<p>{{ o.items.map((i) => `${i.name} × ${i.qty}`).join(', ') }}</p>
</li>
</ul>
</template>
pages/NotFoundPage.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>Diese Seite gibt es nicht</h1>
<p>
<RouterLink to="/">Zurück zum Katalog</RouterLink>
</p>
</template>
components/ProductCard.vue<script setup>
import { RouterLink } from 'vue-router';
import { money } from '../format.js';
defineProps(['product']);
</script>
<template>
<li class="card">
<h2>
<RouterLink :to="`/product/${product.id}`">{{ product.name }}</RouterLink>
</h2>
<p v-if="product.category" class="muted">{{ product.category }}</p>
<p class="price">{{ money(product.price) }}</p>
</li>
</template>
components/Filter.vue<script setup>
import { computed } from 'vue';
const props = defineProps(['categories']);
const q = defineModel('q');
const category = defineModel('category');
// Die Produkte liegen in den Unterkategorien: „Lebensmittel“ und „Haushalt“ sind nur Gruppen.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Suche
<input v-model="q" type="search" />
</label>
<label>
Kategorie
<select v-model="category">
<option value="">Alle</option>
<option v-for="c in groups" :key="c.id" :value="String(c.id)">{{ c.name }}</option>
</select>
</label>
</form>
</template>
components/Field.vue<script setup>
const value = defineModel();
defineProps({
name: String,
label: String,
type: { type: String, default: 'text' },
error: String,
});
</script>
<template>
<div class="field">
<label :for="name">{{ label }}</label>
<input
:id="name"
v-model="value"
:name="name"
:type="type"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error ? `${name}-error` : undefined"
/>
<span v-if="error" :id="`${name}-error`" class="error">{{ error }}</span>
</div>
</template>
store/cart.jsimport { computed, ref } from 'vue';
import { defineStore } from 'pinia';
export const useCart = defineStore('cart', () => {
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.qty, 0));
function add(product) {
const found = items.value.find((i) => i.id === product.id);
if (found) found.qty += 1;
else items.value.push({ id: product.id, name: product.name, price: product.price, stock: product.stock, qty: 1 });
}
function setQty(id, qty) {
const found = items.value.find((i) => i.id === id);
if (found) found.qty = qty;
}
function remove(id) {
items.value = items.value.filter((i) => i.id !== id);
}
function clear() {
items.value = [];
}
return { items, count, total, add, setQty, remove, clear };
});
api.js// 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.
createWebHistory()statt 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. Vue macht es mit Reaktivität: watch auf den Parameter und den Filter, Pinia für den Warenkorb, v-model und defineModel für die Felder. 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 vuecd shopnpm install vue-router piniaKopiere die Ordner pages/, components/, store/ und die Dateien api.js, format.js, router.js, style.css nach src/. Tausche in router.js createMemoryHistory() gegen createWebHistory().
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