Витрината на магазина
Накратко: Проект на уеб приложение: витрина на магазин с каталог от API, страница на продукта по адрес, обща кошница, поръчка с проверки и „Моите поръчки“.
Проблем
Витрината
Магазинът има API: продукти, категории, поръчки. Липсва витрината, през която купувачът пазарува. Пътят му е:
- Търси в каталога или избира категория.
- Отваря продукт и го добавя в кошницата.
- В кошницата сменя бройките.
- Поръчва: име, имейл, адрес. Ако сгреши, вижда къде.
- Вижда поръчката си в „Моите поръчки“.
Всяка част вече си я правил в отделен урок. Тук ги събираш в едно приложение и го строиш на пет стъпки: всяка задача започва от решението на предишната. Последната проверка минава целия път.
Идеята
Скелетът
Това е приложението на нулата стъпка: менюто и страниците-заглушки. Мини през менюто.
Конзола
Файловете са подредени като в истински проект:
api.jsпази всички заявки. Страниците викатgetProducts,placeOrderи не знаят адресите. Грешката от сървъра идва катоErrorсъсstatusиerrors(грешките по поле).format.jsформатира пари, дата и статус.pages/са страниците, по една за всеки адрес.components/са частите, които страниците ползват:ProductCard,Filter,Field.store/cartе кошницата, обща за цялото приложение.
Рутерът е в main.jsx: MemoryRouter над CartProvider и App. Адресите са в App.jsx с Routes и Route, както в урока за рутирането.
Пясъчникът няма адресна лента, затова историята е в паметта. В истинския проект е BrowserRouter. Останалото е същото.
Filter и Field са готови от уроците: полета, свързани със състояние, и поле с етикет и съобщение за грешка. Ти пишеш страниците и кошницата.
Упражнение
Стъпка 1: каталогът
Каталогът тегли продуктите от getProducts({ q, category }) и ги показва с ProductCard в списък с клас grid. Филтърът е отгоре. При всяка смяна на търсенето или категорията тръгва нова заявка, а старата се отказва. Докато чака, страницата казва „Зарежда…“. Ако няма нищо, казва „Няма такива продукти.“
Внимавай за две неща от урока за данни от API:
- Списъкът следва филтъра. Ефектът има
[q, category]в зависимостите. - Старият отговор не печели. Ако „Млечни“ отговаря бавно, а ти вече си избрал „Напитки“, бавният отговор не трябва да затрие новия.
AbortControllerотказва старата заявка в почистването на ефекта.
Напиши CatalogPage: продуктите от API-то по филтъра, „Зарежда…“, „Няма такива продукти.“ и отказ на старата заявка. Проверката търси „хляб“, после избира „Млечни“ и веднага „Напитки“ (Млечни отговаря за 900 ms), после търси в категория.
Конзола
Решение
main.jsximport { createRoot } from 'react-dom/client';
import { MemoryRouter } from 'react-router';
import App from './App.jsx';
import { CartProvider } from './store/cart.jsx';
import './style.css';
// Пясъчникът няма адресна лента, затова историята е в паметта.
// В истинския проект: BrowserRouter.
createRoot(document.getElementById('root')).render(
<MemoryRouter>
<CartProvider>
<App />
</CartProvider>
</MemoryRouter>,
);
App.jsximport { NavLink, Route, Routes } from 'react-router';
import CartPage from './pages/CartPage.jsx';
import CatalogPage from './pages/CatalogPage.jsx';
import CheckoutPage from './pages/CheckoutPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
import OrdersPage from './pages/OrdersPage.jsx';
import ProductPage from './pages/ProductPage.jsx';
import { useCart } from './store/cart.jsx';
export default function App() {
const cart = useCart();
return (
<>
<header>
<strong>Магазинът</strong>
<nav aria-label="Главно меню">
<NavLink to="/" end>Каталог</NavLink>
<NavLink to="/cart">Кошница ({cart.count})</NavLink>
<NavLink to="/orders">Моите поръчки</NavLink>
</nav>
</header>
<main>
<Routes>
<Route path="/" element={<CatalogPage />} />
<Route path="/product/:id" element={<ProductPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
</>
);
}
pages/CatalogPage.jsximport { useEffect, useState } from 'react';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.jsx';
import ProductCard from '../components/ProductCard.jsx';
export default function CatalogPage() {
const [q, setQ] = useState('');
const [category, setCategory] = useState('');
const [categories, setCategories] = useState([]);
const [products, setProducts] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
getCategories()
.then(setCategories)
.catch(() => setCategories([]));
}, []);
useEffect(() => {
const controller = new AbortController();
setProducts(null);
setError(null);
getProducts({ q, category }, controller.signal)
.then(setProducts)
.catch((e) => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort();
}, [q, category]);
return (
<>
<h1>Каталог</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Зарежда…</p>}
{products && products.length === 0 && <p>Няма такива продукти.</p>}
{products && products.length > 0 && (
<ul className="grid">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
)}
</>
);
}
pages/ProductPage.jsxexport default function ProductPage() {
return (
<>
<h1>Продукт</h1>
<p>Тук идва продуктът.</p>
</>
);
}
pages/CartPage.jsxexport default function CartPage() {
return (
<>
<h1>Кошница</h1>
<p>Тук идва кошницата.</p>
</>
);
}
pages/CheckoutPage.jsxexport default function CheckoutPage() {
return (
<>
<h1>Поръчка</h1>
<p>Тук идва поръчката.</p>
</>
);
}
pages/OrdersPage.jsxexport default function OrdersPage() {
return (
<>
<h1>Моите поръчки</h1>
<p>Тук идват поръчките ти.</p>
</>
);
}
pages/NotFoundPage.jsximport { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<h1>Няма такава страница</h1>
<p>
<Link to="/">Към каталога</Link>
</p>
</>
);
}
components/ProductCard.jsximport { money } from '../format.js';
export default function ProductCard({ product }) {
return (
<li className="card">
<h2>{product.name}</h2>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
</li>
);
}
components/Filter.jsxexport default function Filter({ categories, q, category, onQChange, onCategoryChange }) {
// Продуктите са в подкатегориите: „Храни“ и „Дом“ са само групи.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Търси
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Категория
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">Всички</option>
{groups.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
</form>
);
}
components/Field.jsxexport default function Field({ name, label, type = 'text', value, error, onChange }) {
const errorId = `${name}-error`;
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<span id={errorId} className="error">
{error}
</span>
)}
</div>
);
}
store/cart.jsx// Кошницата идва в стъпка 3. Дотогава е празна и не се сменя.
export function CartProvider({ children }) {
return children;
}
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; }
Проверката хваща три грешки: заявка само веднъж, при показването; търсене, което не е в зависимостите; и заявка без отказ, при която отговорът за „Млечни“ идва последен и показва сирене, докато в полето пише „Напитки“.
Упражнение
Стъпка 2: страницата на продукта
Името в ProductCard става връзка към /product/:id. ProductPage взема id от адреса, тегли продукта с getProduct(id) и показва име, категория, цена и наличност: „В наличност: 18 бр.“ или „Изчерпан“. Под него е „Още от категорията“: до три други продукта от същата категория, с връзки.
Внимавай:
- Връзката е на рутера:
<Link to={…}>, не<a href>. Обикновената връзка зарежда цялата страница наново. В пясъчника тя не отива никъде. - Същата страница, друг параметър. От „Сирене“ към „Кашкавал“ рутерът не прави нов
ProductPage, а сменя самоid. Ефектът е с[id]. idот адреса е низ:"7", не7.getProduct(id)го слага в адреса и това е наред. Ако търсиш в масив с===, сравняваш низ с число.
Подробно е в урока за рутирането.
Направи името в ProductCard връзка и напиши ProductPage. Проверката отваря „Сирене“, после „Кашкавал“ от „Още от категорията“, връща се в каталога и отваря изчерпаната „Баница със сирене“.
Конзола
Решение
main.jsximport { createRoot } from 'react-dom/client';
import { MemoryRouter } from 'react-router';
import App from './App.jsx';
import { CartProvider } from './store/cart.jsx';
import './style.css';
// Пясъчникът няма адресна лента, затова историята е в паметта.
// В истинския проект: BrowserRouter.
createRoot(document.getElementById('root')).render(
<MemoryRouter>
<CartProvider>
<App />
</CartProvider>
</MemoryRouter>,
);
App.jsximport { NavLink, Route, Routes } from 'react-router';
import CartPage from './pages/CartPage.jsx';
import CatalogPage from './pages/CatalogPage.jsx';
import CheckoutPage from './pages/CheckoutPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
import OrdersPage from './pages/OrdersPage.jsx';
import ProductPage from './pages/ProductPage.jsx';
import { useCart } from './store/cart.jsx';
export default function App() {
const cart = useCart();
return (
<>
<header>
<strong>Магазинът</strong>
<nav aria-label="Главно меню">
<NavLink to="/" end>Каталог</NavLink>
<NavLink to="/cart">Кошница ({cart.count})</NavLink>
<NavLink to="/orders">Моите поръчки</NavLink>
</nav>
</header>
<main>
<Routes>
<Route path="/" element={<CatalogPage />} />
<Route path="/product/:id" element={<ProductPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
</>
);
}
pages/CatalogPage.jsximport { useEffect, useState } from 'react';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.jsx';
import ProductCard from '../components/ProductCard.jsx';
export default function CatalogPage() {
const [q, setQ] = useState('');
const [category, setCategory] = useState('');
const [categories, setCategories] = useState([]);
const [products, setProducts] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
getCategories()
.then(setCategories)
.catch(() => setCategories([]));
}, []);
useEffect(() => {
const controller = new AbortController();
setProducts(null);
setError(null);
getProducts({ q, category }, controller.signal)
.then(setProducts)
.catch((e) => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort();
}, [q, category]);
return (
<>
<h1>Каталог</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Зарежда…</p>}
{products && products.length === 0 && <p>Няма такива продукти.</p>}
{products && products.length > 0 && (
<ul className="grid">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
)}
</>
);
}
pages/ProductPage.jsximport { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
export default function ProductPage() {
const { id } = useParams();
const [product, setProduct] = useState(null);
const [related, setRelated] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
const p = await getProduct(id, controller.signal);
setProduct(p);
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, controller.signal);
setRelated(same.filter((x) => x.id !== p.id).slice(0, 3));
}
setProduct(null);
setRelated([]);
setError(null);
load().catch((e) => {
if (e.name !== 'AbortError') setError(e.status === 404 ? 'Няма такъв продукт.' : e.message);
});
return () => controller.abort();
}, [id]);
if (error) return <p role="alert">{error}</p>;
if (!product) return <p role="status">Зарежда…</p>;
return (
<article>
<p>
<Link to="/">← Към каталога</Link>
</p>
<h1>{product.name}</h1>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
<p>{product.stock > 0 ? `В наличност: ${product.stock} бр.` : 'Изчерпан'}</p>
{related.length > 0 && (
<section aria-labelledby="related-title">
<h2 id="related-title">Още от категорията</h2>
<ul>
{related.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</section>
)}
</article>
);
}
pages/CartPage.jsxexport default function CartPage() {
return (
<>
<h1>Кошница</h1>
<p>Тук идва кошницата.</p>
</>
);
}
pages/CheckoutPage.jsxexport default function CheckoutPage() {
return (
<>
<h1>Поръчка</h1>
<p>Тук идва поръчката.</p>
</>
);
}
pages/OrdersPage.jsxexport default function OrdersPage() {
return (
<>
<h1>Моите поръчки</h1>
<p>Тук идват поръчките ти.</p>
</>
);
}
pages/NotFoundPage.jsximport { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<h1>Няма такава страница</h1>
<p>
<Link to="/">Към каталога</Link>
</p>
</>
);
}
components/ProductCard.jsximport { Link } from 'react-router';
import { money } from '../format.js';
export default function ProductCard({ product }) {
return (
<li className="card">
<h2>
<Link to={`/product/${product.id}`}>{product.name}</Link>
</h2>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
</li>
);
}
components/Filter.jsxexport default function Filter({ categories, q, category, onQChange, onCategoryChange }) {
// Продуктите са в подкатегориите: „Храни“ и „Дом“ са само групи.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Търси
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Категория
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">Всички</option>
{groups.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
</form>
);
}
components/Field.jsxexport default function Field({ name, label, type = 'text', value, error, onChange }) {
const errorId = `${name}-error`;
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<span id={errorId} className="error">
{error}
</span>
)}
</div>
);
}
store/cart.jsx// Кошницата идва в стъпка 3. Дотогава е празна и не се сменя.
export function CartProvider({ children }) {
return children;
}
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.jsx има CartProvider с useState и CartContext, както в урока за общото състояние. useCart() връща useContext(CartContext): items, count, total, add, setQty, remove, clear. count и total се изчисляват от items, не се пазят отделно. Всяка смяна прави нов масив: [...items, …], items.map(…), items.filter(…).
CartPage показва ред за всеки продукт: име, падащо меню „Брой: Сирене“ (от 1 до наличността, най-много 10), сума на реда и бутон „Махни“ с име „Махни Сирене“. Отдолу е „Общо: …“ и връзка „Към поръчката“. Празната кошница казва „Кошницата е празна.“
Напиши store/cart и CartPage. Проверката добавя две сирена и ябълки, отваря кошницата, сменя броя на сиренето на 3 и маха ябълките, като гледа менюто и сумата.
Конзола
Решение
main.jsximport { createRoot } from 'react-dom/client';
import { MemoryRouter } from 'react-router';
import App from './App.jsx';
import { CartProvider } from './store/cart.jsx';
import './style.css';
// Пясъчникът няма адресна лента, затова историята е в паметта.
// В истинския проект: BrowserRouter.
createRoot(document.getElementById('root')).render(
<MemoryRouter>
<CartProvider>
<App />
</CartProvider>
</MemoryRouter>,
);
App.jsximport { NavLink, Route, Routes } from 'react-router';
import CartPage from './pages/CartPage.jsx';
import CatalogPage from './pages/CatalogPage.jsx';
import CheckoutPage from './pages/CheckoutPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
import OrdersPage from './pages/OrdersPage.jsx';
import ProductPage from './pages/ProductPage.jsx';
import { useCart } from './store/cart.jsx';
export default function App() {
const cart = useCart();
return (
<>
<header>
<strong>Магазинът</strong>
<nav aria-label="Главно меню">
<NavLink to="/" end>Каталог</NavLink>
<NavLink to="/cart">Кошница ({cart.count})</NavLink>
<NavLink to="/orders">Моите поръчки</NavLink>
</nav>
</header>
<main>
<Routes>
<Route path="/" element={<CatalogPage />} />
<Route path="/product/:id" element={<ProductPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
</>
);
}
pages/CatalogPage.jsximport { useEffect, useState } from 'react';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.jsx';
import ProductCard from '../components/ProductCard.jsx';
export default function CatalogPage() {
const [q, setQ] = useState('');
const [category, setCategory] = useState('');
const [categories, setCategories] = useState([]);
const [products, setProducts] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
getCategories()
.then(setCategories)
.catch(() => setCategories([]));
}, []);
useEffect(() => {
const controller = new AbortController();
setProducts(null);
setError(null);
getProducts({ q, category }, controller.signal)
.then(setProducts)
.catch((e) => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort();
}, [q, category]);
return (
<>
<h1>Каталог</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Зарежда…</p>}
{products && products.length === 0 && <p>Няма такива продукти.</p>}
{products && products.length > 0 && (
<ul className="grid">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
)}
</>
);
}
pages/ProductPage.jsximport { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
export default function ProductPage() {
const { id } = useParams();
const cart = useCart();
const [product, setProduct] = useState(null);
const [related, setRelated] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
const p = await getProduct(id, controller.signal);
setProduct(p);
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, controller.signal);
setRelated(same.filter((x) => x.id !== p.id).slice(0, 3));
}
setProduct(null);
setRelated([]);
setError(null);
load().catch((e) => {
if (e.name !== 'AbortError') setError(e.status === 404 ? 'Няма такъв продукт.' : e.message);
});
return () => controller.abort();
}, [id]);
if (error) return <p role="alert">{error}</p>;
if (!product) return <p role="status">Зарежда…</p>;
const inCart = cart.items.find((i) => i.id === product.id)?.qty ?? 0;
return (
<article>
<p>
<Link to="/">← Към каталога</Link>
</p>
<h1>{product.name}</h1>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
<p>{product.stock > 0 ? `В наличност: ${product.stock} бр.` : 'Изчерпан'}</p>
<button type="button" disabled={product.stock === 0} onClick={() => cart.add(product)}>
Добави в кошницата
</button>
{inCart > 0 && <p role="status">В кошницата: {inCart}</p>}
{related.length > 0 && (
<section aria-labelledby="related-title">
<h2 id="related-title">Още от категорията</h2>
<ul>
{related.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</section>
)}
</article>
);
}
pages/CartPage.jsximport { Link } from 'react-router';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
const upTo = (n) => Array.from({ length: Math.min(n, 10) }, (_, i) => i + 1);
export default function CartPage() {
const cart = useCart();
if (cart.items.length === 0)
return (
<>
<h1>Кошница</h1>
<p>Кошницата е празна.</p>
<p>
<Link to="/">Към каталога</Link>
</p>
</>
);
return (
<>
<h1>Кошница</h1>
<ul className="cart">
{cart.items.map((item) => (
<li key={item.id}>
<span>{item.name}</span>
<select
aria-label={`Брой: ${item.name}`}
value={item.qty}
onChange={(e) => cart.setQty(item.id, Number(e.target.value))}
>
{upTo(item.stock).map((n) => (
<option key={n}>{n}</option>
))}
</select>
<span>{money(item.price * item.qty)}</span>
<button type="button" aria-label={`Махни ${item.name}`} onClick={() => cart.remove(item.id)}>
Махни
</button>
</li>
))}
</ul>
<p className="price">
Общо: {money(cart.total)}
</p>
<Link to="/checkout">Към поръчката</Link>
</>
);
}
pages/CheckoutPage.jsxexport default function CheckoutPage() {
return (
<>
<h1>Поръчка</h1>
<p>Тук идва поръчката.</p>
</>
);
}
pages/OrdersPage.jsxexport default function OrdersPage() {
return (
<>
<h1>Моите поръчки</h1>
<p>Тук идват поръчките ти.</p>
</>
);
}
pages/NotFoundPage.jsximport { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<h1>Няма такава страница</h1>
<p>
<Link to="/">Към каталога</Link>
</p>
</>
);
}
components/ProductCard.jsximport { Link } from 'react-router';
import { money } from '../format.js';
export default function ProductCard({ product }) {
return (
<li className="card">
<h2>
<Link to={`/product/${product.id}`}>{product.name}</Link>
</h2>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
</li>
);
}
components/Filter.jsxexport default function Filter({ categories, q, category, onQChange, onCategoryChange }) {
// Продуктите са в подкатегориите: „Храни“ и „Дом“ са само групи.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Търси
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Категория
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">Всички</option>
{groups.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
</form>
);
}
components/Field.jsxexport default function Field({ name, label, type = 'text', value, error, onChange }) {
const errorId = `${name}-error`;
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<span id={errorId} className="error">
{error}
</span>
)}
</div>
);
}
store/cart.jsximport { createContext, useContext, useState } from 'react';
const CartContext = createContext(null);
export function CartProvider({ children }) {
const [items, setItems] = useState([]);
function add(product) {
const found = items.find((i) => i.id === product.id);
if (found) setItems(items.map((i) => (i.id === product.id ? { ...i, qty: i.qty + 1 } : i)));
else setItems([...items, { id: product.id, name: product.name, price: product.price, stock: product.stock, qty: 1 }]);
}
function setQty(id, qty) {
setItems(items.map((i) => (i.id === id ? { ...i, qty } : i)));
}
function remove(id) {
setItems(items.filter((i) => i.id !== id));
}
function clear() {
setItems([]);
}
const count = items.reduce((sum, i) => sum + i.qty, 0);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return <CartContext value={{ items, count, total, add, setQty, remove, clear }}>{children}</CartContext>;
}
export function useCart() {
return useContext(CartContext);
}
api.js// Всички заявки към 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 без Context (всеки компонент има своя кошница и менюто казва 0), сума в отделен useState, която смяната на броя не обновява, и add, който мени масива на място и подава същия масив на setItems: React вижда същия масив и не рисува наново.Упражнение
Стъпка 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.jsximport { createRoot } from 'react-dom/client';
import { MemoryRouter } from 'react-router';
import App from './App.jsx';
import { CartProvider } from './store/cart.jsx';
import './style.css';
// Пясъчникът няма адресна лента, затова историята е в паметта.
// В истинския проект: BrowserRouter.
createRoot(document.getElementById('root')).render(
<MemoryRouter>
<CartProvider>
<App />
</CartProvider>
</MemoryRouter>,
);
App.jsximport { NavLink, Route, Routes } from 'react-router';
import CartPage from './pages/CartPage.jsx';
import CatalogPage from './pages/CatalogPage.jsx';
import CheckoutPage from './pages/CheckoutPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
import OrdersPage from './pages/OrdersPage.jsx';
import ProductPage from './pages/ProductPage.jsx';
import { useCart } from './store/cart.jsx';
export default function App() {
const cart = useCart();
return (
<>
<header>
<strong>Магазинът</strong>
<nav aria-label="Главно меню">
<NavLink to="/" end>Каталог</NavLink>
<NavLink to="/cart">Кошница ({cart.count})</NavLink>
<NavLink to="/orders">Моите поръчки</NavLink>
</nav>
</header>
<main>
<Routes>
<Route path="/" element={<CatalogPage />} />
<Route path="/product/:id" element={<ProductPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
</>
);
}
pages/CatalogPage.jsximport { useEffect, useState } from 'react';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.jsx';
import ProductCard from '../components/ProductCard.jsx';
export default function CatalogPage() {
const [q, setQ] = useState('');
const [category, setCategory] = useState('');
const [categories, setCategories] = useState([]);
const [products, setProducts] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
getCategories()
.then(setCategories)
.catch(() => setCategories([]));
}, []);
useEffect(() => {
const controller = new AbortController();
setProducts(null);
setError(null);
getProducts({ q, category }, controller.signal)
.then(setProducts)
.catch((e) => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort();
}, [q, category]);
return (
<>
<h1>Каталог</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Зарежда…</p>}
{products && products.length === 0 && <p>Няма такива продукти.</p>}
{products && products.length > 0 && (
<ul className="grid">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
)}
</>
);
}
pages/ProductPage.jsximport { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
export default function ProductPage() {
const { id } = useParams();
const cart = useCart();
const [product, setProduct] = useState(null);
const [related, setRelated] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
const p = await getProduct(id, controller.signal);
setProduct(p);
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, controller.signal);
setRelated(same.filter((x) => x.id !== p.id).slice(0, 3));
}
setProduct(null);
setRelated([]);
setError(null);
load().catch((e) => {
if (e.name !== 'AbortError') setError(e.status === 404 ? 'Няма такъв продукт.' : e.message);
});
return () => controller.abort();
}, [id]);
if (error) return <p role="alert">{error}</p>;
if (!product) return <p role="status">Зарежда…</p>;
const inCart = cart.items.find((i) => i.id === product.id)?.qty ?? 0;
return (
<article>
<p>
<Link to="/">← Към каталога</Link>
</p>
<h1>{product.name}</h1>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
<p>{product.stock > 0 ? `В наличност: ${product.stock} бр.` : 'Изчерпан'}</p>
<button type="button" disabled={product.stock === 0} onClick={() => cart.add(product)}>
Добави в кошницата
</button>
{inCart > 0 && <p role="status">В кошницата: {inCart}</p>}
{related.length > 0 && (
<section aria-labelledby="related-title">
<h2 id="related-title">Още от категорията</h2>
<ul>
{related.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</section>
)}
</article>
);
}
pages/CartPage.jsximport { Link } from 'react-router';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
const upTo = (n) => Array.from({ length: Math.min(n, 10) }, (_, i) => i + 1);
export default function CartPage() {
const cart = useCart();
if (cart.items.length === 0)
return (
<>
<h1>Кошница</h1>
<p>Кошницата е празна.</p>
<p>
<Link to="/">Към каталога</Link>
</p>
</>
);
return (
<>
<h1>Кошница</h1>
<ul className="cart">
{cart.items.map((item) => (
<li key={item.id}>
<span>{item.name}</span>
<select
aria-label={`Брой: ${item.name}`}
value={item.qty}
onChange={(e) => cart.setQty(item.id, Number(e.target.value))}
>
{upTo(item.stock).map((n) => (
<option key={n}>{n}</option>
))}
</select>
<span>{money(item.price * item.qty)}</span>
<button type="button" aria-label={`Махни ${item.name}`} onClick={() => cart.remove(item.id)}>
Махни
</button>
</li>
))}
</ul>
<p className="price">
Общо: {money(cart.total)}
</p>
<Link to="/checkout">Към поръчката</Link>
</>
);
}
pages/CheckoutPage.jsximport { useState } from 'react';
import { Link } from 'react-router';
import { placeOrder } from '../api.js';
import Field from '../components/Field.jsx';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
function check(form) {
const errors = {};
if (!form.name.trim()) errors.name = 'Въведи име.';
if (!form.email.includes('@')) errors.email = 'Въведи имейл.';
if (!form.address.trim()) errors.address = 'Въведи адрес.';
return errors;
}
export default function CheckoutPage() {
const cart = useCart();
const [form, setForm] = useState({ name: '', email: '', address: '' });
const [errors, setErrors] = useState({});
const [sending, setSending] = useState(false);
const [order, setOrder] = useState(null);
const change = (e) => setForm({ ...form, [e.target.name]: e.target.value });
async function submit(e) {
e.preventDefault();
const found = check(form);
setErrors(found);
if (Object.keys(found).length > 0) return;
setSending(true);
try {
const items = cart.items.map((i) => ({ productId: i.id, qty: i.qty }));
setOrder(await placeOrder({ ...form, items }));
cart.clear();
} catch (err) {
setErrors(err.errors ?? { form: err.message });
} finally {
setSending(false);
}
}
if (order)
return (
<>
<h1>Поръчката е приета</h1>
<p role="status">
Поръчка № {order.id} за {money(order.total)}.
</p>
<p>
<Link to="/orders">Моите поръчки</Link>
</p>
</>
);
if (cart.items.length === 0)
return (
<>
<h1>Поръчка</h1>
<p>Кошницата е празна.</p>
</>
);
return (
<>
<h1>Поръчка</h1>
<p className="price">
Общо: {money(cart.total)}
</p>
<form noValidate onSubmit={submit}>
<Field name="name" label="Име" value={form.name} error={errors.name} onChange={change} />
<Field name="email" label="Имейл" type="email" value={form.email} error={errors.email} onChange={change} />
<Field name="address" label="Адрес" value={form.address} error={errors.address} onChange={change} />
{errors.items && <p role="alert">{errors.items}</p>}
{errors.form && <p role="alert">{errors.form}</p>}
<button disabled={sending}>{sending ? 'Изпраща…' : 'Поръчай'}</button>
</form>
</>
);
}
pages/OrdersPage.jsxexport default function OrdersPage() {
return (
<>
<h1>Моите поръчки</h1>
<p>Тук идват поръчките ти.</p>
</>
);
}
pages/NotFoundPage.jsximport { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<h1>Няма такава страница</h1>
<p>
<Link to="/">Към каталога</Link>
</p>
</>
);
}
components/ProductCard.jsximport { Link } from 'react-router';
import { money } from '../format.js';
export default function ProductCard({ product }) {
return (
<li className="card">
<h2>
<Link to={`/product/${product.id}`}>{product.name}</Link>
</h2>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
</li>
);
}
components/Filter.jsxexport default function Filter({ categories, q, category, onQChange, onCategoryChange }) {
// Продуктите са в подкатегориите: „Храни“ и „Дом“ са само групи.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Търси
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Категория
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">Всички</option>
{groups.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
</form>
);
}
components/Field.jsxexport default function Field({ name, label, type = 'text', value, error, onChange }) {
const errorId = `${name}-error`;
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<span id={errorId} className="error">
{error}
</span>
)}
</div>
);
}
store/cart.jsximport { createContext, useContext, useState } from 'react';
const CartContext = createContext(null);
export function CartProvider({ children }) {
const [items, setItems] = useState([]);
function add(product) {
const found = items.find((i) => i.id === product.id);
if (found) setItems(items.map((i) => (i.id === product.id ? { ...i, qty: i.qty + 1 } : i)));
else setItems([...items, { id: product.id, name: product.name, price: product.price, stock: product.stock, qty: 1 }]);
}
function setQty(id, qty) {
setItems(items.map((i) => (i.id === id ? { ...i, qty } : i)));
}
function remove(id) {
setItems(items.filter((i) => i.id !== id));
}
function clear() {
setItems([]);
}
const count = items.reduce((sum, i) => sum + i.qty, 0);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return <CartContext value={{ items, count, total, add, setQty, remove, clear }}>{children}</CartContext>;
}
export function useCart() {
return useContext(CartContext);
}
api.js// Всички заявки към 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.jsximport { createRoot } from 'react-dom/client';
import { MemoryRouter } from 'react-router';
import App from './App.jsx';
import { CartProvider } from './store/cart.jsx';
import './style.css';
// Пясъчникът няма адресна лента, затова историята е в паметта.
// В истинския проект: BrowserRouter.
createRoot(document.getElementById('root')).render(
<MemoryRouter>
<CartProvider>
<App />
</CartProvider>
</MemoryRouter>,
);
App.jsximport { NavLink, Route, Routes } from 'react-router';
import CartPage from './pages/CartPage.jsx';
import CatalogPage from './pages/CatalogPage.jsx';
import CheckoutPage from './pages/CheckoutPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
import OrdersPage from './pages/OrdersPage.jsx';
import ProductPage from './pages/ProductPage.jsx';
import { useCart } from './store/cart.jsx';
export default function App() {
const cart = useCart();
return (
<>
<header>
<strong>Магазинът</strong>
<nav aria-label="Главно меню">
<NavLink to="/" end>Каталог</NavLink>
<NavLink to="/cart">Кошница ({cart.count})</NavLink>
<NavLink to="/orders">Моите поръчки</NavLink>
</nav>
</header>
<main>
<Routes>
<Route path="/" element={<CatalogPage />} />
<Route path="/product/:id" element={<ProductPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
</>
);
}
pages/CatalogPage.jsximport { useEffect, useState } from 'react';
import { getCategories, getProducts } from '../api.js';
import Filter from '../components/Filter.jsx';
import ProductCard from '../components/ProductCard.jsx';
export default function CatalogPage() {
const [q, setQ] = useState('');
const [category, setCategory] = useState('');
const [categories, setCategories] = useState([]);
const [products, setProducts] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
getCategories()
.then(setCategories)
.catch(() => setCategories([]));
}, []);
useEffect(() => {
const controller = new AbortController();
setProducts(null);
setError(null);
getProducts({ q, category }, controller.signal)
.then(setProducts)
.catch((e) => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort();
}, [q, category]);
return (
<>
<h1>Каталог</h1>
<Filter categories={categories} q={q} category={category} onQChange={setQ} onCategoryChange={setCategory} />
{error && <p role="alert">{error}</p>}
{!error && !products && <p role="status">Зарежда…</p>}
{products && products.length === 0 && <p>Няма такива продукти.</p>}
{products && products.length > 0 && (
<ul className="grid">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
)}
</>
);
}
pages/ProductPage.jsximport { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { getProduct, getProducts } from '../api.js';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
export default function ProductPage() {
const { id } = useParams();
const cart = useCart();
const [product, setProduct] = useState(null);
const [related, setRelated] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
const p = await getProduct(id, controller.signal);
setProduct(p);
if (p.categoryId === null) return;
const same = await getProducts({ category: p.categoryId }, controller.signal);
setRelated(same.filter((x) => x.id !== p.id).slice(0, 3));
}
setProduct(null);
setRelated([]);
setError(null);
load().catch((e) => {
if (e.name !== 'AbortError') setError(e.status === 404 ? 'Няма такъв продукт.' : e.message);
});
return () => controller.abort();
}, [id]);
if (error) return <p role="alert">{error}</p>;
if (!product) return <p role="status">Зарежда…</p>;
const inCart = cart.items.find((i) => i.id === product.id)?.qty ?? 0;
return (
<article>
<p>
<Link to="/">← Към каталога</Link>
</p>
<h1>{product.name}</h1>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
<p>{product.stock > 0 ? `В наличност: ${product.stock} бр.` : 'Изчерпан'}</p>
<button type="button" disabled={product.stock === 0} onClick={() => cart.add(product)}>
Добави в кошницата
</button>
{inCart > 0 && <p role="status">В кошницата: {inCart}</p>}
{related.length > 0 && (
<section aria-labelledby="related-title">
<h2 id="related-title">Още от категорията</h2>
<ul>
{related.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</section>
)}
</article>
);
}
pages/CartPage.jsximport { Link } from 'react-router';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
const upTo = (n) => Array.from({ length: Math.min(n, 10) }, (_, i) => i + 1);
export default function CartPage() {
const cart = useCart();
if (cart.items.length === 0)
return (
<>
<h1>Кошница</h1>
<p>Кошницата е празна.</p>
<p>
<Link to="/">Към каталога</Link>
</p>
</>
);
return (
<>
<h1>Кошница</h1>
<ul className="cart">
{cart.items.map((item) => (
<li key={item.id}>
<span>{item.name}</span>
<select
aria-label={`Брой: ${item.name}`}
value={item.qty}
onChange={(e) => cart.setQty(item.id, Number(e.target.value))}
>
{upTo(item.stock).map((n) => (
<option key={n}>{n}</option>
))}
</select>
<span>{money(item.price * item.qty)}</span>
<button type="button" aria-label={`Махни ${item.name}`} onClick={() => cart.remove(item.id)}>
Махни
</button>
</li>
))}
</ul>
<p className="price">
Общо: {money(cart.total)}
</p>
<Link to="/checkout">Към поръчката</Link>
</>
);
}
pages/CheckoutPage.jsximport { useState } from 'react';
import { Link } from 'react-router';
import { placeOrder } from '../api.js';
import Field from '../components/Field.jsx';
import { money } from '../format.js';
import { useCart } from '../store/cart.jsx';
function check(form) {
const errors = {};
if (!form.name.trim()) errors.name = 'Въведи име.';
if (!form.email.includes('@')) errors.email = 'Въведи имейл.';
if (!form.address.trim()) errors.address = 'Въведи адрес.';
return errors;
}
export default function CheckoutPage() {
const cart = useCart();
const [form, setForm] = useState({ name: '', email: '', address: '' });
const [errors, setErrors] = useState({});
const [sending, setSending] = useState(false);
const [order, setOrder] = useState(null);
const change = (e) => setForm({ ...form, [e.target.name]: e.target.value });
async function submit(e) {
e.preventDefault();
const found = check(form);
setErrors(found);
if (Object.keys(found).length > 0) return;
setSending(true);
try {
const items = cart.items.map((i) => ({ productId: i.id, qty: i.qty }));
setOrder(await placeOrder({ ...form, items }));
cart.clear();
} catch (err) {
setErrors(err.errors ?? { form: err.message });
} finally {
setSending(false);
}
}
if (order)
return (
<>
<h1>Поръчката е приета</h1>
<p role="status">
Поръчка № {order.id} за {money(order.total)}.
</p>
<p>
<Link to="/orders">Моите поръчки</Link>
</p>
</>
);
if (cart.items.length === 0)
return (
<>
<h1>Поръчка</h1>
<p>Кошницата е празна.</p>
</>
);
return (
<>
<h1>Поръчка</h1>
<p className="price">
Общо: {money(cart.total)}
</p>
<form noValidate onSubmit={submit}>
<Field name="name" label="Име" value={form.name} error={errors.name} onChange={change} />
<Field name="email" label="Имейл" type="email" value={form.email} error={errors.email} onChange={change} />
<Field name="address" label="Адрес" value={form.address} error={errors.address} onChange={change} />
{errors.items && <p role="alert">{errors.items}</p>}
{errors.form && <p role="alert">{errors.form}</p>}
<button disabled={sending}>{sending ? 'Изпраща…' : 'Поръчай'}</button>
</form>
</>
);
}
pages/OrdersPage.jsximport { useEffect, useState } from 'react';
import { ME, getOrders } from '../api.js';
import { date, money, statusText } from '../format.js';
export default function OrdersPage() {
const [orders, setOrders] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
getOrders(controller.signal)
.then((all) => setOrders(all.filter((o) => o.customerId === ME).sort((a, b) => b.id - a.id)))
.catch((e) => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort();
}, []);
return (
<>
<h1 id="orders-title">Моите поръчки</h1>
{error && <p role="alert">{error}</p>}
{!error && !orders && <p role="status">Зарежда…</p>}
{orders && orders.length === 0 && <p>Още нямаш поръчки.</p>}
{orders && orders.length > 0 && (
<ul className="orders" aria-labelledby="orders-title">
{orders.map((o) => (
<li key={o.id}>
<h2>Поръчка № {o.id}</h2>
<p className="muted">
{date(o.date)} · {statusText(o.status)} · {money(o.total)}
</p>
<p>{o.items.map((i) => `${i.name} × ${i.qty}`).join(', ')}</p>
</li>
))}
</ul>
)}
</>
);
}
pages/NotFoundPage.jsximport { Link } from 'react-router';
export default function NotFoundPage() {
return (
<>
<h1>Няма такава страница</h1>
<p>
<Link to="/">Към каталога</Link>
</p>
</>
);
}
components/ProductCard.jsximport { Link } from 'react-router';
import { money } from '../format.js';
export default function ProductCard({ product }) {
return (
<li className="card">
<h2>
<Link to={`/product/${product.id}`}>{product.name}</Link>
</h2>
{product.category && <p className="muted">{product.category}</p>}
<p className="price">{money(product.price)}</p>
</li>
);
}
components/Filter.jsxexport default function Filter({ categories, q, category, onQChange, onCategoryChange }) {
// Продуктите са в подкатегориите: „Храни“ и „Дом“ са само групи.
const groups = categories.filter((c) => c.parentId !== null);
return (
<form className="filter" role="search" onSubmit={(e) => e.preventDefault()}>
<label>
Търси
<input type="search" value={q} onChange={(e) => onQChange(e.target.value)} />
</label>
<label>
Категория
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="">Всички</option>
{groups.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
</form>
);
}
components/Field.jsxexport default function Field({ name, label, type = 'text', value, error, onChange }) {
const errorId = `${name}-error`;
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<span id={errorId} className="error">
{error}
</span>
)}
</div>
);
}
store/cart.jsximport { createContext, useContext, useState } from 'react';
const CartContext = createContext(null);
export function CartProvider({ children }) {
const [items, setItems] = useState([]);
function add(product) {
const found = items.find((i) => i.id === product.id);
if (found) setItems(items.map((i) => (i.id === product.id ? { ...i, qty: i.qty + 1 } : i)));
else setItems([...items, { id: product.id, name: product.name, price: product.price, stock: product.stock, qty: 1 }]);
}
function setQty(id, qty) {
setItems(items.map((i) => (i.id === id ? { ...i, qty } : i)));
}
function remove(id) {
setItems(items.filter((i) => i.id !== id));
}
function clear() {
setItems([]);
}
const count = items.reduce((sum, i) => sum + i.qty, 0);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return <CartContext value={{ items, count, total, add, setQty, remove, clear }}>{children}</CartContext>;
}
export function useCart() {
return useContext(CartContext);
}
api.js// Всички заявки към 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или на сървъра. - Адресите са истински.
BrowserRouterвместо историята в паметта, и хостинг, който връщаindex.htmlза всеки адрес.
По-точно, за любопитните
Двете библиотеки. Пътят е един и същ: адреси, данни от API, обща кошница, формуляр с грешки по поле. React го прави с hooks: useEffect със зависимости, Context за кошницата, контролирани полета с value и onChange. Другата библиотека решава същите задачи по свой начин.
Тестовете. Сценариите на стъпките кликат и пишат като човек. Същото пишеш с Testing Library: render, screen.getByRole, userEvent. Как, е в урока за тестовете.
Бързината. Каталогът тегли при всеки натиснат клавиш. С отказа на старата заявка това е вярно, но излишно. В истинския проект търсенето чака 300 ms тишина, преди да тръгне (debounce).
Идеята
Какво следва
Пренеси витрината в истински проект, както в урока „От пясъчника към проект“:
npm create vite@latest shop -- --template reactcd shopnpm install react-routerКопирай папките pages/, components/, store/ и файловете api.js, format.js, style.css в src/. В main.jsx смени MemoryRouter с BrowserRouter.
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: празен формуляр показва трите грешки и не праща заявка.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Общо състояние · Рутиране · Тестове на компоненти · От пясъчника към проект
- Задачи по темата: Проект: витрината на магазина