Витрината на магазина
Накратко: Проект на уеб приложение: витрина на магазин с каталог от API, страница на продукта по адрес, обща кошница, поръчка с проверки и „Моите поръчки“.
Проблем
Витрината
Магазинът има API: продукти, категории, поръчки. Липсва витрината, през която купувачът пазарува. Пътят му е:
- Търси в каталога или избира категория.
- Отваря продукт и го добавя в кошницата.
- В кошницата сменя бройките.
- Поръчва: име, имейл, адрес. Ако сгреши, вижда къде.
- Вижда поръчката си в „Моите поръчки“.
Всяка част вече си я правил в отделен урок. Тук ги събираш в едно приложение и го строиш на пет стъпки: всяка задача започва от решението на предишната. Последната проверка минава целия път.
Идеята
Скелетът
Това е приложението на нулата стъпка: менюто и страниците-заглушки. Мини през менюто.
Конзола
Файловете са подредени като в истински проект:
api.jsпази всички заявки. Страниците викатgetProducts,placeOrderи не знаят адресите. Грешката от сървъра идва катоErrorсъсstatusиerrors(грешките по поле).format.jsформатира пари, дата и статус.pages/са страниците, по една за всеки адрес.components/са частите, които страниците ползват:ProductCard,Filter,Field.store/cartе кошницата, обща за цялото приложение.
Рутерът е в router.js с createMemoryHistory(), а main.js слага createPinia() и router на приложението. Страницата е в <RouterView /> на App.vue, както в урока за рутирането.
Пясъчникът няма адресна лента, затова историята е в паметта. В истинския проект е createWebHistory(). Останалото е същото.
Filter и Field са готови от уроците: полета, свързани със състояние, и поле с етикет и съобщение за грешка. Ти пишеш страниците и кошницата.
Упражнение
Стъпка 1: каталогът
Каталогът тегли продуктите от getProducts({ q, category }) и ги показва с ProductCard в списък с клас grid. Филтърът е отгоре. При всяка смяна на търсенето или категорията тръгва нова заявка, а старата се отказва. Докато чака, страницата казва „Зарежда…“. Ако няма нищо, казва „Няма такива продукти.“
Внимавай за две неща от урока за данни от API:
- Списъкът следва филтъра. Заявката е в
watch([q, category], …, { immediate: true }), не вonMounted. - Старият отговор не печели. Ако „Млечни“ отговаря бавно, а ти вече си избрал „Напитки“, бавният отговор не трябва да затрие новия.
AbortControllerотказва старата заявка вonCleanupнаwatch.
Напиши CatalogPage: продуктите от API-то по филтъра, „Зарежда…“, „Няма такива продукти.“ и отказ на старата заявка. Проверката търси „хляб“, после избира „Млечни“ и веднага „Напитки“ (Млечни отговаря за 900 ms), после търси в категория.
Конзола
Решение
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({
// Пясъчникът няма адресна лента, затова историята е в паметта.
// В истинския проект: 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>Магазинът</strong>
<nav aria-label="Главно меню">
<RouterLink to="/">Каталог</RouterLink>
<RouterLink to="/cart">Кошница ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">Моите поръчки</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Каталог</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">Зарежда…</p>
<p v-else-if="products.length === 0">Няма такива продукти.</p>
<ul v-else class="grid">
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</ul>
</template>
pages/ProductPage.vue<template>
<h1>Продукт</h1>
<p>Тук идва продуктът.</p>
</template>
pages/CartPage.vue<template>
<h1>Кошница</h1>
<p>Тук идва кошницата.</p>
</template>
pages/CheckoutPage.vue<template>
<h1>Поръчка</h1>
<p>Тук идва поръчката.</p>
</template>
pages/OrdersPage.vue<template>
<h1>Моите поръчки</h1>
<p>Тук идват поръчките ти.</p>
</template>
pages/NotFoundPage.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>Няма такава страница</h1>
<p>
<RouterLink to="/">Към каталога</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');
// Продуктите са в подкатегориите: „Храни“ и „Дом“ са само групи.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Търси
<input v-model="q" type="search" />
</label>
<label>
Категория
<select v-model="category">
<option value="">Всички</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// Кошницата идва в стъпка 3. Дотогава е празна и не се сменя.
export function useCart() {
return { items: [], count: 0, total: 0, add() {}, setQty() {}, remove() {}, clear() {} };
}
api.js// Всички заявки към API-то на магазина са тук: страниците не знаят адресите.
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 ?? `Сървърът върна ${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),
});
}
// Учебното API няма вход и връща поръчките на всички клиенти. Ти си клиент 1.
// Истинският сървър връща само твоите: знае кой си от сесията.
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('bg-BG');
const STATUS = { new: 'Нова', paid: 'Платена', shipped: 'Изпратена', delivered: 'Доставена' };
export const statusText = (status) => STATUS[status] ?? status;
style.css* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; color: #1f2328; background: #fbfaf7; }
header { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; background: #f3efe6; }
header strong { margin-right: auto; }
nav { display: flex; flex-wrap: wrap; gap: 12px; }
nav a { color: inherit; }
nav a[aria-current="page"] { font-weight: 600; text-decoration-thickness: 2px; }
main { padding: 0 16px 24px; }
h1 { font-size: 1.4rem; }
input, select, button { font: inherit; padding: 4px 8px; }
.filter { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 12px; }
.filter label { display: flex; flex-direction: column; gap: 2px; font-size: 0.9rem; }
.grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.card { background: #fff; border: 1px solid #ddd6c8; border-radius: 8px; padding: 8px 10px; }
.card h2 { font-size: 1rem; margin: 0 0 4px; }
.card p { margin: 0; }
.muted { color: #5c6168; font-size: 0.85rem; }
.price { font-weight: 600; }
.cart, .orders { list-style: none; padding: 0; }
.cart li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.cart li span:first-child { flex: 1; }
.orders li { padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.orders h2 { font-size: 1rem; margin: 0; }
.orders p { margin: 2px 0; }
.field { display: flex; flex-direction: column; gap: 2px; max-width: 320px; margin-bottom: 10px; }
.field input[aria-invalid="true"] { border-color: #b42318; }
.error { color: #b42318; font-size: 0.85rem; }
Проверката хваща три грешки: заявка само веднъж, при показването; търсене, което не е в watch; и заявка без отказ, при която отговорът за „Млечни“ идва последен и показва сирене, докато в полето пише „Напитки“.
Упражнение
Стъпка 2: страницата на продукта
Името в ProductCard става връзка към /product/:id. ProductPage взема id от адреса, тегли продукта с getProduct(id) и показва име, категория, цена и наличност: „В наличност: 18 бр.“ или „Изчерпан“. Под него е „Още от категорията“: до три други продукта от същата категория, с връзки.
Внимавай:
- Връзката е на рутера:
<RouterLink :to="…">, не<a href>. Обикновената връзка зарежда цялата страница наново. В пясъчника тя не отива никъде. - Същата страница, друг параметър. От „Сирене“ към „Кашкавал“ рутерът не прави нов
ProductPage, а сменя самоid. Заявката е вwatch(() => route.params.id, …, { immediate: true }). idот адреса е низ:"7", не7.getProduct(id)го слага в адреса и това е наред. Ако търсиш в масив с===, сравняваш низ с число.
Подробно е в урока за рутирането.
Направи името в ProductCard връзка и напиши ProductPage. Проверката отваря „Сирене“, после „Кашкавал“ от „Още от категорията“, връща се в каталога и отваря изчерпаната „Баница със сирене“.
Конзола
Решение
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({
// Пясъчникът няма адресна лента, затова историята е в паметта.
// В истинския проект: 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>Магазинът</strong>
<nav aria-label="Главно меню">
<RouterLink to="/">Каталог</RouterLink>
<RouterLink to="/cart">Кошница ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">Моите поръчки</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Каталог</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">Зарежда…</p>
<p v-else-if="products.length === 0">Няма такива продукти.</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 ? 'Няма такъв продукт.' : e.message;
});
},
{ immediate: true },
);
</script>
<template>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!product" role="status">Зарежда…</p>
<article v-else>
<p>
<RouterLink to="/">← Към каталога</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">В наличност: {{ product.stock }} бр.</p>
<p v-else>Изчерпан</p>
<section v-if="related.length > 0" aria-labelledby="related-title">
<h2 id="related-title">Още от категорията</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>Кошница</h1>
<p>Тук идва кошницата.</p>
</template>
pages/CheckoutPage.vue<template>
<h1>Поръчка</h1>
<p>Тук идва поръчката.</p>
</template>
pages/OrdersPage.vue<template>
<h1>Моите поръчки</h1>
<p>Тук идват поръчките ти.</p>
</template>
pages/NotFoundPage.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>Няма такава страница</h1>
<p>
<RouterLink to="/">Към каталога</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');
// Продуктите са в подкатегориите: „Храни“ и „Дом“ са само групи.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Търси
<input v-model="q" type="search" />
</label>
<label>
Категория
<select v-model="category">
<option value="">Всички</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// Кошницата идва в стъпка 3. Дотогава е празна и не се сменя.
export function useCart() {
return { items: [], count: 0, total: 0, add() {}, setQty() {}, remove() {}, clear() {} };
}
api.js// Всички заявки към API-то на магазина са тук: страниците не знаят адресите.
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 ?? `Сървърът върна ${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),
});
}
// Учебното API няма вход и връща поръчките на всички клиенти. Ти си клиент 1.
// Истинският сървър връща само твоите: знае кой си от сесията.
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('bg-BG');
const STATUS = { new: 'Нова', paid: 'Платена', shipped: 'Изпратена', delivered: 'Доставена' };
export const statusText = (status) => STATUS[status] ?? status;
style.css* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; color: #1f2328; background: #fbfaf7; }
header { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; background: #f3efe6; }
header strong { margin-right: auto; }
nav { display: flex; flex-wrap: wrap; gap: 12px; }
nav a { color: inherit; }
nav a[aria-current="page"] { font-weight: 600; text-decoration-thickness: 2px; }
main { padding: 0 16px 24px; }
h1 { font-size: 1.4rem; }
input, select, button { font: inherit; padding: 4px 8px; }
.filter { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 12px; }
.filter label { display: flex; flex-direction: column; gap: 2px; font-size: 0.9rem; }
.grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.card { background: #fff; border: 1px solid #ddd6c8; border-radius: 8px; padding: 8px 10px; }
.card h2 { font-size: 1rem; margin: 0 0 4px; }
.card p { margin: 0; }
.muted { color: #5c6168; font-size: 0.85rem; }
.price { font-weight: 600; }
.cart, .orders { list-style: none; padding: 0; }
.cart li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.cart li span:first-child { flex: 1; }
.orders li { padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.orders h2 { font-size: 1rem; margin: 0; }
.orders p { margin: 2px 0; }
.field { display: flex; flex-direction: column; gap: 2px; max-width: 320px; margin-bottom: 10px; }
.field input[aria-invalid="true"] { border-color: #b42318; }
.error { color: #b42318; font-size: 0.85rem; }
Проверката хваща <a href> (в конзолата пише „Връзката води към /product/7“), страница, която тегли продукта само при първото показване (остава на сиренето), и find((x) => x.id === id), който не намира нищо, защото "7" !== 7.
Упражнение
Стъпка 3: кошницата
ProductPage и менюто вече викат useCart(): бутон „Добави в кошницата“, „В кошницата: 2“ и „Кошница (2)“. Сега useCart е заглушка. Напиши истинската кошница и CartPage.
store/cart.js е хранилище на Pinia с defineStore('cart', () => { … }), както в урока за общото състояние. Връща items, count, total, add, setQty, remove, clear. count и total са computed от items, не се пазят отделно.
CartPage показва ред за всеки продукт: име, падащо меню „Брой: Сирене“ (от 1 до наличността, най-много 10), сума на реда и бутон „Махни“ с име „Махни Сирене“. Отдолу е „Общо: …“ и връзка „Към поръчката“. Празната кошница казва „Кошницата е празна.“
Напиши store/cart и CartPage. Проверката добавя две сирена и ябълки, отваря кошницата, сменя броя на сиренето на 3 и маха ябълките, като гледа менюто и сумата.
Конзола
Решение
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({
// Пясъчникът няма адресна лента, затова историята е в паметта.
// В истинския проект: 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>Магазинът</strong>
<nav aria-label="Главно меню">
<RouterLink to="/">Каталог</RouterLink>
<RouterLink to="/cart">Кошница ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">Моите поръчки</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Каталог</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">Зарежда…</p>
<p v-else-if="products.length === 0">Няма такива продукти.</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 ? 'Няма такъв продукт.' : e.message;
});
},
{ immediate: true },
);
</script>
<template>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!product" role="status">Зарежда…</p>
<article v-else>
<p>
<RouterLink to="/">← Към каталога</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">В наличност: {{ product.stock }} бр.</p>
<p v-else>Изчерпан</p>
<button type="button" :disabled="product.stock === 0" @click="cart.add(product)">
Добави в кошницата
</button>
<p v-if="inCart > 0" role="status">В кошницата: {{ inCart }}</p>
<section v-if="related.length > 0" aria-labelledby="related-title">
<h2 id="related-title">Още от категорията</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>Кошница</h1>
<template v-if="cart.items.length === 0">
<p>Кошницата е празна.</p>
<p>
<RouterLink to="/">Към каталога</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="`Брой: ${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="`Махни ${item.name}`" @click="cart.remove(item.id)">
Махни
</button>
</li>
</ul>
<p class="price">Общо: {{ money(cart.total) }}</p>
<RouterLink to="/checkout">Към поръчката</RouterLink>
</template>
</template>
pages/CheckoutPage.vue<template>
<h1>Поръчка</h1>
<p>Тук идва поръчката.</p>
</template>
pages/OrdersPage.vue<template>
<h1>Моите поръчки</h1>
<p>Тук идват поръчките ти.</p>
</template>
pages/NotFoundPage.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>Няма такава страница</h1>
<p>
<RouterLink to="/">Към каталога</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');
// Продуктите са в подкатегориите: „Храни“ и „Дом“ са само групи.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Търси
<input v-model="q" type="search" />
</label>
<label>
Категория
<select v-model="category">
<option value="">Всички</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// Всички заявки към API-то на магазина са тук: страниците не знаят адресите.
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 ?? `Сървърът върна ${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),
});
}
// Учебното API няма вход и връща поръчките на всички клиенти. Ти си клиент 1.
// Истинският сървър връща само твоите: знае кой си от сесията.
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('bg-BG');
const STATUS = { new: 'Нова', paid: 'Платена', shipped: 'Изпратена', delivered: 'Доставена' };
export const statusText = (status) => STATUS[status] ?? status;
style.css* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; color: #1f2328; background: #fbfaf7; }
header { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; background: #f3efe6; }
header strong { margin-right: auto; }
nav { display: flex; flex-wrap: wrap; gap: 12px; }
nav a { color: inherit; }
nav a[aria-current="page"] { font-weight: 600; text-decoration-thickness: 2px; }
main { padding: 0 16px 24px; }
h1 { font-size: 1.4rem; }
input, select, button { font: inherit; padding: 4px 8px; }
.filter { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 12px; }
.filter label { display: flex; flex-direction: column; gap: 2px; font-size: 0.9rem; }
.grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.card { background: #fff; border: 1px solid #ddd6c8; border-radius: 8px; padding: 8px 10px; }
.card h2 { font-size: 1rem; margin: 0 0 4px; }
.card p { margin: 0; }
.muted { color: #5c6168; font-size: 0.85rem; }
.price { font-weight: 600; }
.cart, .orders { list-style: none; padding: 0; }
.cart li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.cart li span:first-child { flex: 1; }
.orders li { padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.orders h2 { font-size: 1rem; margin: 0; }
.orders p { margin: 2px 0; }
.field { display: flex; flex-direction: column; gap: 2px; max-width: 320px; margin-bottom: 10px; }
.field input[aria-invalid="true"] { border-color: #b42318; }
.error { color: #b42318; font-size: 0.85rem; }
useCart без Pinia, като функция с ref([]) (всеки компонент има своя кошница и менюто казва 0), и сума в отделен ref, която смяната на броя не обновява.Упражнение
Стъпка 4: поръчката
CheckoutPage има формуляр с три Field: „Име“, „Имейл“, „Адрес“. При „Поръчай“:
- Проверка в браузъра. Празно име: „Въведи име.“ Имейл без
@: „Въведи имейл.“ Празен адрес: „Въведи адрес.“ Ако има грешка, заявка не тръгва. - Заявката.
placeOrder({ name, email, address, items }), къдетоitemsе[{ productId, qty }]от кошницата. - Отговорът. При 201 страницата казва например „Поръчка № 61 за 15,80 €.“ и кошницата се изчиства. При 400
err.errorsе обект по поле, например{ email: "Имейлът не е валиден" }, и всяко съобщение отива до своето поле.
Field вече показва error до полето, с aria-invalid и aria-describedby. Формулярът е с novalidate, за да говорят твоите съобщения, а не балончетата на браузъра. Проверката в браузъра е за удобство: хваща празното, преди да тръгне заявка. Последната дума е на сървъра, затова „maria@shop“ минава в браузъра, а сървърът я връща. Как се прави формуляр е в урока за формулярите.
Напиши CheckoutPage. Проверката добавя две сирена, натиска „Поръчай“ с празен формуляр, после с имейл „maria@shop“ (сървърът го връща) и накрая с верен имейл.
Конзола
Решение
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({
// Пясъчникът няма адресна лента, затова историята е в паметта.
// В истинския проект: 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>Магазинът</strong>
<nav aria-label="Главно меню">
<RouterLink to="/">Каталог</RouterLink>
<RouterLink to="/cart">Кошница ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">Моите поръчки</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Каталог</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">Зарежда…</p>
<p v-else-if="products.length === 0">Няма такива продукти.</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 ? 'Няма такъв продукт.' : e.message;
});
},
{ immediate: true },
);
</script>
<template>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!product" role="status">Зарежда…</p>
<article v-else>
<p>
<RouterLink to="/">← Към каталога</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">В наличност: {{ product.stock }} бр.</p>
<p v-else>Изчерпан</p>
<button type="button" :disabled="product.stock === 0" @click="cart.add(product)">
Добави в кошницата
</button>
<p v-if="inCart > 0" role="status">В кошницата: {{ inCart }}</p>
<section v-if="related.length > 0" aria-labelledby="related-title">
<h2 id="related-title">Още от категорията</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>Кошница</h1>
<template v-if="cart.items.length === 0">
<p>Кошницата е празна.</p>
<p>
<RouterLink to="/">Към каталога</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="`Брой: ${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="`Махни ${item.name}`" @click="cart.remove(item.id)">
Махни
</button>
</li>
</ul>
<p class="price">Общо: {{ money(cart.total) }}</p>
<RouterLink to="/checkout">Към поръчката</RouterLink>
</template>
</template>
pages/CheckoutPage.vue<script setup>
import { reactive, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { placeOrder } from '../api.js';
import Field from '../components/Field.vue';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const cart = useCart();
const form = reactive({ name: '', email: '', address: '' });
const errors = ref({});
const sending = ref(false);
const order = ref(null);
function check() {
const found = {};
if (!form.name.trim()) found.name = 'Въведи име.';
if (!form.email.includes('@')) found.email = 'Въведи имейл.';
if (!form.address.trim()) found.address = 'Въведи адрес.';
return found;
}
async function submit() {
errors.value = check();
if (Object.keys(errors.value).length > 0) return;
sending.value = true;
try {
const items = cart.items.map((i) => ({ productId: i.id, qty: i.qty }));
order.value = await placeOrder({ ...form, items });
cart.clear();
} catch (err) {
errors.value = err.errors ?? { form: err.message };
} finally {
sending.value = false;
}
}
</script>
<template>
<template v-if="order">
<h1>Поръчката е приета</h1>
<p role="status">Поръчка № {{ order.id }} за {{ money(order.total) }}.</p>
<p>
<RouterLink to="/orders">Моите поръчки</RouterLink>
</p>
</template>
<template v-else-if="cart.items.length === 0">
<h1>Поръчка</h1>
<p>Кошницата е празна.</p>
</template>
<template v-else>
<h1>Поръчка</h1>
<p class="price">Общо: {{ money(cart.total) }}</p>
<form novalidate @submit.prevent="submit">
<Field v-model="form.name" name="name" label="Име" :error="errors.name" />
<Field v-model="form.email" name="email" label="Имейл" type="email" :error="errors.email" />
<Field v-model="form.address" name="address" label="Адрес" :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 ? 'Изпраща…' : 'Поръчай' }}</button>
</form>
</template>
</template>
pages/OrdersPage.vue<template>
<h1>Моите поръчки</h1>
<p>Тук идват поръчките ти.</p>
</template>
pages/NotFoundPage.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>Няма такава страница</h1>
<p>
<RouterLink to="/">Към каталога</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');
// Продуктите са в подкатегориите: „Храни“ и „Дом“ са само групи.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Търси
<input v-model="q" type="search" />
</label>
<label>
Категория
<select v-model="category">
<option value="">Всички</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// Всички заявки към API-то на магазина са тук: страниците не знаят адресите.
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 ?? `Сървърът върна ${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),
});
}
// Учебното API няма вход и връща поръчките на всички клиенти. Ти си клиент 1.
// Истинският сървър връща само твоите: знае кой си от сесията.
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('bg-BG');
const STATUS = { new: 'Нова', paid: 'Платена', shipped: 'Изпратена', delivered: 'Доставена' };
export const statusText = (status) => STATUS[status] ?? status;
style.css* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; color: #1f2328; background: #fbfaf7; }
header { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; background: #f3efe6; }
header strong { margin-right: auto; }
nav { display: flex; flex-wrap: wrap; gap: 12px; }
nav a { color: inherit; }
nav a[aria-current="page"] { font-weight: 600; text-decoration-thickness: 2px; }
main { padding: 0 16px 24px; }
h1 { font-size: 1.4rem; }
input, select, button { font: inherit; padding: 4px 8px; }
.filter { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 12px; }
.filter label { display: flex; flex-direction: column; gap: 2px; font-size: 0.9rem; }
.grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.card { background: #fff; border: 1px solid #ddd6c8; border-radius: 8px; padding: 8px 10px; }
.card h2 { font-size: 1rem; margin: 0 0 4px; }
.card p { margin: 0; }
.muted { color: #5c6168; font-size: 0.85rem; }
.price { font-weight: 600; }
.cart, .orders { list-style: none; padding: 0; }
.cart li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.cart li span:first-child { flex: 1; }
.orders li { padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.orders h2 { font-size: 1rem; margin: 0; }
.orders p { margin: 2px 0; }
.field { display: flex; flex-direction: column; gap: 2px; max-width: 320px; margin-bottom: 10px; }
.field input[aria-invalid="true"] { border-color: #b42318; }
.error { color: #b42318; font-size: 0.85rem; }
Проверката хваща грешка на сървъра, показана над формуляра вместо до полето; items: cart.items, при което сървърът не намира productId; и кошница, изчистена преди отговора: при 400 формулярът изчезва, а с него и кошницата на човека.
Упражнение
Стъпка 5: моите поръчки и целият път
OrdersPage тегли getOrders() при всяко отваряне и показва поръчките на клиента ME, най-новата отгоре: „Поръчка № 61“, дата, статус, сума и редовете („Сирене × 3“). Докато чака, „Зарежда…“, а без поръчки, „Още нямаш поръчки.“
Учебното API няма вход и връща поръчките на всички клиенти. Затова страницата филтрира по customerId === ME. Истинският сървър връща само твоите.
Сега приложението е цяло. Проверката минава пътя от началото на урока: гледа старите поръчки, филтрира „Млечни“, отваря сиренето, добавя 2, прави ги 3 в кошницата, поръчва с грешен имейл, вижда грешката до полето, поправя я, поръчва и намира новата поръчка най-горе в „Моите поръчки“.
Напиши OrdersPage. Проверката минава целия път на купувача и накрая търси новата поръчка най-горе в „Моите поръчки“.
Конзола
Решение
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({
// Пясъчникът няма адресна лента, затова историята е в паметта.
// В истинския проект: 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>Магазинът</strong>
<nav aria-label="Главно меню">
<RouterLink to="/">Каталог</RouterLink>
<RouterLink to="/cart">Кошница ({{ cart.count }})</RouterLink>
<RouterLink to="/orders">Моите поръчки</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
pages/CatalogPage.vue<script setup>
import { onMounted, ref, watch } from 'vue';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.vue';
import ProductCard from '../components/ProductCard.vue';
const q = ref('');
const category = ref('');
const categories = ref([]);
const products = ref(null);
const error = ref(null);
onMounted(async () => {
try {
categories.value = await getCategories();
} catch {
categories.value = [];
}
});
watch(
[q, category],
(now, before, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
products.value = null;
error.value = null;
getProducts({ q: q.value, category: category.value }, controller.signal)
.then((list) => (products.value = list))
.catch((e) => {
if (e.name !== 'AbortError') error.value = e.message;
});
},
{ immediate: true },
);
</script>
<template>
<h1>Каталог</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">Зарежда…</p>
<p v-else-if="products.length === 0">Няма такива продукти.</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 ? 'Няма такъв продукт.' : e.message;
});
},
{ immediate: true },
);
</script>
<template>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!product" role="status">Зарежда…</p>
<article v-else>
<p>
<RouterLink to="/">← Към каталога</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">В наличност: {{ product.stock }} бр.</p>
<p v-else>Изчерпан</p>
<button type="button" :disabled="product.stock === 0" @click="cart.add(product)">
Добави в кошницата
</button>
<p v-if="inCart > 0" role="status">В кошницата: {{ inCart }}</p>
<section v-if="related.length > 0" aria-labelledby="related-title">
<h2 id="related-title">Още от категорията</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>Кошница</h1>
<template v-if="cart.items.length === 0">
<p>Кошницата е празна.</p>
<p>
<RouterLink to="/">Към каталога</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="`Брой: ${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="`Махни ${item.name}`" @click="cart.remove(item.id)">
Махни
</button>
</li>
</ul>
<p class="price">Общо: {{ money(cart.total) }}</p>
<RouterLink to="/checkout">Към поръчката</RouterLink>
</template>
</template>
pages/CheckoutPage.vue<script setup>
import { reactive, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { placeOrder } from '../api.js';
import Field from '../components/Field.vue';
import { money } from '../format.js';
import { useCart } from '../store/cart.js';
const cart = useCart();
const form = reactive({ name: '', email: '', address: '' });
const errors = ref({});
const sending = ref(false);
const order = ref(null);
function check() {
const found = {};
if (!form.name.trim()) found.name = 'Въведи име.';
if (!form.email.includes('@')) found.email = 'Въведи имейл.';
if (!form.address.trim()) found.address = 'Въведи адрес.';
return found;
}
async function submit() {
errors.value = check();
if (Object.keys(errors.value).length > 0) return;
sending.value = true;
try {
const items = cart.items.map((i) => ({ productId: i.id, qty: i.qty }));
order.value = await placeOrder({ ...form, items });
cart.clear();
} catch (err) {
errors.value = err.errors ?? { form: err.message };
} finally {
sending.value = false;
}
}
</script>
<template>
<template v-if="order">
<h1>Поръчката е приета</h1>
<p role="status">Поръчка № {{ order.id }} за {{ money(order.total) }}.</p>
<p>
<RouterLink to="/orders">Моите поръчки</RouterLink>
</p>
</template>
<template v-else-if="cart.items.length === 0">
<h1>Поръчка</h1>
<p>Кошницата е празна.</p>
</template>
<template v-else>
<h1>Поръчка</h1>
<p class="price">Общо: {{ money(cart.total) }}</p>
<form novalidate @submit.prevent="submit">
<Field v-model="form.name" name="name" label="Име" :error="errors.name" />
<Field v-model="form.email" name="email" label="Имейл" type="email" :error="errors.email" />
<Field v-model="form.address" name="address" label="Адрес" :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 ? 'Изпраща…' : 'Поръчай' }}</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">Моите поръчки</h1>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="!orders" role="status">Зарежда…</p>
<p v-else-if="orders.length === 0">Още нямаш поръчки.</p>
<ul v-else class="orders" aria-labelledby="orders-title">
<li v-for="o in orders" :key="o.id">
<h2>Поръчка № {{ 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>Няма такава страница</h1>
<p>
<RouterLink to="/">Към каталога</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');
// Продуктите са в подкатегориите: „Храни“ и „Дом“ са само групи.
const groups = computed(() => props.categories.filter((c) => c.parentId !== null));
</script>
<template>
<form class="filter" role="search" @submit.prevent>
<label>
Търси
<input v-model="q" type="search" />
</label>
<label>
Категория
<select v-model="category">
<option value="">Всички</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// Всички заявки към API-то на магазина са тук: страниците не знаят адресите.
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 ?? `Сървърът върна ${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),
});
}
// Учебното API няма вход и връща поръчките на всички клиенти. Ти си клиент 1.
// Истинският сървър връща само твоите: знае кой си от сесията.
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('bg-BG');
const STATUS = { new: 'Нова', paid: 'Платена', shipped: 'Изпратена', delivered: 'Доставена' };
export const statusText = (status) => STATUS[status] ?? status;
style.css* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; color: #1f2328; background: #fbfaf7; }
header { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; background: #f3efe6; }
header strong { margin-right: auto; }
nav { display: flex; flex-wrap: wrap; gap: 12px; }
nav a { color: inherit; }
nav a[aria-current="page"] { font-weight: 600; text-decoration-thickness: 2px; }
main { padding: 0 16px 24px; }
h1 { font-size: 1.4rem; }
input, select, button { font: inherit; padding: 4px 8px; }
.filter { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 12px; }
.filter label { display: flex; flex-direction: column; gap: 2px; font-size: 0.9rem; }
.grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.card { background: #fff; border: 1px solid #ddd6c8; border-radius: 8px; padding: 8px 10px; }
.card h2 { font-size: 1rem; margin: 0 0 4px; }
.card p { margin: 0; }
.muted { color: #5c6168; font-size: 0.85rem; }
.price { font-weight: 600; }
.cart, .orders { list-style: none; padding: 0; }
.cart li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.cart li span:first-child { flex: 1; }
.orders li { padding: 6px 0; border-bottom: 1px solid #ddd6c8; }
.orders h2 { font-size: 1rem; margin: 0; }
.orders p { margin: 2px 0; }
.field { display: flex; flex-direction: column; gap: 2px; max-width: 320px; margin-bottom: 10px; }
.field input[aria-invalid="true"] { border-color: #b42318; }
.error { color: #b42318; font-size: 0.85rem; }
Проверката хваща api.js, който пази първия отговор на /api/orders и не пита пак (новата поръчка не се вижда), списък без подреждане (новата е най-долу) и списък без филтъра по клиент (60 чужди поръчки).
Идеята
Какво е учебно тук
Приложението е истинско, но около него има опростявания. В истинския магазин:
- Кой си, знае сървърът. Влизаш с парола и сървърът връща само твоите поръчки по сесията. Филтър в браузъра не пази нищо: всеки може да смени
ME. - Цената е на сървъра. Поръчката праща само
productIdиqty. Сумата идва в отговора, смятана от цените в базата. - Кошницата оцелява. Тук тя е в паметта и изчезва при презареждане. Истинската е в
localStorageили на сървъра. - Адресите са истински.
createWebHistory()вместо историята в паметта, и хостинг, който връщаindex.htmlза всеки адрес.
По-точно, за любопитните
Двете библиотеки. Пътят е един и същ: адреси, данни от API, обща кошница, формуляр с грешки по поле. Vue го прави с реактивност: watch върху параметъра и филтъра, Pinia за кошницата, v-model и defineModel за полетата. Другата библиотека решава същите задачи по свой начин.
Тестовете. Сценариите на стъпките кликат и пишат като човек. Същото пишеш с Testing Library: render, screen.getByRole, userEvent. Как, е в урока за тестовете.
Бързината. Каталогът тегли при всеки натиснат клавиш. С отказа на старата заявка това е вярно, но излишно. В истинския проект търсенето чака 300 ms тишина, преди да тръгне (debounce).
Идеята
Какво следва
Пренеси витрината в истински проект, както в урока „От пясъчника към проект“:
npm create vite@latest shop -- --template vuecd shopnpm install vue-router piniaКопирай папките pages/, components/, store/ и файловете api.js, format.js, router.js, style.css в src/. В router.js смени createMemoryHistory() с createWebHistory().
API-то вече не е мнимо. Пусни истински сървър с адресите /api/products, /api/orders или насочи /api към него с server.proxy във vite.config.js. После npm run build и публикувай dist/ с правило, което връща index.html за всеки адрес.
Още задачи
- Подробности за поръчка.
/orders/:idсGET /api/orders/:idи връзка от „Моите поръчки“. Непознат номер показва „Няма такава поръчка.“ - Кошница, която оцелява. Пази
itemsвlocalStorageи ги чети при старт. - Тестове. Тест за
CartPage: смяна на броя сменя сумата. Тест заCheckoutPage: празен формуляр показва трите грешки и не праща заявка.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Общо състояние · Рутиране · Тестове на компоненти · От пясъчника към проект
- Задачи по темата: Проект: витрината на магазина