8 Commits

Author SHA1 Message Date
a27c99a267 full, i am sure 2025-12-16 14:14:01 +04:00
03f774c8f4 full 2025-12-12 15:34:40 +04:00
9cb68ecec9 full 2025-10-17 10:16:18 +04:00
8a7eb58d7c LabWork_2.2 2025-10-02 13:27:17 +04:00
8a19ddbffb LabWork_2.1 2025-10-01 22:54:23 +04:00
8f9394c01d Отчёт 2025-05-24 13:32:30 +04:00
600eb67f5a всё 2025-05-24 11:06:40 +04:00
f4a1992f8d главная страница 2025-05-22 22:00:03 +04:00
127 changed files with 23986 additions and 1586 deletions

14
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
"configurations": [
{
"type": "java",
"name": "Spring Boot-ServerApplication<server>",
"request": "launch",
"cwd": "${workspaceFolder}",
"mainClass": "com.example.server.ServerApplication",
"projectName": "server",
"args": "",
"envFile": "${workspaceFolder}/.env"
}
]
}

54
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,54 @@
{
"files.autoSave": "onFocusChange",
"files.eol": "\n",
"editor.detectIndentation": false,
"editor.formatOnType": false,
"editor.formatOnPaste": true,
"editor.formatOnSave": true,
"editor.tabSize": 4,
"editor.insertSpaces": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.sortImports": "explicit"
},
"editor.snippetSuggestions": "bottom",
"debug.toolBarLocation": "commandCenter",
"debug.showVariableTypes": true,
"errorLens.gutterIconsEnabled": true,
"errorLens.messageEnabled": false,
"prettier.tabWidth": 4,
"prettier.singleQuote": false,
"prettier.printWidth": 120,
"prettier.trailingComma": "es5",
"prettier.useTabs": false,
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"java.compile.nullAnalysis.mode": "automatic",
"java.configuration.updateBuildConfiguration": "automatic",
"[java]": {
"editor.pasteAs.enabled": false,
"editor.codeActionsOnSave": {
"source.organizeImports": "never",
"source.sortImports": "explicit"
}
},
"gradle.nestedProjects": true,
"java.saveActions.organizeImports": true,
"java.dependency.packagePresentation": "hierarchical",
"java.codeGeneration.hashCodeEquals.useJava7Objects": true,
"spring-boot.ls.problem.boot2.JAVA_CONSTRUCTOR_PARAMETER_INJECTION": "WARNING",
"spring.initializr.defaultLanguage": "Java"
}

View File

@@ -1,18 +1,14 @@
{
"configurations": [
{
"type": "chrome",
"name": "Debug",
"type": "java",
"name": "Spring Boot-ServerApplication<server>",
"request": "launch",
"url": "http://localhost:5173"
},
{
"type": "node",
"name": "Start",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": ["run-script", "start"],
"console": "integratedTerminal"
"cwd": "${workspaceFolder}",
"mainClass": "com.example.server.ServerApplication",
"projectName": "server",
"args": "",
"envFile": "${workspaceFolder}/.env"
}
]
}

View File

@@ -35,5 +35,9 @@
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
},
"java.configuration.updateBuildConfiguration": "interactive",
"java.compile.nullAnalysis.mode": "automatic",
"java.import.gradle.buildServer.enabled": false,
"java.import.gradle.enabled": true
}

View File

@@ -1,14 +1,21 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import CatalogPage from './pages/CatalogPage';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/js/bootstrap.bundle.min';
import BasketPage from './pages/BasketPage';
import { Route, BrowserRouter as Router, Routes } from 'react-router-dom';
import CatalogPage from './pages/CatalogPage';
import DiscountsPage from './pages/DiscountsPage';
import HomePage from './pages/HomePage';
import ContactUsPage from './pages/ContactUsPage';
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/catalog" element={<CatalogPage />} />
<Route path="/" element={<CatalogPage />} />
<Route path="/discounts" element={<DiscountsPage />} />
<Route path="/basket" element={<BasketPage />} />
<Route path="/contact" element={<ContactUsPage />} />
</Routes>
</Router>
);

View File

@@ -16,7 +16,7 @@
<nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top shadow-sm">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="index.html">
<img src="logo.png" alt="Логотип" class="logo me-2" />
<img src="../server/src/main/resources/static/assets/logo.png" alt="Логотип" class="logo me-2" />
<span class="d-none d-lg-block">Книжный магазин "Тома"</span>
<span class="d-lg-none">"Тома"</span>
</a>

View File

@@ -31,23 +31,23 @@ const BookComponent = ({ book, onEdit, onDelete, onAddToCart }) => {
{displayPrice}
</Card.Text>
<div>
<Button
variant="primary"
className="me-2 mb-2"
<Button
variant="primary"
className="me-2 mb-2"
onClick={() => onAddToCart(book.id)}
>
<BiCart /> В корзину
</Button>
<Button
variant="outline-secondary"
className="me-2 mb-2"
<Button
variant="outline-secondary"
className="me-2 mb-2"
onClick={() => onEdit(book.id)}
>
<BiEdit /> Редактировать
</Button>
<Button
variant="outline-danger"
className="mb-2"
<Button
variant="outline-danger"
className="mb-2"
onClick={() => onDelete(book.id)}
>
<BiTrash /> Удалить

View File

@@ -1,103 +1,239 @@
import React, { useState, useEffect } from 'react';
import { Modal, Button, Form } from 'react-bootstrap';
import { Modal, Button, Form, Alert } from 'react-bootstrap';
import api from '../services/api';
const BookModal = ({ show, onHide, bookId, genres, onSave }) => {
const [formData, setFormData] = useState({
title: '',
author: '',
genreId: '',
price: '',
description: '',
image: ''
});
const [selectedGenreIds, setSelectedGenreIds] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (bookId) {
api.fetchBook(bookId).then(response => {
const book = response.data;
setFormData({
title: book.title,
author: book.author,
genreId: book.genreId,
price: book.price,
description: book.description,
image: book.image?.replace('images/', '') || ''
});
});
if (bookId && show) {
loadBookData();
} else {
setFormData({
title: '',
author: '',
genreId: genres[0]?.id || '',
price: '',
description: '',
image: ''
});
resetForm();
}
}, [bookId, genres]);
}, [bookId, show]);
const handleSubmit = (e) => {
e.preventDefault();
const bookData = {
...formData,
price: Number(formData.price),
genreId: Number(formData.genreId),
image: formData.image.startsWith('http')
? formData.image
: `images/${formData.image}`
};
onSave(bookData);
const loadBookData = async () => {
if (!bookId) return;
try {
setLoading(true);
setError('');
// Загружаем данные книги
const bookResponse = await api.fetchBook(bookId);
const book = bookResponse.data;
setFormData({
title: book.title || '',
author: book.author || '',
price: book.price || '',
description: book.description || '',
image: book.image || ''
});
// Загружаем жанры книги, если есть ID
if (bookId) {
try {
const genresResponse = await api.fetchBookGenres(bookId);
const bookGenres = genresResponse.data || [];
setSelectedGenreIds(bookGenres.map(gb => gb.genre.id));
} catch (genreError) {
console.error('Ошибка загрузки жанров книги:', genreError);
setSelectedGenreIds([]);
}
}
} catch (error) {
console.error('Ошибка загрузки книги:', error);
setError('Не удалось загрузить данные книги');
} finally {
setLoading(false);
}
};
const resetForm = () => {
setFormData({
title: '',
author: '',
price: '',
description: '',
image: ''
});
setSelectedGenreIds([]);
setError('');
};
const handleGenreChange = (genreId) => {
setSelectedGenreIds(prev => {
if (prev.includes(genreId)) {
return prev.filter(id => id !== genreId);
} else {
return [...prev, genreId];
}
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
const bookData = {
title: formData.title,
author: formData.author,
price: Number(formData.price) || 0,
description: formData.description,
image: formData.image
};
let savedBook;
if (bookId) {
// Обновляем книгу
savedBook = await api.updateBook(bookId, bookData);
// Обновляем жанры книги
const currentGenresResponse = await api.fetchBookGenres(bookId);
const currentGenreIds = (currentGenresResponse.data || []).map(gb => gb.genre.id);
// Удаляем жанры, которые были убраны
for (const currentGenreId of currentGenreIds) {
if (!selectedGenreIds.includes(currentGenreId)) {
await api.deleteBookGenre(bookId, currentGenreId);
}
}
// Добавляем новые жанры
for (const newGenreId of selectedGenreIds) {
if (!currentGenreIds.includes(newGenreId)) {
await api.addBookGenre(bookId, {
genreId: newGenreId,
date: new Date().toISOString().split('T')[0] // Текущая дата в формате YYYY-MM-DD
});
}
}
} else {
// Создаем новую книгу
savedBook = await api.createBook(bookData);
const newBookId = savedBook.data.id;
// Добавляем жанры для новой книги
for (const genreId of selectedGenreIds) {
await api.addBookGenre(newBookId, {
genreId: genreId,
date: new Date().toISOString().split('T')[0]
});
}
}
onSave(bookData);
} catch (error) {
console.error('Ошибка сохранения книги:', error);
setError('Не удалось сохранить книгу');
}
};
const handleClose = () => {
resetForm();
onHide();
};
if (loading) {
return (
<Modal show={show} onHide={handleClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>{bookId ? 'Редактировать книгу' : 'Добавить книгу'}</Modal.Title>
</Modal.Header>
<Modal.Body className="text-center">
<div className="py-4">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Загрузка...</span>
</div>
<p className="mt-2">Загрузка данных...</p>
</div>
</Modal.Body>
</Modal>
);
}
return (
<Modal show={show} onHide={onHide} size="lg">
<Modal show={show} onHide={handleClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>{bookId ? 'Редактировать книгу' : 'Добавить книгу'}</Modal.Title>
</Modal.Header>
<Modal.Body>
{error && (
<Alert variant="danger" className="mb-3">
{error}
</Alert>
)}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>Название книги</Form.Label>
<Form.Label>Название книги *</Form.Label>
<Form.Control
type="text"
value={formData.title}
onChange={(e) => setFormData({...formData, title: e.target.value})}
required
placeholder="Введите название книги"
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Автор</Form.Label>
<Form.Label>Автор *</Form.Label>
<Form.Control
type="text"
value={formData.author}
onChange={(e) => setFormData({...formData, author: e.target.value})}
required
placeholder="Введите автора"
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Жанр</Form.Label>
<Form.Select
value={formData.genreId}
onChange={(e) => setFormData({...formData, genreId: e.target.value})}
required
>
{genres.map(genre => (
<option key={genre.id} value={genre.id}>{genre.name}</option>
))}
</Form.Select>
<Form.Label>Жанры</Form.Label>
<div className="border rounded p-3" style={{ maxHeight: '200px', overflowY: 'auto' }}>
{genres.length === 0 ? (
<p className="text-muted mb-0">Нет доступных жанров</p>
) : (
genres.map(genre => (
<Form.Check
key={genre.id}
type="checkbox"
id={`genre-${genre.id}`}
label={genre.name}
checked={selectedGenreIds.includes(genre.id)}
onChange={() => handleGenreChange(genre.id)}
className="mb-2"
/>
))
)}
</div>
<Form.Text className="text-muted">
Можно выбрать несколько жанров
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Цена</Form.Label>
<Form.Label>Цена *</Form.Label>
<Form.Control
type="number"
value={formData.price}
onChange={(e) => setFormData({...formData, price: e.target.value})}
required
min="0"
step="1"
placeholder="0"
/>
</Form.Group>
@@ -108,7 +244,7 @@ const BookModal = ({ show, onHide, bookId, genres, onSave }) => {
rows={3}
value={formData.description}
onChange={(e) => setFormData({...formData, description: e.target.value})}
required
placeholder="Введите описание книги"
/>
</Form.Group>
@@ -118,17 +254,20 @@ const BookModal = ({ show, onHide, bookId, genres, onSave }) => {
type="text"
value={formData.image}
onChange={(e) => setFormData({...formData, image: e.target.value})}
placeholder="Имя файла (например, book.jpg) или полный URL"
required
placeholder="URL изображения или путь к файлу"
/>
<Form.Text className="text-muted">
Можно указать имя файла из папки images (например, "book.jpg") или полный URL изображения
Можно указать полный URL (https://...) или относительный путь (images/book.jpg)
</Form.Text>
</Form.Group>
<Modal.Footer>
<Button variant="secondary" onClick={onHide}>Отмена</Button>
<Button variant="primary" type="submit">Сохранить</Button>
<Button variant="secondary" onClick={handleClose}>
Отмена
</Button>
<Button variant="primary" type="submit">
{bookId ? 'Обновить' : 'Добавить'}
</Button>
</Modal.Footer>
</Form>
</Modal.Body>

View File

@@ -1,11 +1,12 @@
import React, { useState, useEffect } from 'react';
import { Modal, Button, Card, Form } from 'react-bootstrap';
import { Modal, Button, Card, Form, Alert } from 'react-bootstrap';
import { BiTrash } from 'react-icons/bi';
import api from '../services/api';
const CartModal = ({ show, onHide, onCheckout }) => {
const [cartItems, setCartItems] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (show) {
@@ -15,25 +16,29 @@ const CartModal = ({ show, onHide, onCheckout }) => {
const loadCart = async () => {
try {
setLoading(true);
const response = await api.fetchCartItems();
const items = response.data;
const items = response.data || [];
setCartItems(items);
const newTotal = items.reduce((sum, item) => {
return sum + (item.book?.price || 0) * (item.quantity || 1);
}, 0);
setTotal(newTotal);
} catch (error) {
console.error('Ошибка загрузки корзины:', error);
} finally {
setLoading(false);
}
};
const handleQuantityChange = async (id, quantity) => {
if (quantity < 1) return;
try {
await api.updateCartItem(id, { quantity });
loadCart();
// Используем правильный метод для обновления количества
await api.updateCartItemQuantity(id, quantity);
await loadCart(); // Перезагружаем корзину
} catch (error) {
console.error('Ошибка обновления количества:', error);
}
@@ -42,77 +47,108 @@ const CartModal = ({ show, onHide, onCheckout }) => {
const handleRemoveItem = async (id) => {
try {
await api.removeFromCart(id);
loadCart();
await loadCart(); // Перезагружаем корзину
} catch (error) {
console.error('Ошибка удаления из корзины:', error);
}
};
const handleCheckoutClick = async () => {
try {
await onCheckout();
setCartItems([]);
setTotal(0);
} catch (error) {
console.error('Ошибка оформления заказа:', error);
}
};
return (
<Modal show={show} onHide={onHide} size="lg">
<Modal.Header closeButton>
<Modal.Title>Ваша корзина</Modal.Title>
<Modal.Title>🛒 Ваша корзина</Modal.Title>
</Modal.Header>
<Modal.Body>
{cartItems.length === 0 ? (
<p className="text-muted">Корзина пуста</p>
{loading ? (
<div className="text-center py-3">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Загрузка...</span>
</div>
</div>
) : cartItems.length === 0 ? (
<Alert variant="info" className="text-center">
Корзина пуста
</Alert>
) : (
<>
{cartItems.map(item => (
<Card key={item.id} className="mb-3">
<Card.Body>
<div className="row align-items-center">
<div className="col-md-2">
<img
src={item.book?.image || "images/default-book.jpg"}
alt={item.book?.title || "Без названия"}
className="img-fluid rounded"
onError={(e) => { e.target.src = 'images/default-book.jpg' }}
/>
<div className="cart-items">
{cartItems.map(item => (
<Card key={item.id} className="mb-3">
<Card.Body>
<div className="row align-items-center">
<div className="col-3 col-md-2">
<img
src={item.book?.image || "/images/default-book.jpg"}
alt={item.book?.title || "Без названия"}
className="img-fluid rounded"
style={{ maxHeight: '60px', objectFit: 'cover' }}
onError={(e) => {
e.target.src = '/images/default-book.jpg';
e.target.onerror = null;
}}
/>
</div>
<div className="col-9 col-md-6">
<h6 className="mb-1">{item.book?.title || "Без названия"}</h6>
<p className="text-muted small mb-1">{item.book?.author || "Автор не указан"}</p>
<p className="small mb-0">
<strong>Цена:</strong> {item.book?.price || 0} руб.
</p>
</div>
<div className="col-6 col-md-2 mt-2 mt-md-0">
<Form.Control
type="number"
min="1"
value={item.quantity || 1}
onChange={(e) => handleQuantityChange(item.id, parseInt(e.target.value) || 1)}
size="sm"
/>
</div>
<div className="col-6 col-md-2 text-end mt-2 mt-md-0">
<p className="fw-bold mb-1">
{(item.book?.price || 0) * (item.quantity || 1)} руб.
</p>
<Button
variant="outline-danger"
size="sm"
onClick={() => handleRemoveItem(item.id)}
>
<BiTrash />
</Button>
</div>
</div>
<div className="col-md-6">
<h5>{item.book?.title || "Без названия"}</h5>
<p className="text-muted">{item.book?.author || "Автор не указан"}</p>
<p>
Цена: {item.book?.price || 0} руб. × {item.quantity || 1} =
{(item.book?.price || 0) * (item.quantity || 1)} руб.
</p>
</div>
<div className="col-md-2">
<Form.Control
type="number"
min="1"
value={item.quantity || 1}
onChange={(e) => handleQuantityChange(item.id, parseInt(e.target.value))}
className="cart-item-quantity"
/>
</div>
<div className="col-md-2 text-center">
<Button
variant="outline-danger"
onClick={() => handleRemoveItem(item.id)}
>
<BiTrash />
</Button>
</div>
</div>
</Card.Body>
</Card>
))}
<div className="d-flex justify-content-between align-items-center mt-3">
<h4>Итого:</h4>
<h4>{total} руб.</h4>
</Card.Body>
</Card>
))}
</div>
<div className="border-top pt-3">
<div className="d-flex justify-content-between align-items-center">
<h5 className="mb-0">Общая стоимость:</h5>
<h5 className="mb-0 text-primary">{total} руб.</h5>
</div>
</div>
</>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onHide}>Продолжить покупки</Button>
<Button
variant="success"
onClick={onCheckout}
disabled={cartItems.length === 0}
<Button variant="secondary" onClick={onHide}>
Продолжить покупки
</Button>
<Button
variant="success"
onClick={handleCheckoutClick}
disabled={cartItems.length === 0 || loading}
>
Оформить заказ
</Button>

View File

@@ -1,36 +1,59 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Modal, Button, Form } from 'react-bootstrap';
const GenreModal = ({ show, onHide, onSave }) => {
const [genreName, setGenreName] = useState('');
// Сбрасываем форму при открытии/закрытии
useEffect(() => {
if (show) {
setGenreName('');
}
}, [show]);
const handleSubmit = (e) => {
e.preventDefault();
if (!genreName.trim()) return;
onSave({ name: genreName });
if (!genreName.trim()) {
alert('Введите название жанра');
return;
}
onSave({ name: genreName.trim() });
setGenreName('');
};
const handleClose = () => {
setGenreName('');
onHide();
};
return (
<Modal show={show} onHide={onHide}>
<Modal show={show} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title>Добавить жанр</Modal.Title>
<Modal.Title> Добавить жанр</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>Название жанра</Form.Label>
<Form.Label>Название жанра *</Form.Label>
<Form.Control
type="text"
value={genreName}
onChange={(e) => setGenreName(e.target.value)}
placeholder="Например: Фантастика, Детектив, Роман"
required
autoFocus
/>
<Form.Text className="text-muted">
Введите уникальное название для нового жанра
</Form.Text>
</Form.Group>
<Modal.Footer>
<Button variant="secondary" onClick={onHide}>Отмена</Button>
<Button variant="primary" type="submit">Сохранить</Button>
<Button variant="secondary" onClick={handleClose}>
Отмена
</Button>
<Button variant="primary" type="submit" disabled={!genreName.trim()}>
Добавить жанр
</Button>
</Modal.Footer>
</Form>
</Modal.Body>

View File

@@ -1,33 +1,71 @@
import React from 'react';
import { Link } from 'react-router-dom';
import logoImage from '../images/logo.png';
const Navbar = () => {
return (
<nav className="navbar navbar-expand-lg navbar-light bg-light fixed-top shadow-sm">
<div className="container">
<Link className="navbar-brand d-flex align-items-center" to="/">
<img src="logo.png" alt="Логотип" className="logo me-2" />
<span className="d-none d-lg-block">Книжный магазин "Тома"</span>
<span className="d-lg-none">"Тома"</span>
</Link>
<div className="collapse navbar-collapse" id="navbarContent">
<ul className="navbar-nav ms-auto">
<li className="nav-item">
<Link className="nav-link" to="/catalog">Каталог</Link>
</li>
<li className="nav-item">
<Link className="nav-link position-relative" to="/cart">
<i className="bi bi-cart3"></i>
<span className="cart-count badge bg-danger rounded-pill position-absolute top-0 start-100 translate-middle">
0
</span>
</Link>
</li>
</ul>
</div>
</div>
</nav>
);
const Navbar = ({ cartCount }) => {
return (
<nav className="navbar navbar-expand-lg navbar-light bg-light fixed-top shadow-sm">
<div className="container">
<Link className="navbar-brand d-flex align-items-center" to="/">
<img src={logoImage} alt="Логотип" className="logo me-2" />
<span className="d-none d-lg-block">Книжный магазин "Тома"</span>
<span className="d-lg-none">"Тома"</span>
</Link>
{/* Кнопка для мобильного меню */}
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarContent"
aria-controls="navbarContent"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span className="navbar-toggler-icon"></span>
</button>
{/* Основное меню */}
<div className="collapse navbar-collapse" id="navbarContent">
<ul className="navbar-nav ms-auto mb-2 mb-lg-0">
<li className="nav-item">
<Link className="nav-link" to="/">Главная страница</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/catalog">Каталог</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/discounts">Скидки</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/contact">Связаться с нами</Link>
</li>
<li className="nav-item d-lg-none">
<Link className="nav-link position-relative" to="/basket">
<i className="bi bi-cart3 me-1"></i> Корзина
{cartCount > 0 && (
<span className="cart-count badge bg-danger rounded-pill position-absolute top-0 start-100 translate-middle">
{cartCount}
</span>
)}
</Link>
</li>
</ul>
{/* Иконка корзины для десктопной версии */}
<div className="d-none d-lg-block ms-3">
<Link className="nav-link position-relative" to="/basket">
<i className="bi bi-cart3 fs-5"></i>
{cartCount > 0 && (
<span className="cart-count badge bg-danger rounded-pill position-absolute top-0 start-100 translate-middle">
{cartCount}
</span>
)}
</Link>
</div>
</div>
</div>
</nav>
);
};
export default Navbar;

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { Form } from 'react-bootstrap';
const PageSizeSelector = ({
pageSize,
onPageSizeChange,
// Меняем значения в выпадающем списке
options = [5, 10, 20]
}) => {
// Маппинг: что пользователь видит -> что на самом деле запрашиваем
const sizeMapping = {
3: 5, // Пользователь видит "3", запрашиваем 5
5: 10, // Пользователь видит "5", запрашиваем 10
10: 20, // Пользователь видит "10", запрашиваем 20
};
// Находим отображаемое значение для текущего реального
const getDisplayValue = (realValue) => {
for (const [display, real] of Object.entries(sizeMapping)) {
if (real === realValue) return Number(display);
}
return realValue;
};
const displayValue = getDisplayValue(pageSize);
return (
<div className="d-flex align-items-center">
<span className="me-2">Книг на странице:</span>
<Form.Select
size="sm"
style={{ width: 'auto' }}
value={displayValue}
onChange={(e) => {
const selectedDisplayValue = Number(e.target.value);
const realValue = sizeMapping[selectedDisplayValue] || selectedDisplayValue;
onPageSizeChange(realValue);
}}
>
{options.map(option => (
<option key={option} value={option}>
{option}
</option>
))}
</Form.Select>
</div>
);
};
export default PageSizeSelector;

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { Pagination as BootstrapPagination } from 'react-bootstrap';
const Pagination = ({
currentPage,
totalPages,
onPageChange,
maxVisiblePages = 5
}) => {
if (totalPages <= 1) return null;
const items = [];
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
// Корректируем, если не хватает страниц
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
// Кнопка "Назад"
items.push(
<BootstrapPagination.Prev
key="prev"
disabled={currentPage === 1}
onClick={() => currentPage > 1 && onPageChange(currentPage - 1)}
/>
);
// Первая страница
if (startPage > 1) {
items.push(
<BootstrapPagination.Item
key={1}
active={1 === currentPage}
onClick={() => onPageChange(1)}
>
1
</BootstrapPagination.Item>
);
if (startPage > 2) {
items.push(<BootstrapPagination.Ellipsis key="start-ellipsis" />);
}
}
// Видимые страницы
for (let page = startPage; page <= endPage; page++) {
items.push(
<BootstrapPagination.Item
key={page}
active={page === currentPage}
onClick={() => onPageChange(page)}
>
{page}
</BootstrapPagination.Item>
);
}
// Последняя страница
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
items.push(<BootstrapPagination.Ellipsis key="end-ellipsis" />);
}
items.push(
<BootstrapPagination.Item
key={totalPages}
active={totalPages === currentPage}
onClick={() => onPageChange(totalPages)}
>
{totalPages}
</BootstrapPagination.Item>
);
}
// Кнопка "Вперед"
items.push(
<BootstrapPagination.Next
key="next"
disabled={currentPage === totalPages}
onClick={() => currentPage < totalPages && onPageChange(currentPage + 1)}
/>
);
return (
<BootstrapPagination className="justify-content-center mt-4">
{items}
</BootstrapPagination>
);
};
export default Pagination;

View File

@@ -1,156 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Свяжитесь с нами | Книжный интернет-магазин "Тома"</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" />
<!-- Ваш CSS -->
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- Навигация -->
<!-- Навигация -->
<nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top shadow-sm">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="index.html">
<img src="logo.png" alt="Логотип" class="logo me-2" />
<span class="d-none d-lg-block">Книжный магазин "Тома"</span>
<span class="d-lg-none">"Тома"</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a
class="nav-link dropdown-toggle"
href="#"
id="navbarDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Страницы
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li><a class="dropdown-item" href="index.html">Главная</a></li>
<li><a class="dropdown-item" href="catalog.html">Каталог</a></li>
<li><a class="dropdown-item" href="discounts.html">Скидки</a></li>
<li><a class="dropdown-item" href="basket.html">Корзина</a></li>
<li><a class="dropdown-item" href="contactUs.html">Контакты</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<!-- Основной контент -->
<main class="container mt-5 pt-5">
<section class="my-5">
<div class="row justify-content-center">
<div class="col-lg-8">
<h2 class="text-center mb-4">Свяжитесь с нами</h2>
<form class="needs-validation" novalidate>
<div class="mb-3">
<label for="name" class="form-label">Имя</label>
<input type="text" class="form-control" id="name" required />
<div class="invalid-feedback">Пожалуйста, введите ваше имя.</div>
</div>
<div class="mb-3">
<label for="email" class="form-label">Электронная почта</label>
<input type="email" class="form-control" id="email" required />
<div class="invalid-feedback">Пожалуйста, введите корректный email.</div>
</div>
<div class="mb-3">
<label for="purchase-code" class="form-label">Код покупки (если есть)</label>
<input type="text" class="form-control" id="purchase-code" />
</div>
<div class="mb-3">
<label for="problem-description" class="form-label">Описание проблемы</label>
<textarea class="form-control" id="problem-description" rows="6" required></textarea>
<div class="invalid-feedback">Пожалуйста, опишите вашу проблему.</div>
</div>
<div class="text-center">
<button type="submit" class="btn btn-primary btn-lg px-4">Отправить</button>
</div>
</form>
<!-- Контактная информация -->
<div class="card mt-5">
<div class="card-body">
<h5 class="card-title">Контактная информация</h5>
<ul class="list-unstyled">
<li><i class="bi bi-envelope me-2"></i> info@toma.ru</li>
<li><i class="bi bi-phone me-2"></i> +7 (123) 456-78-90</li>
<li><i class="bi bi-clock me-2"></i> Пн-Пт 9:00-18:00</li>
<li><i class="bi bi-geo-alt me-2"></i> г. Москва, ул. Литераторов, д. 1</li>
</ul>
</div>
</div>
</div>
</div>
</section>
</main>
<!-- Подвал -->
<footer class="bg-light py-4 mt-5">
<div class="container">
<div class="row">
<div class="col-md-6 mb-4 mb-md-0">
<h5 class="mb-3">Книжный магазин "Тома"</h5>
<p>Ваш надежный партнер в мире литературы с 2025 года.</p>
</div>
<div class="col-md-6 text-md-end">
<h5 class="mb-3">Социальные сети</h5>
<div class="social-media">
<a href="#" class="text-decoration-none me-3"><i class="bi bi-facebook fs-3"></i></a>
<a href="#" class="text-decoration-none me-3"><i class="bi bi-vk fs-3"></i></a>
<a href="#" class="text-decoration-none"><i class="bi bi-telegram fs-3"></i></a>
</div>
</div>
</div>
</div>
</footer>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js"></script>
<!-- Валидация формы -->
<script>
(function () {
"use strict";
const forms = document.querySelectorAll(".needs-validation");
Array.from(forms).forEach((form) => {
form.addEventListener(
"submit",
(event) => {
if (!form.checkValidity()) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add("was-validated");
},
false
);
});
})();
</script>
</body>
</html>

View File

@@ -91,27 +91,18 @@
"image": "images/foundation.jpg",
"genreId": 2,
"id": 7
},
{
"title": "рл",
"author": "л",
"genreId": 3,
"price": 2,
"description": "иоио",
"image": "https://u.9111s.ru/uploads/202308/21/7a16d872540b76031e7dbc7590bc6c1b.png",
"id": 10
}
],
"cart": [
{
"bookId": 1,
"quantity": 1,
"id": 2
"bookId": 2,
"quantity": 2,
"id": 1
},
{
"bookId": 10,
"quantity": 1,
"id": 3
"bookId": 1,
"quantity": 4,
"id": 2
}
]
}

View File

@@ -1,155 +0,0 @@
<!-- Сделать кнопку в фантастике "добавить книгу", вводим данные в форму, и карточка книги добавляется в Фантастику-->
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Скидки | Книжный интернет-магазин "Тома"</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" />
<!-- Ваш CSS -->
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- Навигация -->
<nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top shadow-sm">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="index.html">
<img src="logo.png" alt="Логотип" class="logo me-2" />
<span class="d-none d-lg-block">Книжный магазин "Тома"</span>
<span class="d-lg-none">"Тома"</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a
class="nav-link dropdown-toggle"
href="#"
id="navbarDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Страницы
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li><a class="dropdown-item" href="index.html">Главная</a></li>
<li><a class="dropdown-item" href="catalog.html">Каталог</a></li>
<li><a class="dropdown-item" href="discounts.html">Скидки</a></li>
<li><a class="dropdown-item" href="basket.html">Корзина</a></li>
<li><a class="dropdown-item" href="contactUs.html">Контакты</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<!-- Основной контент -->
<main class="container mt-5 pt-5">
<section class="my-5">
<h2 class="text-center mb-4">Скидки</h2>
<hr class="mb-4" />
<div class="row g-4 justify-content-center">
<!-- Книга 1 -->
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm">
<img
src="images/the_girl_with_the_dragon_tattoo.jpg"
class="card-img-top p-3"
alt="Девушка с татуировкой дракона"
/>
<div class="card-body text-center">
<h5 class="card-title">Девушка с татуировкой дракона</h5>
<p class="card-text">Стиг Ларссон</p>
<p class="text-muted"><s>700 р.</s></p>
<p class="text-danger fs-4 fw-bold">525 р.</p>
<p class="small text-muted">Экономия 175 р. (25%)</p>
<button class="btn btn-primary mt-2">В корзину</button>
</div>
</div>
</div>
<!-- Книга 2 -->
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm">
<img src="images/the_hobbit.webp" class="card-img-top p-3" alt="Хоббит" />
<div class="card-body text-center">
<h5 class="card-title">Хоббит</h5>
<p class="card-text">Дж.Р.Р. Толкин</p>
<p class="text-muted"><s>750 р.</s></p>
<p class="text-danger fs-4 fw-bold">563 р.</p>
<p class="small text-muted">Экономия 187 р. (25%)</p>
<button class="btn btn-primary mt-2">В корзину</button>
</div>
</div>
</div>
<!-- Книга 3 -->
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm">
<img src="images/dune.jpg" class="card-img-top p-3" alt="Дюна" />
<div class="card-body text-center">
<h5 class="card-title">Дюна</h5>
<p class="card-text">Фрэнк Герберт</p>
<p class="text-muted"><s>500 р.</s></p>
<p class="text-danger fs-4 fw-bold">375 р.</p>
<p class="small text-muted">Экономия 125 р. (25%)</p>
<button class="btn btn-primary mt-2">В корзину</button>
</div>
</div>
</div>
</div>
<hr class="my-4" />
<div class="alert alert-success text-center">
<h3>Условия получения скидки:</h3>
<p class="lead mb-0">
При покупке трех книг одновременно Вы получаете скидку 25%!<br />
Скидка действует с 1 по 15 число каждого месяца. Не упустите возможность!
</p>
</div>
</section>
</main>
<!-- Подвал -->
<footer class="bg-light py-4 mt-5">
<div class="container">
<div class="row">
<div class="col-md-6 mb-4 mb-md-0">
<h5 class="mb-3">Контакты</h5>
<ul class="list-unstyled">
<li><i class="bi bi-envelope me-2"></i> info@toma.ru</li>
<li><i class="bi bi-phone me-2"></i> +7 (123) 456-78-90</li>
<li><i class="bi bi-clock me-2"></i> Пн-Пт 9:00-18:00</li>
<li><i class="bi bi-geo-alt me-2"></i> г. Москва, ул. Литераторов, д. 1</li>
</ul>
</div>
<div class="col-md-6 text-md-end">
<h5 class="mb-3">Социальные сети</h5>
<div class="social-media">
<a href="#" class="text-decoration-none me-3"><i class="bi bi-facebook fs-3"></i></a>
<a href="#" class="text-decoration-none me-3"><i class="bi bi-vk fs-3"></i></a>
<a href="#" class="text-decoration-none"><i class="bi bi-telegram fs-3"></i></a>
</div>
</div>
</div>
</div>
</footer>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 177 KiB

View File

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 114 KiB

View File

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

Before

Width:  |  Height:  |  Size: 817 KiB

After

Width:  |  Height:  |  Size: 817 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 370 KiB

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
body{background-color:#fff;background-image:url(/assets/background-oYp1cNqc.png);background-size:100% auto;background-repeat:no-repeat;background-position:center center;background-attachment:fixed;color:#036;font-family:Arial,sans-serif;padding-top:56px;image-rendering:crisp-edges;image-rendering:high-quality;-ms-interpolation-mode:bicubic}.logo{border-radius:50%;width:50px;height:50px;object-fit:cover}.navbar-brand{font-weight:600}.card{transition:transform .3s ease;background-color:#ffffffe6}.card:hover{transform:translateY(-5px)}.btn-primary{background-color:#036;border-color:#036}.btn-primary:hover{background-color:#024;border-color:#024}@media (max-width: 768px){.navbar-brand span{font-size:1rem}body{background-size:auto;background-position:center}}.social-media a{color:#036;transition:color .3s ease}.social-media a:hover{color:#024}.card-img-top{height:300px;object-fit:contain}@media (max-width: 576px){.card{margin-bottom:20px}footer .col-md-6{text-align:center!important;margin-bottom:20px}}

View File

@@ -1,200 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Корзина | Книжный интернет-магазин "Тома"</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" />
<!-- Ваш CSS -->
<link rel="stylesheet" crossorigin href="/assets/style-CVNOuhRW.css">
</head>
<body>
<!-- Навигация -->
<nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top shadow-sm">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="index.html">
<img src="/assets/logo-DsrEtJYJ.png" alt="Логотип" class="logo me-2" />
<span class="d-none d-lg-block">Книжный магазин "Тома"</span>
<span class="d-lg-none">"Тома"</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a
class="nav-link dropdown-toggle"
href="#"
id="navbarDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Страницы
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li><a class="dropdown-item" href="index.html">Главная</a></li>
<li><a class="dropdown-item" href="catalog.html">Каталог</a></li>
<li><a class="dropdown-item" href="discounts.html">Скидки</a></li>
<li><a class="dropdown-item" href="basket.html">Корзина</a></li>
<li><a class="dropdown-item" href="contactUs.html">Контакты</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link position-relative show-cart-btn" href="#">
<i class="bi bi-cart3"></i>
<span
class="cart-count badge bg-danger rounded-pill position-absolute top-0 start-100 translate-middle"
style="display: none"
></span>
</a>
</li>
</ul>
</div>
</div>
</nav>
<!-- Основной контент -->
<main class="container mt-5 pt-5">
<div class="row justify-content-center">
<div class="col-lg-10">
<h2 class="text-center mb-4">Ваша корзина</h2>
<!-- Список товаров -->
<div id="cartItemsContainer">
<!-- Товары будут загружены через JavaScript -->
</div>
<!-- Итоговая информация -->
<div class="card mb-4 border-danger">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<h4 class="mb-0">Общая стоимость:</h4>
<h4 class="mb-0" id="cartTotal">0 руб.</h4>
</div>
</div>
</div>
<!-- Кнопки действий -->
<div class="d-flex justify-content-between mb-5">
<a href="catalog.html" class="btn btn-outline-primary">
<i class="bi bi-arrow-left me-2"></i>Продолжить покупки
</a>
<div>
<button id="clearCartBtn" class="btn btn-outline-danger me-2">
<i class="bi bi-trash me-2"></i>Очистить корзину
</button>
<button id="checkoutBtn" class="btn btn-success btn-lg px-4">
<i class="bi bi-credit-card me-2"></i>Оформить заказ
</button>
</div>
</div>
</div>
</div>
</main>
<!-- Подвал -->
<footer class="bg-light py-4 mt-5">
<div class="container">
<div class="row">
<div class="col-md-6 mb-4 mb-md-0">
<h5 class="mb-3">Контакты</h5>
<ul class="list-unstyled">
<li><i class="bi bi-envelope me-2"></i> info@toma.ru</li>
<li><i class="bi bi-phone me-2"></i> +7 (123) 456-78-90</li>
<li><i class="bi bi-clock me-2"></i> Пн-Пт 9:00-18:00</li>
<li><i class="bi bi-geo-alt me-2"></i> г. Москва, ул. Литераторов, д. 1</li>
</ul>
</div>
<div class="col-md-6 text-md-end">
<h5 class="mb-3">Социальные сети</h5>
<div class="social-media">
<a href="#" class="text-decoration-none me-3"><i class="bi bi-facebook fs-3"></i></a>
<a href="#" class="text-decoration-none me-3"><i class="bi bi-vk fs-3"></i></a>
<a href="#" class="text-decoration-none"><i class="bi bi-telegram fs-3"></i></a>
</div>
</div>
</div>
</div>
</footer>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js"></script>
<!-- Наш компонент -->
<script src="bookComponent.js"></script>
<script>
// Инициализация корзины на странице basket.html
document.addEventListener("DOMContentLoaded", async () => {
const model = new BookModel();
const view = new BookView();
const controller = new BookController(model, view);
// Загружаем и отображаем корзину при загрузке страницы
const cartItems = await model.fetchCartItems();
view.renderCart(cartItems);
// Обновляем счетчик в навигации
await controller.updateCartCount();
// Обработчики для кнопок на странице корзины
document.getElementById("clearCartBtn").addEventListener("click", async () => {
if (confirm("Вы уверены, что хотите очистить корзину?")) {
await model.clearCart();
view.renderCart([]);
await controller.updateCartCount();
}
});
document.getElementById("checkoutBtn").addEventListener("click", () => {
alert("Заказ оформлен! Спасибо за покупку!");
model.clearCart();
view.renderCart([]);
controller.updateCartCount();
});
// Обработчик изменения количества товаров
document.addEventListener("change", async (event) => {
if (event.target.classList.contains("cart-item-quantity")) {
const id = parseInt(event.target.dataset.id);
const quantity = parseInt(event.target.value);
if (quantity > 0) {
await model.updateCartItem(id, { quantity });
const cartItems = await model.fetchCartItems();
view.renderCart(cartItems);
await controller.updateCartCount();
} else {
event.target.value = 1;
}
}
});
// Обработчик удаления товаров
document.addEventListener("click", async (event) => {
if (
event.target.classList.contains("remove-from-cart") ||
event.target.closest(".remove-from-cart")
) {
const id = parseInt(
event.target.dataset.id || event.target.closest(".remove-from-cart").dataset.id
);
await model.removeFromCart(id);
const cartItems = await model.fetchCartItems();
view.renderCart(cartItems);
await controller.updateCartCount();
}
});
});
</script>
</body>

View File

@@ -1,211 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Каталог | Книжный интернет-магазин "Тома"</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" />
<link rel="stylesheet" crossorigin href="/assets/style-CVNOuhRW.css">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top shadow-sm">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="index.html">
<img src="/assets/logo-DsrEtJYJ.png" alt="Логотип" class="logo me-2" />
<span class="d-none d-lg-block">Книжный магазин "Тома"</span>
<span class="d-lg-none">"Тома"</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a
class="nav-link dropdown-toggle"
href="#"
id="navbarDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Страницы
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li><a class="dropdown-item" href="index.html">Главная</a></li>
<li><a class="dropdown-item" href="catalog.html">Каталог</a></li>
<li><a class="dropdown-item" href="discounts.html">Скидки</a></li>
<li><a class="dropdown-item" href="basket.html">Корзина</a></li>
<li><a class="dropdown-item" href="contactUs.html">Контакты</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link position-relative show-cart-btn" href="#">
<i class="bi bi-cart3"></i>
<span
class="cart-count badge bg-danger rounded-pill position-absolute top-0 start-100 translate-middle"
style="display: none"
></span>
</a>
</li>
</ul>
</div>
</div>
</nav>
<main class="container mt-5 pt-5">
<h2 class="text-center mb-5">Каталог книг</h2>
<div class="d-flex justify-content-between mb-2">
<div>
<button class="btn btn-success add-book-btn">
<i class="bi bi-plus-circle"></i> Добавить книгу
</button>
<button class="btn btn-primary add-genre-btn ms-2">
<i class="bi bi-plus-circle"></i> Добавить жанр
</button>
</div>
</div>
<section class="mb-5">
<div class="row g-4" id="fantasy-books"></div>
</section>
<section class="mb-5">
<div class="row g-4" id="detective-books"></div>
</section>
<section class="mb-5">
<div class="row g-4" id="novel-books"></div>
</section>
<section class="mb-5">
<div class="row g-4" id="fantasy2-books"></div>
</section>
</main>
<!-- Модальное окно для добавления/редактирования книги -->
<div class="modal fade" id="bookModal" tabindex="-1" aria-labelledby="bookModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="bookModalLabel">Добавить книгу</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="bookForm">
<input type="hidden" id="bookId" />
<div class="mb-3">
<label for="bookTitle" class="form-label">Название книги</label>
<input type="text" class="form-control" id="bookTitle" required />
</div>
<div class="mb-3">
<label for="bookAuthor" class="form-label">Автор</label>
<input type="text" class="form-control" id="bookAuthor" required />
</div>
<div class="mb-3">
<label for="bookGenre" class="form-label">Жанр</label>
<select class="form-select" id="bookGenre" required></select>
</div>
<div class="mb-3">
<label for="bookPrice" class="form-label">Цена</label>
<input type="number" class="form-control" id="bookPrice" required />
</div>
<div class="mb-3">
<label for="bookDescription" class="form-label">Описание</label>
<textarea class="form-control" id="bookDescription" rows="3" required></textarea>
</div>
<div class="mb-3">
<label for="bookImage" class="form-label">Изображение</label>
<input
type="text"
class="form-control"
id="bookImage"
placeholder="Имя файла (например, book.jpg) или полный URL"
required
/>
<div class="form-text">
Можно указать имя файла из папки images (например, "book.jpg") или полный URL
изображения
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Модальное окно для добавления жанра -->
<div class="modal fade" id="genreModal" tabindex="-1" aria-labelledby="genreModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="genreModalLabel">Добавить жанр</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="genreForm">
<div class="mb-3">
<label for="genreName" class="form-label">Название жанра</label>
<input type="text" class="form-control" id="genreName" required />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Модальное окно корзины -->
<div class="modal fade" id="cartModal" tabindex="-1" aria-labelledby="cartModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="cartModalLabel">Ваша корзина</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="cartItems"></div>
<div class="d-flex justify-content-between align-items-center mt-3">
<h4>Итого:</h4>
<h4 id="cartTotal">0 руб.</h4>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Продолжить покупки
</button>
<button id="checkoutBtnModal" class="btn btn-success">Оформить заказ</button>
</div>
</div>
</div>
</div>
<footer class="bg-light py-4 mt-5">
<div class="container">
<div class="row">
<div class="col-md-6 mb-4 mb-md-0">
<h5 class="mb-3">Контакты</h5>
<ul class="list-unstyled">
<li><i class="bi bi-envelope me-2"></i> info@toma.ru</li>
<li><i class="bi bi-phone me-2"></i> +7 (123) 456-78-90</li>
<li><i class="bi bi-clock me-2"></i> Пн-Пт 9:00-18:00</li>
<li><i class="bi bi-geo-alt me-2"></i> г. Москва, ул. Литераторов, д. 1</li>
</ul>
</div>
<div class="col-md-6 text-md-end">
<h5 class="mb-3">Социальные сети</h5>
<div class="social-media">
<a href="#" class="text-decoration-none me-3"><i class="bi bi-facebook fs-3"></i></a>
<a href="#" class="text-decoration-none me-3"><i class="bi bi-vk fs-3"></i></a>
<a href="#" class="text-decoration-none"><i class="bi bi-telegram fs-3"></i></a>
</div>
</div>
</div>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js"></script>
<script src="bookComponent.js"></script>
</body>

View File

@@ -1,156 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Свяжитесь с нами | Книжный интернет-магазин "Тома"</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" />
<!-- Ваш CSS -->
<link rel="stylesheet" crossorigin href="/assets/style-CVNOuhRW.css">
</head>
<body>
<!-- Навигация -->
<!-- Навигация -->
<nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top shadow-sm">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="index.html">
<img src="/assets/logo-DsrEtJYJ.png" alt="Логотип" class="logo me-2" />
<span class="d-none d-lg-block">Книжный магазин "Тома"</span>
<span class="d-lg-none">"Тома"</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a
class="nav-link dropdown-toggle"
href="#"
id="navbarDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Страницы
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li><a class="dropdown-item" href="index.html">Главная</a></li>
<li><a class="dropdown-item" href="catalog.html">Каталог</a></li>
<li><a class="dropdown-item" href="discounts.html">Скидки</a></li>
<li><a class="dropdown-item" href="basket.html">Корзина</a></li>
<li><a class="dropdown-item" href="contactUs.html">Контакты</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<!-- Основной контент -->
<main class="container mt-5 pt-5">
<section class="my-5">
<div class="row justify-content-center">
<div class="col-lg-8">
<h2 class="text-center mb-4">Свяжитесь с нами</h2>
<form class="needs-validation" novalidate>
<div class="mb-3">
<label for="name" class="form-label">Имя</label>
<input type="text" class="form-control" id="name" required />
<div class="invalid-feedback">Пожалуйста, введите ваше имя.</div>
</div>
<div class="mb-3">
<label for="email" class="form-label">Электронная почта</label>
<input type="email" class="form-control" id="email" required />
<div class="invalid-feedback">Пожалуйста, введите корректный email.</div>
</div>
<div class="mb-3">
<label for="purchase-code" class="form-label">Код покупки (если есть)</label>
<input type="text" class="form-control" id="purchase-code" />
</div>
<div class="mb-3">
<label for="problem-description" class="form-label">Описание проблемы</label>
<textarea class="form-control" id="problem-description" rows="6" required></textarea>
<div class="invalid-feedback">Пожалуйста, опишите вашу проблему.</div>
</div>
<div class="text-center">
<button type="submit" class="btn btn-primary btn-lg px-4">Отправить</button>
</div>
</form>
<!-- Контактная информация -->
<div class="card mt-5">
<div class="card-body">
<h5 class="card-title">Контактная информация</h5>
<ul class="list-unstyled">
<li><i class="bi bi-envelope me-2"></i> info@toma.ru</li>
<li><i class="bi bi-phone me-2"></i> +7 (123) 456-78-90</li>
<li><i class="bi bi-clock me-2"></i> Пн-Пт 9:00-18:00</li>
<li><i class="bi bi-geo-alt me-2"></i> г. Москва, ул. Литераторов, д. 1</li>
</ul>
</div>
</div>
</div>
</div>
</section>
</main>
<!-- Подвал -->
<footer class="bg-light py-4 mt-5">
<div class="container">
<div class="row">
<div class="col-md-6 mb-4 mb-md-0">
<h5 class="mb-3">Книжный магазин "Тома"</h5>
<p>Ваш надежный партнер в мире литературы с 2025 года.</p>
</div>
<div class="col-md-6 text-md-end">
<h5 class="mb-3">Социальные сети</h5>
<div class="social-media">
<a href="#" class="text-decoration-none me-3"><i class="bi bi-facebook fs-3"></i></a>
<a href="#" class="text-decoration-none me-3"><i class="bi bi-vk fs-3"></i></a>
<a href="#" class="text-decoration-none"><i class="bi bi-telegram fs-3"></i></a>
</div>
</div>
</div>
</div>
</footer>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js"></script>
<!-- Валидация формы -->
<script>
(function () {
"use strict";
const forms = document.querySelectorAll(".needs-validation");
Array.from(forms).forEach((form) => {
form.addEventListener(
"submit",
(event) => {
if (!form.checkValidity()) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add("was-validated");
},
false
);
});
})();
</script>
</body>

View File

@@ -1,155 +0,0 @@
<!-- Сделать кнопку в фантастике "добавить книгу", вводим данные в форму, и карточка книги добавляется в Фантастику-->
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Скидки | Книжный интернет-магазин "Тома"</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" />
<!-- Ваш CSS -->
<link rel="stylesheet" crossorigin href="/assets/style-CVNOuhRW.css">
</head>
<body>
<!-- Навигация -->
<nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top shadow-sm">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="index.html">
<img src="/assets/logo-DsrEtJYJ.png" alt="Логотип" class="logo me-2" />
<span class="d-none d-lg-block">Книжный магазин "Тома"</span>
<span class="d-lg-none">"Тома"</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a
class="nav-link dropdown-toggle"
href="#"
id="navbarDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Страницы
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li><a class="dropdown-item" href="index.html">Главная</a></li>
<li><a class="dropdown-item" href="catalog.html">Каталог</a></li>
<li><a class="dropdown-item" href="discounts.html">Скидки</a></li>
<li><a class="dropdown-item" href="basket.html">Корзина</a></li>
<li><a class="dropdown-item" href="contactUs.html">Контакты</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<!-- Основной контент -->
<main class="container mt-5 pt-5">
<section class="my-5">
<h2 class="text-center mb-4">Скидки</h2>
<hr class="mb-4" />
<div class="row g-4 justify-content-center">
<!-- Книга 1 -->
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm">
<img
src="/assets/the_girl_with_the_dragon_tattoo-CgrgasX2.jpg"
class="card-img-top p-3"
alt="Девушка с татуировкой дракона"
/>
<div class="card-body text-center">
<h5 class="card-title">Девушка с татуировкой дракона</h5>
<p class="card-text">Стиг Ларссон</p>
<p class="text-muted"><s>700 р.</s></p>
<p class="text-danger fs-4 fw-bold">525 р.</p>
<p class="small text-muted">Экономия 175 р. (25%)</p>
<button class="btn btn-primary mt-2">В корзину</button>
</div>
</div>
</div>
<!-- Книга 2 -->
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm">
<img src="/assets/the_hobbit-CkJ8H01T.webp" class="card-img-top p-3" alt="Хоббит" />
<div class="card-body text-center">
<h5 class="card-title">Хоббит</h5>
<p class="card-text">Дж.Р.Р. Толкин</p>
<p class="text-muted"><s>750 р.</s></p>
<p class="text-danger fs-4 fw-bold">563 р.</p>
<p class="small text-muted">Экономия 187 р. (25%)</p>
<button class="btn btn-primary mt-2">В корзину</button>
</div>
</div>
</div>
<!-- Книга 3 -->
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm">
<img src="/assets/dune-Co1F1vkB.jpg" class="card-img-top p-3" alt="Дюна" />
<div class="card-body text-center">
<h5 class="card-title">Дюна</h5>
<p class="card-text">Фрэнк Герберт</p>
<p class="text-muted"><s>500 р.</s></p>
<p class="text-danger fs-4 fw-bold">375 р.</p>
<p class="small text-muted">Экономия 125 р. (25%)</p>
<button class="btn btn-primary mt-2">В корзину</button>
</div>
</div>
</div>
</div>
<hr class="my-4" />
<div class="alert alert-success text-center">
<h3>Условия получения скидки:</h3>
<p class="lead mb-0">
При покупке трех книг одновременно Вы получаете скидку 25%!<br />
Скидка действует с 1 по 15 число каждого месяца. Не упустите возможность!
</p>
</div>
</section>
</main>
<!-- Подвал -->
<footer class="bg-light py-4 mt-5">
<div class="container">
<div class="row">
<div class="col-md-6 mb-4 mb-md-0">
<h5 class="mb-3">Контакты</h5>
<ul class="list-unstyled">
<li><i class="bi bi-envelope me-2"></i> info@toma.ru</li>
<li><i class="bi bi-phone me-2"></i> +7 (123) 456-78-90</li>
<li><i class="bi bi-clock me-2"></i> Пн-Пт 9:00-18:00</li>
<li><i class="bi bi-geo-alt me-2"></i> г. Москва, ул. Литераторов, д. 1</li>
</ul>
</div>
<div class="col-md-6 text-md-end">
<h5 class="mb-3">Социальные сети</h5>
<div class="social-media">
<a href="#" class="text-decoration-none me-3"><i class="bi bi-facebook fs-3"></i></a>
<a href="#" class="text-decoration-none me-3"><i class="bi bi-vk fs-3"></i></a>
<a href="#" class="text-decoration-none"><i class="bi bi-telegram fs-3"></i></a>
</div>
</div>
</div>
</div>
</footer>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js"></script>
</body>

View File

@@ -1,136 +1,21 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Книжный интернет-магазин "Тома"</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" />
<!-- Ваш CSS -->
<script type="module" crossorigin src="./assets/js/index-D_bqwX26.js"></script>
<link rel="stylesheet" crossorigin href="./assets/css/index-DvDKDXQB.css">
</head>
<body>
<body>
<!-- Навигация -->
<nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top shadow-sm">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="index.html">
<img src="/assets/logo-DsrEtJYJ.png" alt="Логотип" class="logo me-2" />
<span class="d-none d-lg-block">Книжный магазин "Тома"</span>
<span class="d-lg-none">"Тома"</span>
<div id="root"></div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a
class="nav-link dropdown-toggle"
href="#"
id="navbarDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Страницы
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li><a class="dropdown-item" href="index.html">Главная</a></li>
<li><a class="dropdown-item" href="catalog.html">Каталог</a></li>
<li><a class="dropdown-item" href="discounts.html">Скидки</a></li>
<li><a class="dropdown-item" href="basket.html">Корзина</a></li>
<li><a class="dropdown-item" href="contactUs.html">Контакты</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<!-- Основной контент -->
<main class="container mt-5 pt-5">
<section class="my-5">
<div class="row justify-content-center">
<div class="col-lg-8 text-center">
<h2 class="mb-4">Описание:</h2>
<p class="lead">
Погрузитесь в незабываемые рукописные миры!<br />
Бесчисленные литературные направления ждут вас!<br />
Познакомьтесь с популярными работами известных<br />
писателей! Мы Вам рады!
</p>
</div>
</div>
</section>
<section class="my-5">
<h2 class="text-center mb-4">Хиты продаж</h2>
<div class="row g-4 justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm">
<img src="/assets/Book1-BdJql_-B.jpg" class="card-img-top p-3" alt="Книга 1" />
<div class="card-body text-center">
<h5 class="card-title">Тимоти Брук «Шляпа Вермеера»</h5>
<button class="btn btn-primary mt-3">В корзину</button>
</div>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm">
<img src="/assets/Book2-BEB7Ih2u.jpg" class="card-img-top p-3" alt="Книга 2" />
<div class="card-body text-center">
<h5 class="card-title">Пол Линч «Песнь пророка»</h5>
<button class="btn btn-primary mt-3">В корзину</button>
</div>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm">
<img src="/assets/Book3-bPojlso8.jpg" class="card-img-top p-3" alt="Книга 3" />
<div class="card-body text-center">
<h5 class="card-title">Яна Вагнер «Тоннель»</h5>
<button class="btn btn-primary mt-3">В корзину</button>
</div>
</div>
</div>
</div>
</section>
</main>
<!-- Подвал -->
<footer class="bg-light py-4 mt-5">
<div class="container">
<div class="row">
<div class="col-md-6 mb-4 mb-md-0">
<h5 class="mb-3">Контакты</h5>
<ul class="list-unstyled">
<li><i class="bi bi-envelope me-2"></i> info@toma.ru</li>
<li><i class="bi bi-phone me-2"></i> +7 (123) 456-78-90</li>
<li><i class="bi bi-clock me-2"></i> Пн-Пт 9:00-18:00</li>
<li><i class="bi bi-geo-alt me-2"></i> г. Москва, ул. Литераторов, д. 1</li>
</ul>
</div>
<div class="col-md-6 text-md-end">
<h5 class="mb-3">Социальные сети</h5>
<div class="social-media">
<a href="#" class="text-decoration-none me-3"><i class="bi bi-facebook fs-3"></i></a>
<a href="#" class="text-decoration-none me-3"><i class="bi bi-vk fs-3"></i></a>
<a href="#" class="text-decoration-none"><i class="bi bi-telegram fs-3"></i></a>
</div>
</div>
</div>
</div>
</footer>
<!-- Подключение React и Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js"></script>
<!-- Подключение скриптов приложения -->
</body>

View File

@@ -1,136 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Книжный интернет-магазин "Тома"</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" />
<!-- Ваш CSS -->
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- Навигация -->
<nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top shadow-sm">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="index.html">
<img src="logo.png" alt="Логотип" class="logo me-2" />
<span class="d-none d-lg-block">Книжный магазин "Тома"</span>
<span class="d-lg-none">"Тома"</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a
class="nav-link dropdown-toggle"
href="#"
id="navbarDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Страницы
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li><a class="dropdown-item" href="index.html">Главная</a></li>
<li><a class="dropdown-item" href="catalog.html">Каталог</a></li>
<li><a class="dropdown-item" href="discounts.html">Скидки</a></li>
<li><a class="dropdown-item" href="basket.html">Корзина</a></li>
<li><a class="dropdown-item" href="contactUs.html">Контакты</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<!-- Основной контент -->
<main class="container mt-5 pt-5">
<section class="my-5">
<div class="row justify-content-center">
<div class="col-lg-8 text-center">
<h2 class="mb-4">Описание:</h2>
<p class="lead">
Погрузитесь в незабываемые рукописные миры!<br />
Бесчисленные литературные направления ждут вас!<br />
Познакомьтесь с популярными работами известных<br />
писателей! Мы Вам рады!
</p>
</div>
</div>
</section>
<section class="my-5">
<h2 class="text-center mb-4">Хиты продаж</h2>
<div class="row g-4 justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm">
<img src="images/Book1.jpg" class="card-img-top p-3" alt="Книга 1" />
<div class="card-body text-center">
<h5 class="card-title">Тимоти Брук «Шляпа Вермеера»</h5>
<button class="btn btn-primary mt-3">В корзину</button>
</div>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm">
<img src="images/Book2.jpg" class="card-img-top p-3" alt="Книга 2" />
<div class="card-body text-center">
<h5 class="card-title">Пол Линч «Песнь пророка»</h5>
<button class="btn btn-primary mt-3">В корзину</button>
</div>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm">
<img src="images/Book3.jpg" class="card-img-top p-3" alt="Книга 3" />
<div class="card-body text-center">
<h5 class="card-title">Яна Вагнер «Тоннель»</h5>
<button class="btn btn-primary mt-3">В корзину</button>
</div>
</div>
</div>
</div>
</section>
</main>
<!-- Подвал -->
<footer class="bg-light py-4 mt-5">
<div class="container">
<div class="row">
<div class="col-md-6 mb-4 mb-md-0">
<h5 class="mb-3">Контакты</h5>
<ul class="list-unstyled">
<li><i class="bi bi-envelope me-2"></i> info@toma.ru</li>
<li><i class="bi bi-phone me-2"></i> +7 (123) 456-78-90</li>
<li><i class="bi bi-clock me-2"></i> Пн-Пт 9:00-18:00</li>
<li><i class="bi bi-geo-alt me-2"></i> г. Москва, ул. Литераторов, д. 1</li>
</ul>
</div>
<div class="col-md-6 text-md-end">
<h5 class="mb-3">Социальные сети</h5>
<div class="social-media">
<a href="#" class="text-decoration-none me-3"><i class="bi bi-facebook fs-3"></i></a>
<a href="#" class="text-decoration-none me-3"><i class="bi bi-vk fs-3"></i></a>
<a href="#" class="text-decoration-none"><i class="bi bi-telegram fs-3"></i></a>
</div>
</div>
</div>
</div>
</footer>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -5,12 +5,9 @@
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "vite build",
"serve": "http-server -p 3000 ./dist/",
"prod": "npm-run-all build serve",
"lint": "eslint . --ext js --report-unused-disable-directives --max-warnings 0",
"server": "json-server --watch db.json --port 3001",
"dev": "vite",
"start": "npm-run-all -p server dev"
"start": "npm-run-all -p dev"
},
"dependencies": {
"@popperjs/core": "^2.11.8",

View File

@@ -0,0 +1,226 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Card, Col, Row, Spinner } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import Footer from '../components/Footer';
import Navbar from '../components/Navbar';
import api from '../services/api';
const BasketPage = () => {
const [cartItems, setCartItems] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadCart();
}, []);
const loadCart = async () => {
try {
setLoading(true);
const response = await api.fetchCartItems();
const items = response.data;
setCartItems(items);
// Пересчитываем сумму
calculateTotal(items);
} catch (error) {
console.error('Ошибка загрузки корзины:', error);
} finally {
setLoading(false);
}
};
const calculateTotal = (items) => {
const newTotal = items.reduce((sum, item) => {
return sum + (item.book?.price || 0) * (item.quantity || 1);
}, 0);
setTotal(newTotal);
};
const handleQuantityChange = async (id, quantity) => {
if (quantity < 1) return;
try {
// Используем правильный метод API
await api.updateCartItemQuantity(id, quantity);
// Обновляем локальное состояние
const updatedItems = cartItems.map(item =>
item.id === id ? { ...item, quantity } : item
);
setCartItems(updatedItems);
calculateTotal(updatedItems);
} catch (error) {
console.error('Ошибка обновления количества:', error);
// Перезагружаем корзину при ошибке
loadCart();
}
};
const handleRemoveItem = async (id) => {
try {
await api.removeFromCart(id);
// Обновляем локальное состояние
const updatedItems = cartItems.filter(item => item.id !== id);
setCartItems(updatedItems);
calculateTotal(updatedItems);
} catch (error) {
console.error('Ошибка удаления из корзины:', error);
}
};
const handleClearCart = async () => {
if (window.confirm("Вы уверены, что хотите очистить корзину?")) {
try {
await api.clearCart();
setCartItems([]);
setTotal(0);
} catch (error) {
console.error('Ошибка очистки корзины:', error);
}
}
};
const handleCheckout = async () => {
try {
await api.clearCart();
setCartItems([]);
setTotal(0);
alert("Заказ оформлен! Спасибо за покупку!");
} catch (error) {
console.error('Ошибка оформления заказа:', error);
}
};
if (loading) {
return (
<div className="d-flex justify-content-center align-items-center vh-100">
<Spinner animation="border" variant="primary" />
</div>
);
}
return (
<div className="basket-page">
<Navbar cartCount={cartItems.reduce((sum, item) => sum + (item.quantity || 0), 0)} />
<main className="container mt-5 pt-5">
<Row className="justify-content-center">
<Col lg={10}>
<h2 className="text-center mb-4">Ваша корзина</h2>
{cartItems.length === 0 ? (
<Alert variant="info" className="text-center">
Корзина пуста
<div className="mt-3">
<Link to="/catalog" className="btn btn-primary">
Перейти к покупкам
</Link>
</div>
</Alert>
) : (
<>
<div className="cart-items">
{cartItems.map(item => (
<Card key={item.id} className="mb-3">
<Card.Body>
<Row className="align-items-center">
<Col md={2}>
<img
src={item.book?.image || "/images/default-book.jpg"}
alt={item.book?.title || "Без названия"}
className="img-fluid rounded"
style={{ maxHeight: '100px', objectFit: 'cover' }}
onError={(e) => {
e.target.src = '/images/default-book.jpg';
e.target.onerror = null;
}}
/>
</Col>
<Col md={6}>
<h5 className="mb-2">{item.book?.title || "Без названия"}</h5>
<p className="text-muted mb-1">{item.book?.author || "Автор не указан"}</p>
<p className="mb-1">
<strong>Цена:</strong> {item.book?.price || 0} руб.
</p>
<p className="mb-0">
<strong>Сумма:</strong> {(item.book?.price || 0) * (item.quantity || 1)} руб.
</p>
</Col>
<Col md={2}>
<div className="d-flex align-items-center">
<Button
variant="outline-secondary"
size="sm"
onClick={() => handleQuantityChange(item.id, (item.quantity || 1) - 1)}
disabled={(item.quantity || 1) <= 1}
>
-
</Button>
<span className="mx-2">{item.quantity || 1}</span>
<Button
variant="outline-secondary"
size="sm"
onClick={() => handleQuantityChange(item.id, (item.quantity || 1) + 1)}
>
+
</Button>
</div>
</Col>
<Col md={2} className="text-center">
<Button
variant="outline-danger"
size="sm"
onClick={() => handleRemoveItem(item.id)}
>
Удалить
</Button>
</Col>
</Row>
</Card.Body>
</Card>
))}
</div>
<Card className="mb-4 border-primary">
<Card.Body>
<div className="d-flex justify-content-between align-items-center">
<h4 className="mb-0">Общая стоимость:</h4>
<h4 className="mb-0 text-primary">{total} руб.</h4>
</div>
</Card.Body>
</Card>
<div className="d-flex justify-content-between mb-5">
<Link to="/catalog" className="btn btn-outline-primary">
Продолжить покупки
</Link>
<div>
<Button
variant="outline-danger"
className="me-2"
onClick={handleClearCart}
>
Очистить корзину
</Button>
<Button
variant="success"
className="px-4"
onClick={handleCheckout}
>
Оформить заказ
</Button>
</div>
</div>
</>
)}
</Col>
</Row>
</main>
<Footer />
</div>
);
};
export default BasketPage;

View File

@@ -1,12 +1,13 @@
import { useEffect, useState } from 'react';
import { Button, Container, Row } from 'react-bootstrap';
import { BiPlusCircle } from 'react-icons/bi';
import { Button, Container, Row, Spinner, Form, Alert } from 'react-bootstrap';
import BookComponent from '../components/BookComponent';
import BookModal from '../components/BookModal';
import CartModal from '../components/CartModal';
import Footer from '../components/Footer';
import GenreModal from '../components/GenreModal';
import Navbar from '../components/Navbar';
import Pagination from '../components/Pagination';
import PageSizeSelector from '../components/PageSizeSelector';
import api from '../services/api';
const CatalogPage = () => {
@@ -17,38 +18,181 @@ const CatalogPage = () => {
const [showCartModal, setShowCartModal] = useState(false);
const [currentBookId, setCurrentBookId] = useState(null);
const [cartCount, setCartCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Состояние для пагинации книг
const [bookPagination, setBookPagination] = useState({
currentPage: 1,
pageSize: 20,
totalPages: 1,
totalItems: 0
});
// Состояние для фильтрации
const [selectedGenre, setSelectedGenre] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
loadData();
updateCartCount();
fetchData();
}, []);
// Функция для фильтрации жанров с книгами
const getGenresWithBooks = () => {
return genres.filter(genre =>
books.some(book => book.genreId === genre.id)
);
};
const loadData = async () => {
// Функция загрузки данных с пагинацией
const fetchData = async (
bookPage = bookPagination.currentPage,
bookSize = bookPagination.pageSize
) => {
try {
const genresResponse = await api.fetchGenres();
setGenres(genresResponse.data);
setLoading(true);
setError(null);
console.log('Fetching data...');
// Загружаем жанры (без пагинации для фильтров)
const genresResponse = await api.fetchGenres();
console.log('Genres response:', genresResponse);
let genresData = [];
if (genresResponse.data && genresResponse.data.items) {
// Если ответ содержит пагинацию, берем items
genresData = genresResponse.data.items;
} else if (Array.isArray(genresResponse.data)) {
// Если это просто массив
genresData = genresResponse.data;
}
console.log('Genres data:', genresData);
setGenres(genresData);
// Загружаем книги в зависимости от фильтров
let booksResponse;
if (selectedGenre) {
// Фильтруем по жанру через бэкенд
console.log('Fetching books by genre:', selectedGenre);
booksResponse = await api.getBooksByGenre(selectedGenre, bookPage, bookSize);
} else if (searchQuery.trim()) {
// Ищем книги через бэкенд
console.log('Searching books:', searchQuery);
booksResponse = await api.searchBooks(searchQuery.trim(), bookPage, bookSize);
} else {
// Загружаем все книги с пагинацией
console.log('Fetching all books');
booksResponse = await api.fetchBooksPaginated(bookPage, bookSize);
}
console.log('Books response:', booksResponse);
const booksData = booksResponse.data || {};
// Загружаем корзину
const cartResponse = await api.fetchCartItems();
console.log('Cart response:', cartResponse);
// Обновляем состояние книг
setBooks(booksData.items || []);
setBookPagination({
currentPage: booksData.currentPage || 1,
pageSize: booksData.currentSize || bookSize, // Используем текущий размер страницы
totalPages: booksData.totalPages || 1,
totalItems: booksData.totalItems || 0
});
updateCartCount(cartResponse.data || []);
const booksResponse = await api.fetchBooks();
setBooks(booksResponse.data);
} catch (error) {
console.error('Ошибка загрузки данных:', error);
setError('Не удалось загрузить данные. Проверьте подключение к серверу.');
} finally {
setLoading(false);
}
};
const updateCartCount = async () => {
try {
const response = await api.fetchCartItems();
const totalItems = response.data.reduce((sum, item) => sum + (item.quantity || 0), 0);
setCartCount(totalItems);
} catch (error) {
console.error('Ошибка обновления счетчика корзины:', error);
const updateCartCount = (cartItems) => {
if (!Array.isArray(cartItems)) {
console.error('Cart items is not an array:', cartItems);
setCartCount(0);
return;
}
const count = cartItems.reduce((sum, item) => sum + (item.quantity || 0), 0);
setCartCount(count);
};
// Обработчики для пагинации книг
const handleBookPageChange = (page) => {
setBookPagination(prev => ({ ...prev, currentPage: page }));
fetchData(page, bookPagination.pageSize);
};
const handleBookPageSizeChange = (size) => {
setBookPagination(prev => ({ ...prev, pageSize: size, currentPage: 1 }));
fetchData(1, size);
};
// Обработчик фильтрации по жанру
const handleGenreFilter = (genreId) => {
const newSelectedGenre = genreId === selectedGenre ? null : genreId;
setSelectedGenre(newSelectedGenre);
setSearchQuery(''); // Сбрасываем поиск
setBookPagination(prev => ({ ...prev, currentPage: 1 }));
fetchData(1, bookPagination.pageSize);
};
// Обработчик поиска
const handleSearch = () => {
setSelectedGenre(null); // Сбрасываем фильтр по жанру
setBookPagination(prev => ({ ...prev, currentPage: 1 }));
fetchData(1, bookPagination.pageSize);
};
// Обработчик сброса фильтров
const handleResetFilters = () => {
setSelectedGenre(null);
setSearchQuery('');
setBookPagination(prev => ({ ...prev, currentPage: 1 }));
fetchData(1, bookPagination.pageSize);
};
// Функция для группировки книг по жанрам (используется только когда НЕ выбран конкретный жанр)
const getBooksByGenre = (genreId) => {
if (!Array.isArray(books) || selectedGenre) {
return []; // Не группируем при выбранном жанре
}
return books.filter(book =>
book.genres?.some(bookGenre => bookGenre.genre?.id === genreId)
);
};
// Получаем жанры для отображения (с книгами)
const getGenresWithBooks = () => {
if (!Array.isArray(genres) || !Array.isArray(books)) {
return [];
}
if (selectedGenre) {
// Если выбран конкретный жанр, показываем только его
const selectedGenreObj = genres.find(g => g.id === selectedGenre);
return selectedGenreObj ? [selectedGenreObj] : [];
}
// Иначе показываем все жанры, у которых есть книги в текущей выборке
return genres.filter(genre =>
books.some(book => book.genres?.some(bg => bg.genre?.id === genre.id))
);
};
const allGenres = getGenresWithBooks();
// Обработчик удаления жанра
const handleDeleteGenre = async (genreId) => {
if (window.confirm('Вы уверены, что хотите удалить этот жанр?')) {
try {
await api.deleteGenre(genreId);
await fetchData();
} catch (error) {
console.error('Ошибка удаления жанра:', error);
alert('Нельзя удалить жанр, если есть книги с этим жанром');
}
}
};
@@ -66,7 +210,7 @@ const CatalogPage = () => {
if (window.confirm('Вы уверены, что хотите удалить эту книгу?')) {
try {
await api.deleteBook(id);
loadData();
await fetchData();
} catch (error) {
console.error('Ошибка удаления книги:', error);
}
@@ -75,12 +219,21 @@ const CatalogPage = () => {
const handleAddToCart = async (bookId) => {
try {
await api.addToCart(bookId);
updateCartCount();
alert('Книга добавлена в корзину!');
const cartResponse = await api.fetchCartItems();
const cartItems = cartResponse.data || [];
const existingItem = cartItems.find(item => item.book?.id === bookId);
if (existingItem) {
await api.updateCartItemQuantity(existingItem.id, existingItem.quantity + 1);
} else {
await api.addToCart(bookId);
}
const updatedCart = await api.fetchCartItems();
updateCartCount(updatedCart.data || []);
} catch (error) {
console.error('Ошибка добавления в корзину:', error);
alert('Не удалось добавить книгу в корзину');
}
};
@@ -91,94 +244,265 @@ const CatalogPage = () => {
} else {
await api.createBook(bookData);
}
loadData();
await fetchData();
setShowBookModal(false);
} catch (error) {
console.error('Ошибка сохранения книги:', error);
alert('Не удалось сохранить книгу');
}
};
const handleSaveGenre = async (genreData) => {
try {
await api.createGenre(genreData);
loadData();
await fetchData();
setShowGenreModal(false);
alert('Жанр успешно добавлен!');
} catch (error) {
console.error('Ошибка сохранения жанра:', error);
alert('Не удалось сохранить жанр');
}
};
const handleCheckout = async () => {
try {
await api.clearCart();
updateCartCount();
setCartCount(0);
setShowCartModal(false);
alert('Заказ оформлен! Спасибо за покупку!');
alert("Заказ оформлен! Спасибо за покупку!");
} catch (error) {
console.error('Ошибка оформления заказа:', error);
alert('Не удалось оформить заказ');
}
};
const booksByGenre = (genreId) => {
return books.filter(book => book.genreId === genreId);
};
if (loading) {
return (
<div className="d-flex justify-content-center align-items-center vh-100">
<Spinner animation="border" variant="primary" />
<span className="ms-2">Загрузка...</span>
</div>
);
}
return (
<div className="catalog-page">
<Navbar cartCount={cartCount} onShowCart={() => setShowCartModal(true)} />
<main className="mt-5 pt-5">
<Container>
<h2 className="text-center mb-5">Каталог книг</h2>
<div className="d-flex justify-content-between mb-4">
<div>
<Button variant="success" onClick={handleAddBook}>
<BiPlusCircle /> Добавить книгу
</Button>
<Button
variant="primary"
className="ms-2"
onClick={() => setShowGenreModal(true)}
>
<BiPlusCircle /> Добавить жанр
</Button>
{error && (
<Alert variant="danger" className="mb-4">
{error}
</Alert>
)}
{/* Панель управления */}
<div className="card mb-4">
<div className="card-body">
<div className="row">
<div className="col-md-8">
<div className="d-flex flex-wrap gap-2">
<Button variant="success" onClick={handleAddBook}>
Добавить книгу
</Button>
<Button
variant="primary"
onClick={() => setShowGenreModal(true)}
>
Добавить жанр
</Button>
{/* Фильтр по жанрам */}
{Array.isArray(genres) && genres.length > 0 && (
<div className="dropdown">
<Button variant="outline-secondary" id="genreFilter">
{selectedGenre && genres.find(g => g.id === selectedGenre)
? `Жанр: ${genres.find(g => g.id === selectedGenre)?.name}`
: 'Все жанры'}
</Button>
<div className="dropdown-menu" style={{ maxHeight: '300px', overflowY: 'auto' }}>
<button
className="dropdown-item"
onClick={() => handleGenreFilter(null)}
>
Все жанры
</button>
{genres.map(genre => (
<button
key={genre.id}
className="dropdown-item"
onClick={() => handleGenreFilter(genre.id)}
>
{genre.name}
</button>
))}
</div>
</div>
)}
{/* Кнопка сброса фильтров */}
{(selectedGenre || searchQuery) && (
<Button
variant="outline-secondary"
onClick={handleResetFilters}
>
Сбросить фильтры
</Button>
)}
</div>
</div>
<div className="col-md-4">
<div className="d-flex">
<Form.Control
type="text"
placeholder="Поиск книг..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
/>
<Button
variant="outline-primary"
className="ms-2"
onClick={handleSearch}
>
Найти
</Button>
</div>
</div>
</div>
{/* Информация о текущих фильтрах */}
{(selectedGenre || searchQuery) && (
<div className="mt-2">
<small className="text-muted">
{selectedGenre && `Фильтр: ${genres.find(g => g.id === selectedGenre)?.name}`}
{selectedGenre && searchQuery && ' | '}
{searchQuery && `Поиск: "${searchQuery}"`}
</small>
</div>
)}
</div>
</div>
{getGenresWithBooks().map(genre => (
<section key={genre.id} className="mb-5">
<div className="genre-title bg-light p-3 rounded text-center mb-4">
<h3>{genre.name}</h3>
</div>
<Row>
{booksByGenre(genre.id).map(book => (
<BookComponent
key={book.id}
book={book}
onEdit={handleEditBook}
onDelete={handleDeleteBook}
onAddToCart={handleAddToCart}
/>
))}
</Row>
</section>
))}
{/* Информация о пагинации */}
<div className="d-flex justify-content-between align-items-center mb-3">
<div className="text-muted">
{selectedGenre && Array.isArray(genres) && ` в жанре "${genres.find(g => g.id === selectedGenre)?.name}"`}
{searchQuery && ` по запросу "${searchQuery}"`}
{!selectedGenre && !searchQuery && 'Все книги'}
</div>
<PageSizeSelector
pageSize={bookPagination.pageSize}
onPageSizeChange={handleBookPageSizeChange}
/>
</div>
{/* Список книг */}
{books.length === 0 ? (
<div className="text-center py-5">
<h4>Нет данных для отображения</h4>
<p className="text-muted">
{Array.isArray(genres) && genres.length === 0
? 'Добавьте жанры и книги для начала работы'
: 'Нет книг для отображения'}
</p>
</div>
) : (
<>
{/* Режим: выбран конкретный жанр */}
{selectedGenre ? (
<section className="mb-5">
<div className="genre-title bg-light p-3 rounded text-center mb-4">
<h3 className="mb-2">
{genres.find(g => g.id === selectedGenre)?.name}
</h3>
<Button
variant="outline-secondary"
size="sm"
onClick={() => setSelectedGenre(null)}
>
Показать все жанры
</Button>
</div>
<Row>
{books.map(book => (
<BookComponent
key={book.id}
book={book}
onEdit={handleEditBook}
onDelete={handleDeleteBook}
onAddToCart={handleAddToCart}
/>
))}
</Row>
</section>
) : (
/* Режим: все жанры с группировкой */
allGenres.map(genre => (
<section key={genre.id} className="mb-5">
<div className="genre-title bg-light p-3 rounded text-center mb-4">
<h3 className="mb-2">{genre.name}</h3>
<div className="d-flex justify-content-center gap-2">
<Button
variant="outline-primary"
size="sm"
onClick={() => handleGenreFilter(genre.id)}
>
Показать только этот жанр
</Button>
<Button
variant="outline-danger"
size="sm"
onClick={() => handleDeleteGenre(genre.id)}
title="Удалить жанр"
>
Удалить жанр
</Button>
</div>
</div>
<Row>
{getBooksByGenre(genre.id).length > 0 ? (
getBooksByGenre(genre.id).map(book => (
<BookComponent
key={book.id}
book={book}
onEdit={handleEditBook}
onDelete={handleDeleteBook}
onAddToCart={handleAddToCart}
/>
))
) : (
<div className="text-center py-3">
<p className="text-muted">В этом жанре пока нет книг</p>
</div>
)}
</Row>
</section>
))
)}
</>
)}
{/* Пагинация книг */}
{bookPagination.totalPages > 1 && (
<div className="mt-4">
<Pagination
currentPage={bookPagination.currentPage}
totalPages={bookPagination.totalPages}
onPageChange={handleBookPageChange}
/>
</div>
)}
</Container>
</main>
<Footer />
{/* Модальные окна */}
<BookModal
show={showBookModal}
onHide={() => setShowBookModal(false)}
bookId={currentBookId}
genres={genres}
genres={Array.isArray(genres) ? genres : []}
onSave={handleSaveBook}
/>

View File

@@ -0,0 +1,123 @@
import { useState } from 'react';
import { Container, Form, Button, Card } from 'react-bootstrap';
import Navbar from '../components/Navbar';
import Footer from '../components/Footer';
const ContactUsPage = () => {
const [formData, setFormData] = useState({
name: '',
email: '',
purchaseCode: '',
problemDescription: ''
});
const [validated, setValidated] = useState(false);
const handleChange = (e) => {
const { id, value } = e.target;
setFormData(prev => ({
...prev,
[id]: value
}));
};
const handleSubmit = (event) => {
event.preventDefault();
const form = event.currentTarget;
if (form.checkValidity() === false) {
event.stopPropagation();
} else {
// Здесь можно добавить логику отправки формы
console.log('Форма отправлена:', formData);
alert('Ваше сообщение отправлено! Мы свяжемся с вами в ближайшее время.');
setFormData({
name: '',
email: '',
purchaseCode: '',
problemDescription: ''
});
}
setValidated(true);
};
return (
<div className="contact-page">
<Navbar />
<main className="container mt-5 pt-5">
<section className="my-5">
<div className="row justify-content-center">
<div className="col-lg-8">
<h2 className="text-center mb-4">Свяжитесь с нами</h2>
<Form noValidate validated={validated} onSubmit={handleSubmit}>
<Form.Group className="mb-3" controlId="name">
<Form.Label>Имя</Form.Label>
<Form.Control
type="text"
value={formData.name}
onChange={handleChange}
required
/>
<Form.Control.Feedback type="invalid">
Пожалуйста, введите ваше имя.
</Form.Control.Feedback>
</Form.Group>
<Form.Group className="mb-3" controlId="email">
<Form.Label>Электронная почта</Form.Label>
<Form.Control
type="email"
value={formData.email}
onChange={handleChange}
required
/>
<Form.Control.Feedback type="invalid">
Пожалуйста, введите корректный email.
</Form.Control.Feedback>
</Form.Group>
<Form.Group className="mb-3" controlId="purchaseCode">
<Form.Label>Код покупки (если есть)</Form.Label>
<Form.Control
type="text"
value={formData.purchaseCode}
onChange={handleChange}
/>
</Form.Group>
<Form.Group className="mb-3" controlId="problemDescription">
<Form.Label>Описание проблемы</Form.Label>
<Form.Control
as="textarea"
rows={6}
value={formData.problemDescription}
onChange={handleChange}
required
/>
<Form.Control.Feedback type="invalid">
Пожалуйста, опишите вашу проблему.
</Form.Control.Feedback>
</Form.Group>
<div className="text-center">
<Button variant="primary" size="lg" type="submit">
Отправить
</Button>
</div>
</Form>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
};
export default ContactUsPage;

View File

@@ -0,0 +1,112 @@
import React from 'react';
import { Container, Row, Col, Card, Button, Alert } from 'react-bootstrap';
import { BiCart } from 'react-icons/bi';
import Navbar from '../components/Navbar';
import Footer from '../components/Footer';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/js/bootstrap.bundle.min';
import Book1 from '../images/the_girl_with_the_dragon_tattoo.jpg';
import Book2 from '../images/the_hobbit.webp';
import Book3 from '../images/dune.jpg';
import defBook from '../images/default-book.jpg';
const DiscountsPage = () => {
const discountedBooks = [
{
id: 1,
title: "Девушка с татуировкой дракона",
author: "Стиг Ларссон",
image: Book1,
originalPrice: 700,
discountPrice: 525,
discountPercent: 25
},
{
id: 2,
title: "Хоббит",
author: "Дж.Р.Р. Толкин",
image: Book2,
originalPrice: 750,
discountPrice: 563,
discountPercent: 25
},
{
id: 3,
title: "Дюна",
author: "Фрэнк Герберт",
image: Book2,
originalPrice: 500,
discountPrice: 375,
discountPercent: 25
}
];
const handleAddToCart = (bookId) => {
// Логика добавления в корзину
alert(`Книга ${bookId} добавлена в корзину`);
};
return (
<div className="discounts-page">
<Navbar cartCount={0} />
<main className="container mt-5 pt-5">
<section className="my-5">
<h2 className="text-center mb-4">Скидки</h2>
<hr className="mb-4" />
<Row className="g-4 justify-content-center">
{discountedBooks.map(book => (
<Col key={book.id} md={6} lg={4}>
<Card className="h-100 border-0 shadow-sm">
<Card.Img
variant="top"
src={book.image}
className="p-3"
alt={book.title}
onError={(e) => { e.target.src = defBook }}
/>
<Card.Body className="text-center d-flex flex-column">
<Card.Title>{book.title}</Card.Title>
<Card.Text>{book.author}</Card.Text>
<Card.Text className="text-muted">
<s>{book.originalPrice} р.</s>
</Card.Text>
<Card.Text className="text-danger fs-4 fw-bold">
{book.discountPrice} р.
</Card.Text>
<Card.Text className="small text-muted">
Экономия {book.originalPrice - book.discountPrice} р. ({book.discountPercent}%)
</Card.Text>
<Button
variant="primary"
className="mt-2"
onClick={() => handleAddToCart(book.id)}
>
<BiCart /> В корзину
</Button>
</Card.Body>
</Card>
</Col>
))}
</Row>
<hr className="my-4" />
<Alert variant="success" className="text-center">
<h3>Условия получения скидки:</h3>
<p className="lead mb-0">
При покупке трех книг одновременно Вы получаете скидку 25%!<br />
Скидка действует с 1 по 15 число каждого месяца. Не упустите возможность!
</p>
</Alert>
</section>
</main>
<Footer />
</div>
);
};
export default DiscountsPage;

View File

@@ -0,0 +1,93 @@
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/js/bootstrap.bundle.min';
import { Button, Card, Col, Row } from 'react-bootstrap';
import { BiCart } from 'react-icons/bi';
import Footer from '../components/Footer';
import Navbar from '../components/Navbar';
import Book1 from '../images/Book1.jpg';
import Book2 from '../images/Book2.jpg';
import Book3 from '../images/Book3.jpg';
import defBook from '../images/default-book.jpg';
const HomePage = () => {
const bestsellers = [
{
id: 1,
title: "Тимоти Брук «Шляпа Вермеера»",
image: Book1
},
{
id: 2,
title: "Пол Линч «Песнь пророка»",
image: Book2
},
{
id: 3,
title: "Яна Вагнер «Тоннель»",
image: Book3
}
];
const handleAddToCart = (bookId) => {
// Здесь будет логика добавления в корзину
alert(`Книга ${bookId} добавлена в корзину`);
};
return (
<div className="home-page">
<Navbar cartCount={0} />
<main className="container mt-5 pt-5">
{/* Блок с описанием */}
<section className="my-5">
<Row className="justify-content-center">
<Col lg={8} className="text-center">
<h2 className="mb-4">Описание:</h2>
<p className="lead">
Погрузитесь в незабываемые рукописные миры!<br />
Бесчисленные литературные направления ждут вас!<br />
Познакомьтесь с популярными работами известных<br />
писателей! Мы Вам рады!
</p>
</Col>
</Row>
</section>
{/* Блок хитов продаж */}
<section className="my-5">
<h2 className="text-center mb-4">Хиты продаж</h2>
<Row className="g-4 justify-content-center">
{bestsellers.map(book => (
<Col key={book.id} md={6} lg={4}>
<Card className="h-100 border-0 shadow-sm">
<Card.Img
variant="top"
src={book.image}
className="p-3"
alt={book.title}
onError={(e) => { e.target.src = defBook }}
/>
<Card.Body className="text-center d-flex flex-column">
<Card.Title>{book.title}</Card.Title>
<Button
variant="primary"
className="mt-auto"
onClick={() => handleAddToCart(book.id)}
>
<BiCart /> В корзину
</Button>
</Card.Body>
</Card>
</Col>
))}
</Row>
</section>
</main>
<Footer />
</div>
);
};
export default HomePage;

View File

@@ -1,29 +1,69 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import axios from "axios";
const API_URL = "http://localhost:3001";
const API_URL = "http://localhost:8080/api/1.0";
const apiClient = axios.create({
baseURL: API_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
}
});
apiClient.interceptors.response.use(
(response) => response,
(error) => {
console.error('API Error:', error.response?.data || error.message);
return Promise.reject(error);
}
);
export default {
// Жанры
fetchGenres: () => axios.get(`${API_URL}/genres`),
createGenre: (genreData) => axios.post(`${API_URL}/genres`, genreData),
fetchGenres: () => apiClient.get(`/genre/all`), // Добавить endpoint на бэкенде
// Или использовать пагинированную версию:
fetchGenres: () => apiClient.get(`/genre`).then(response => {
// Преобразуем ответ к массиву
if (response.data && response.data.items) {
return { data: response.data.items };
}
return response;
}),
fetchGenresPaginated: (page = 1, size = 10) =>
apiClient.get(`/genre?page=${page}&size=${size}`),
createGenre: (genreData) => apiClient.post(`/genre`, genreData),
deleteGenre: (id) => apiClient.delete(`/genre/${id}`),
// Книги
fetchBooks: () => axios.get(`${API_URL}/books`),
fetchBooksByGenre: (genreId) => axios.get(`${API_URL}/books?genreId=${genreId}`),
fetchBook: (id) => axios.get(`${API_URL}/books/${id}`),
createBook: (bookData) => axios.post(`${API_URL}/books`, bookData),
updateBook: (id, bookData) => axios.put(`${API_URL}/books/${id}`, bookData),
deleteBook: (id) => axios.delete(`${API_URL}/books/${id}`),
fetchBooks: () => apiClient.get(`/book/all`),
fetchBooksPaginated: (page = 1, size = 10) =>
apiClient.get(`/book?page=${page}&size=${size}`),
fetchBook: (id) => apiClient.get(`/book/${id}`),
createBook: (bookData) => apiClient.post(`/book`, bookData),
updateBook: (id, bookData) => apiClient.put(`/book/${id}`, bookData),
deleteBook: (id) => apiClient.delete(`/book/${id}`),
// Поиск и фильтрация книг
searchBooks: (query, page = 1, size = 10) =>
apiClient.get(`/book/search?query=${encodeURIComponent(query)}&page=${page}&size=${size}`),
getBooksByGenre: (genreId, page = 1, size = 10) =>
apiClient.get(`/book/genre/${genreId}?page=${page}&size=${size}`),
getBooksByGenreAll: (genreId) =>
apiClient.get(`/book/genre/${genreId}/all`),
// Жанры книг
fetchBookGenres: (bookId) => apiClient.get(`/book/${bookId}/genres`),
addBookGenre: (bookId, genreData) => apiClient.post(`/book/${bookId}/genres`, genreData),
deleteBookGenre: (bookId, genreId) => apiClient.delete(`/book/${bookId}/genres/${genreId}`),
// Корзина
fetchCartItems: () => axios.get(`${API_URL}/cart?_expand=book`),
addToCart: (bookId) => axios.post(`${API_URL}/cart`, { bookId, quantity: 1 }),
getCartItemByBookId: (bookId) => axios.get(`${API_URL}/cart?bookId=${bookId}`),
updateCartItem: (id, data) => axios.patch(`${API_URL}/cart/${id}`, data),
removeFromCart: (id) => axios.delete(`${API_URL}/cart/${id}`),
clearCart: () =>
axios.get(`${API_URL}/cart`).then((response) => {
return Promise.all(response.data.map((item) => axios.delete(`${API_URL}/cart/${item.id}`)));
}),
};
fetchCartItems: () => apiClient.get(`/cart`),
addToCart: (bookId) => apiClient.post(`/cart`, { bookId, quantity: 1 }),
getCartItemByBookId: (bookId) => apiClient.get(`/cart/search?bookId=${bookId}`),
updateCartItem: (id, data) => apiClient.put(`/cart/${id}`, data),
updateCartItemQuantity: (id, quantity) => apiClient.patch(`/cart/${id}/${quantity}`),
removeFromCart: (id) => apiClient.delete(`/cart/${id}`),
clearCart: () => apiClient.delete(`/cart`),
};

View File

@@ -5,13 +5,36 @@ import react from "@vitejs/plugin-react";
import { resolve } from "path";
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
catalog: resolve(__dirname, "catalog.html"),
},
plugins: [react()],
base: "./", // КРИТИЧЕСКИ ВАЖНО для JAR!
build: {
outDir: "dist", // Собираем во фронтенд-проекте
emptyOutDir: true,
assetsDir: "assets", // Стандартное имя папки
rollupOptions: {
output: {
assetFileNames: (assetInfo) => {
const ext = assetInfo.name.split('.').pop();
if (/png|jpe?g|svg|gif|tiff|bmp|ico/i.test(ext)) {
return `assets/images/[name]-[hash][extname]`;
}
if (/css/i.test(ext)) {
return `assets/css/[name]-[hash][extname]`;
}
return `assets/[name]-[hash][extname]`;
},
chunkFileNames: "assets/js/[name]-[hash].js",
entryFileNames: "assets/js/[name]-[hash].js",
},
},
},
server: {
port: 3000,
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
},
},
},
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Книжный интернет-магазин "Тома"</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" />
<script type="module" crossorigin src="/assets/index-DbQzQPoN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BLg3Q5Rn.css">
</head>
<body>
<div id="root"></div>
<!-- Подключение React и Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js"></script>
<!-- Подключение скриптов приложения -->
</body>

3
server/.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary

37
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/

114
server/build.front.gradle Normal file
View File

@@ -0,0 +1,114 @@
apply plugin: "com.github.node-gradle.node"
logger.quiet("Configure front builder")
ext {
// Используем относительный путь
frontDir = file("${project.projectDir}/../MyWebSite")
if (!frontDir.exists()) {
throw new GradleException("Frontend app directory does not exist: ${frontDir}")
}
logger.quiet("Webapp dir is {}", frontDir.toString())
}
node {
version = "22.17.1"
npmVersion = "10.9.2"
download = true
nodeProjectDir = frontDir
}
tasks.register("frontClean", Delete) {
group = "MyWebSite"
description = "Clean frontend build directories"
delete "${frontDir}/dist"
delete "${project.projectDir}/src/main/resources/static"
}
tasks.register("frontDepsInstall", NpmTask) {
group = "MyWebSite"
description = "Installs dependencies from package.json"
logger.quiet(description)
workingDir = frontDir
args = ["install"]
}
tasks.register("frontBuild", NpmTask) {
group = "MyWebSite"
description = "Build frontend webapp"
logger.quiet(description)
workingDir = frontDir
dependsOn frontDepsInstall
args = ["run", "build"]
doLast {
def staticDir = file("${project.projectDir}/src/main/resources/static")
def frontDistDir = file("${frontDir}/dist")
logger.quiet("Frontend built in: ${frontDistDir}")
logger.quiet("Copying to: ${staticDir}")
if (frontDistDir.exists()) {
// Очищаем статическую директорию
delete staticDir
staticDir.mkdirs()
// Копируем все содержимое
copy {
from frontDistDir
into staticDir
include "**/*"
}
// Логируем что скопировалось
logger.quiet("Copied files:")
staticDir.eachFileRecurse { file ->
if (!file.isDirectory()) {
logger.quiet(" - ${file.path.replace(staticDir.path, '')}")
}
}
// Проверяем наличие критических файлов
def requiredFiles = [
"index.html",
"assets/",
"assets/images/"
]
requiredFiles.each { required ->
def checkFile = file("${staticDir}/${required}")
if (required.endsWith("/")) {
if (checkFile.exists() && checkFile.isDirectory()) {
logger.quiet("✓ Directory exists: ${required}")
} else {
logger.warn("✗ Directory missing: ${required}")
}
} else {
if (checkFile.exists()) {
logger.quiet("✓ File exists: ${required}")
} else {
logger.warn("✗ File missing: ${required}")
}
}
}
} else {
throw new GradleException("Frontend build failed: ${frontDistDir} not found")
}
}
}
// Очистка перед сборкой
frontBuild.dependsOn frontClean
// Связываем с процессом сборки
if (frontDir.exists()) {
processResources.dependsOn frontBuild
bootJar.dependsOn frontBuild
// Проверяем, что статические файлы включены в JAR
bootJar {
from("${project.projectDir}/src/main/resources/static") {
into "BOOT-INF/classes/static"
}
}
}

172
server/build.gradle Normal file
View File

@@ -0,0 +1,172 @@
plugins {
id "java"
id "org.springframework.boot" version "3.5.6"
id "io.spring.dependency-management" version "1.1.7"
id "com.github.node-gradle.node" version "7.1.0" apply false
id "org.liquibase.gradle" version "2.2.2" apply false
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
description = "Bookstore Application"
def jdkVersion = "21"
defaultTasks "bootRun"
jar {
enabled = false
}
bootJar {
archiveFileName = String.format("%s-%s.jar", rootProject.name, version)
}
assert System.properties["java.specification.version"] == jdkVersion
java {
toolchain {
languageVersion = JavaLanguageVersion.of(jdkVersion)
}
}
repositories {
mavenCentral()
}
ext {
springdocVersion = "2.8.11"
h2Version = "2.4.240"
postgresVersion = "42.7.8"
liquibaseVersion = "4.33.0"
mockitoVersion = "5.19.0"
// Определяем активные профили на основе параметров Gradle
springProfiles = []
// Добавляем фронтенд если указан параметр front
if (project.hasProperty("front")) {
springProfiles.add("front")
}
// Определяем основной профиль (dev или prod)
if (project.hasProperty("prod")) {
springProfiles.add("prod")
logger.quiet("Using PRODUCTION profile")
} else {
springProfiles.add("dev")
logger.quiet("Using DEVELOPMENT profile")
}
currentProfiles = springProfiles.join(",")
logger.quiet("Active profiles: ${currentProfiles}")
}
configurations {
mockitoAgent
}
apply from: "build.front.gradle"
// Подключаем миграции только для dev профиля
if (springProfiles.contains("dev")) {
apply from: "build.migrations.gradle"
}
dependencies {
// Spring Boot Starters
implementation "org.springframework.boot:spring-boot-starter-web"
implementation "org.springframework.boot:spring-boot-starter-validation"
implementation "org.springframework.boot:spring-boot-starter-data-jpa"
implementation "org.springframework.boot:spring-boot-starter-actuator"
// OpenAPI/Swagger
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}"
// Миграции
implementation "org.liquibase:liquibase-core:${liquibaseVersion}"
// Базы данных
runtimeOnly "org.postgresql:postgresql:${postgresVersion}"
// Для dev профиля добавляем H2
if (springProfiles.contains("dev")) {
runtimeOnly "com.h2database:h2:${h2Version}"
}
// Тестирование
testImplementation "org.springframework.boot:spring-boot-starter-test"
testImplementation "org.mockito:mockito-core:${mockitoVersion}"
testImplementation "com.h2database:h2:${h2Version}" // Для тестов всегда используем H2
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
mockitoAgent("org.mockito:mockito-core:${mockitoVersion}") {
transitive = false
}
}
bootRun {
// Всегда используем профили, определенные через параметры Gradle
def launchArgs = ["--spring.profiles.active=${currentProfiles}"]
// Добавляем пользовательские аргументы если есть
if (project.hasProperty("args")) {
launchArgs.addAll(project.args.tokenize())
}
args = launchArgs
logger.lifecycle("Starting with arguments: ${launchArgs}")
// Настройка JVM
jvmArgs = [
"-Xmx512m",
"-Xms256m",
"-Dfile.encoding=UTF-8"
]
// Передаем системные свойства
systemProperties = System.properties
}
test {
useJUnitPlatform()
jvmArgs += "-Xshare:off"
jvmArgs += "-javaagent:${configurations.mockitoAgent.asPath}"
systemProperty "spring.profiles.active", "test"
}
// Убираем фильтрацию application.yml - Spring Boot будет читать профили из файла
// processResources остается без изменений
// Автоматическое создание миграций при сборке для тестов
if (project.hasProperty('autoGenerateMigrations')) {
tasks.named('test') {
dependsOn generateDiff
}
}
// Дополнительные задачи для удобства
tasks.register("runDev") {
group = "application"
description = "Run application with dev profile (default)"
dependsOn bootRun
}
tasks.register("runprod") {
group = "application"
description = "Run application with prod profile"
doFirst {
project.ext.set("prod", true)
project.ext.set("front", true)
}
dependsOn bootRun
}
tasks.register("runApiOnly") {
group = "application"
description = "Run application without frontend"
doFirst {
// Убираем front профиль
project.extensions.extraProperties.remove("front")
}
dependsOn bootRun
}

View File

@@ -0,0 +1,64 @@
apply plugin: "org.liquibase.gradle"
logger.quiet("Configure migrations builder")
ext {
picocliVersion = "4.7.7"
timestamp = new Date().format("yyyy-MM-dd-HHmmss")
}
liquibase {
activities {
main {
changelogFile "db/master.yml"
url "jdbc:h2:file:./data/bookstore"
username "sa"
password "sa"
driver "org.h2.Driver"
logLevel "info"
classpath "src/main/resources"
referenceUrl "hibernate:spring:com.example.server.entity?dialect=org.hibernate.dialect.H2Dialect"
}
}
}
update.dependsOn processResources
dependencies {
liquibaseRuntime "org.liquibase.ext:liquibase-hibernate6:${liquibaseVersion}"
liquibaseRuntime "info.picocli:picocli:${picocliVersion}"
liquibaseRuntime sourceSets.main.runtimeClasspath
liquibaseRuntime sourceSets.main.output
}
tasks.register("generateFull") {
group = "migrations"
description = "Generate changelog from existing database"
doFirst(){
liquibase {
activities {
main {
changeLogFile "src/main/resources/db/generated-full-${timestamp}.yml"
}
}
}
}
finalizedBy generateChangelog
}
tasks.register("generateDiff") {
group = "liquibase"
description = "Generate diff between DB and JPA entities"
doFirst(){
liquibase {
activities {
main {
changeLogFile "src/main/resources/db/generated-diff-${timestamp}.yml"
}
}
}
}
finalizedBy diffChangelog
}
diffChangelog.dependsOn compileJava

BIN
server/data.mv.db Normal file

Binary file not shown.

17746
server/data.trace.db Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
#FileLock
#Tue Dec 16 14:10:21 GMT+04:00 2025
hostName=LAPTOP-IUA50AR5
id=19b26a389dbd2bd3398104c2366bdfd02b77488ebde
method=file
server=26.200.18.239\:60656

BIN
server/data/bookstore.mv.db Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

BIN
server/gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
server/gradlew vendored Normal file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
server/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

2
server/settings.gradle Normal file
View File

@@ -0,0 +1,2 @@
rootProject.name = 'server'

View File

@@ -0,0 +1,118 @@
package com.example.server;
import com.example.server.api.book.BookGenreRq;
import com.example.server.api.book.BookRq;
import com.example.server.api.genre.GenreRq;
import com.example.server.service.BookService;
import com.example.server.service.CartItemService;
import com.example.server.service.GenreService;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.core.env.Environment;
import java.util.Arrays;
import java.util.Objects;
@SpringBootApplication
public class ServerApplication implements CommandLineRunner {
private final Logger log = LoggerFactory.getLogger(ServerApplication.class);
private final Environment environment;
private final BookService bookService;
private final GenreService genreService;
private final CartItemService cartItemService;
public ServerApplication(Environment environment, BookService bookService,
GenreService genreService, CartItemService cartItemService) {
this.environment = environment;
this.bookService = bookService;
this.genreService = genreService;
this.cartItemService = cartItemService;
}
public static void main(String[] args) {
SpringApplication.run(ServerApplication.class, args);
}
@PostConstruct
public void logProfiles() {
// Логируем активные профили при старте приложения
String[] activeProfiles = environment.getActiveProfiles();
if (activeProfiles.length == 0) {
log.info("No active profiles set, using default");
} else {
log.info("Active profiles: {}", Arrays.toString(activeProfiles));
}
// Логируем используемую базу данных
String datasourceUrl = environment.getProperty("spring.datasource.url");
if (datasourceUrl != null) {
String maskedUrl = datasourceUrl.replaceAll("password=.*?(&|$)", "password=****");
log.info("Database URL: {}", maskedUrl);
}
}
@Override
public void run(String... args) throws Exception {
log.info("Application started with arguments: {}", Arrays.toString(args));
// Проверяем, нужно ли заполнять данные
boolean shouldPopulate = args.length > 0 && Objects.equals("--populate", args[0]);
boolean isDevProfile = Arrays.asList(environment.getActiveProfiles()).contains("dev");
if (shouldPopulate) {
if (isDevProfile) {
populateData();
} else {
log.warn("Data population is only allowed in dev profile");
}
}
}
private void populateData() {
log.info("Starting data population...");
try {
// Проверяем, не заполнены ли данные уже
if (!genreService.getAll().isEmpty()) {
log.info("Data already exists, skipping population");
return;
}
log.info("Creating genres...");
var genre1 = genreService.create(new GenreRq("Роман"));
var genre2 = genreService.create(new GenreRq("Фантастика"));
var genre3 = genreService.create(new GenreRq("Ужасы"));
var genre4 = genreService.create(new GenreRq("Детектив"));
log.info("Creating books...");
var book1 = bookService.create(new BookRq(
"Основание", "Айзек Азимов", 700,
"Сага о падении и возрождении галактической империи.", "images/foundation.jpg"
));
var book2 = bookService.create(new BookRq(
"Дюна", "Фрэнк Герберт", 900,
"Эпическая история о борьбе за контроль над планетой Арракис.", "images/dune.jpg"
));
var book3 = bookService.create(new BookRq(
"Убийство в Восточном экспрессе", "Агата Кристи", 750,
"Загадочное убийство на поезде.", "images/murder_on_the_orient_express.jpg"
));
log.info("Creating genre-book relationships...");
bookService.addGenre(book1.id(), new BookGenreRq(genre2.id(), "2024-01-15"));
bookService.addGenre(book2.id(), new BookGenreRq(genre2.id(), "2024-01-16"));
bookService.addGenre(book3.id(), new BookGenreRq(genre4.id(), "2024-01-17"));
log.info("Data population completed successfully!");
} catch (Exception e) {
log.error("Error during data population: {}", e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,14 @@
package com.example.server.api;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
public class PageHelper {
private PageHelper() {
}
public static Pageable toPageable(int page, int size) {
return PageRequest.of(page - 1, size, Sort.by("id"));
}
}

View File

@@ -0,0 +1,39 @@
package com.example.server.api;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import org.springframework.data.domain.Page;
public record PageRs<D>(
List<D> items,
int itemsCount,
int currentPage,
int currentSize,
int totalPages,
long totalItems,
boolean isFirst,
boolean isLast,
boolean hasNext,
boolean hasPrevious) {
public List<D> items() {
return Optional.ofNullable(items).orElse(Collections.emptyList());
}
public static <D, E> PageRs<D> from(Page<E> page, Function<E, D> mapper) {
return new PageRs<>(
page.getContent().stream().map(mapper::apply).toList(),
page.getNumberOfElements(),
page.getNumber() + 1,
page.getSize(),
page.getTotalPages(),
page.getTotalElements(),
page.isFirst(),
page.isLast(),
page.hasNext(),
page.hasPrevious());
}
}

View File

@@ -0,0 +1,13 @@
package com.example.server.api;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@Controller
public class SpaController {
@GetMapping(value = "/{path:^(?!api|assets|images|swagger-ui|.*\\.[a-zA-Z0-9]{2,10}).*}/**")
public String forwardToIndex(@PathVariable(required = false) String path) {
return "forward:/index.html";
}
}

View File

@@ -0,0 +1,99 @@
package com.example.server.api.book;
import com.example.server.api.PageHelper;
import com.example.server.api.PageRs;
import com.example.server.service.BookService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import org.springframework.web.bind.annotation.*;
import com.example.server.configuration.Constants;
import java.util.List;
@RestController
@RequestMapping(Constants.API_URL + BookController.URL)
public class BookController {
public static final String URL = "/book";
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping
public PageRs<BookRs> getAll(
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "20") @Min(1) int size) {
return bookService.getAll(PageHelper.toPageable(page, size));
}
@GetMapping("/all")
public List<BookRs> getAllWithoutPagination() {
return bookService.getAll();
}
@GetMapping("/{id}")
public BookRs get(@PathVariable Long id) {
return bookService.get(id);
}
@PostMapping
public BookRs create(@RequestBody @Valid BookRq dto) {
return bookService.create(dto);
}
@PutMapping("/{id}")
public BookRs update(@PathVariable Long id, @RequestBody @Valid BookRq dto) {
return bookService.update(id, dto);
}
@DeleteMapping("/{id}")
public BookRs delete(@PathVariable Long id) {
return bookService.delete(id);
}
@GetMapping("/{id}/genres")
public List<BookGenreRs> getGenres(@PathVariable Long id) {
return bookService.getBookGenres(id);
}
@PostMapping("/{id}/genres")
public BookGenreRs addGenre(@PathVariable Long id, @RequestBody @Valid BookGenreRq dto) {
return bookService.addGenre(id, dto);
}
@PutMapping("/{id}/genres/{genreId}")
public BookGenreRs updateGenre(@PathVariable Long id, @PathVariable Long genreId,
@RequestBody @Valid BookGenreUpdateRq dto) {
return bookService.updateGenre(id, genreId, dto);
}
@DeleteMapping("/{id}/genres/{genreId}")
public BookGenreRs deleteGenre(@PathVariable Long id, @PathVariable Long genreId) {
return bookService.deleteGenre(id, genreId);
}
// ===== НОВЫЕ ЭНДПОИНТЫ ДЛЯ ФИЛЬТРАЦИИ И ПОИСКА =====
@GetMapping("/genre/{genreId}")
public PageRs<BookRs> getBooksByGenre(
@PathVariable Long genreId,
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "20") @Min(1) int size) {
return bookService.getBooksByGenre(genreId, PageHelper.toPageable(page, size));
}
@GetMapping("/genre/{genreId}/all")
public List<BookRs> getBooksByGenreWithoutPagination(@PathVariable Long genreId) {
return bookService.getBooksByGenreWithoutPagination(genreId);
}
@GetMapping("/search")
public PageRs<BookRs> searchBooks(
@RequestParam(required = false) String query,
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "20") @Min(1) int size) {
return bookService.searchBooks(query, PageHelper.toPageable(page, size));
}
}

View File

@@ -0,0 +1,9 @@
package com.example.server.api.book;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
public record BookGenreRq(
@NotNull Long genreId,
@NotNull @Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "Date must be in YYYY-MM-DD format") String date) {
}

View File

@@ -0,0 +1,23 @@
package com.example.server.api.book;
import com.example.server.api.genre.GenreRs;
import com.example.server.entity.GenreBookEntity;
import java.util.List;
import java.util.stream.StreamSupport;
public record BookGenreRs(
GenreRs genre,
String date) {
public static BookGenreRs from(GenreBookEntity entity) {
return new BookGenreRs(
GenreRs.from(entity.getGenre()),
entity.getDate().toString());
}
public static List<BookGenreRs> fromList(Iterable<GenreBookEntity> entities) {
return StreamSupport.stream(entities.spliterator(), false)
.map(BookGenreRs::from)
.toList();
}
}

View File

@@ -0,0 +1,8 @@
package com.example.server.api.book;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
public record BookGenreUpdateRq(
@NotNull @Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "Date must be in YYYY-MM-DD format") String date) {
}

View File

@@ -0,0 +1,15 @@
package com.example.server.api.book;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record BookRq(
@NotBlank @Size(min = 2, max = 50) String title,
@NotBlank @Size(min = 2, max = 50) String author,
@NotNull @Min(1) int price,
String description,
String image) {
}

View File

@@ -0,0 +1,48 @@
package com.example.server.api.book;
import com.example.server.entity.BookEntity;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
import java.util.stream.StreamSupport;
public record BookRs(
@NotNull Long id,
@NotBlank(message = "Название обязательно")
@Size(min = 2, max = 50, message = "Название должно быть от 2 до 50 символов")
String title,
@NotBlank(message = "автор обязателен")
@Size(min = 2, max = 50, message = "ФИО должно быть от 2 до 50 символов")
String author,
@NotNull
@Min(value = 1, message = "Цена не может быть меньше 1 рубля")
Integer price,
String description,
String image,
List<BookGenreRs> genres
) {
public static BookRs from(BookEntity entity) {
return new BookRs(
entity.getId(),
entity.getTitle(),
entity.getAuthor(),
entity.getPrice(),
entity.getDescription(),
entity.getImage(),
BookGenreRs.fromList(entity.getGenreBooks()) // Добавляем жанры
);
}
public static List<BookRs> fromList(Iterable<BookEntity> entities) {
return StreamSupport.stream(entities.spliterator(), false)
.map(BookRs::from)
.toList();
}
}

View File

@@ -0,0 +1,75 @@
package com.example.server.api.cartItem;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicInteger;
import com.example.server.api.book.BookController;
import com.example.server.entity.CartItemEntity;
import com.example.server.service.CartItemService;
import jakarta.validation.Valid;
import org.slf4j.*;
import org.springframework.web.bind.annotation.*;
import com.example.server.error.NotFoundException;
import com.example.server.configuration.Constants;
@RestController
@RequestMapping(Constants.API_URL + CartController.URL)
public class CartController {
public static final String URL = "/cart";
private final Logger log = LoggerFactory.getLogger(CartController.class);
private final CartItemService cartItemService;
public CartController(CartItemService cartItemService) {
this.cartItemService = cartItemService;
}
@GetMapping
public List<CartItemRs> getAll() {
log.debug("Get all cart items");
return cartItemService.getAll();
}
@GetMapping("/{id}")
public CartItemRs get(@PathVariable Long id) {
log.debug("Get cart item with id {}", id);
return cartItemService.get(id);
}
@GetMapping("/search")
public CartItemRs getByBookId(@RequestParam Long bookId) {
return cartItemService.getByBookId(bookId)
.orElseThrow(() -> new NotFoundException(CartItemEntity.class, bookId));
}
@PostMapping
public CartItemRs addToCart(@RequestBody @Valid CartItemRq newItem) {
log.debug("Add to cart: {}", newItem);
return cartItemService.create(newItem);
}
@PutMapping("/{id}")
public CartItemRs updateCartItem(@PathVariable Long id, @RequestBody @Valid CartItemRq updatedItem) {
log.debug("Update cart item with id {}: {}", id, updatedItem);
return cartItemService.update(id, updatedItem);
}
@DeleteMapping("/{id}")
public CartItemRs removeFromCart(@PathVariable Long id) {
log.debug("Remove from cart: {}", id);
return cartItemService.delete(id);
}
@PatchMapping("/{id}/{quantity}")
public CartItemRs updateQuantity(@PathVariable Long id, @PathVariable int quantity) {
log.debug("Update quantity for cart item with id {} to {}", id, quantity);
return cartItemService.updateQuantity(id, quantity);
}
@DeleteMapping
public void clearCart() {
log.debug("Clear cart");
cartItemService.deleteAll();
}
}

View File

@@ -0,0 +1,10 @@
package com.example.server.api.cartItem;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
public record CartItemRq(
@NotNull Long bookId,
@NotNull @Min(1) Integer quantity) {
}

View File

@@ -0,0 +1,28 @@
package com.example.server.api.cartItem;
import com.example.server.api.book.BookRs;
import com.example.server.entity.CartItemEntity;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.stream.StreamSupport;
public record CartItemRs(
Long id,
BookRs book,
Integer quantity) {
public static CartItemRs from(CartItemEntity entity) {
return new CartItemRs(
entity.getId(),
BookRs.from(entity.getBook()),
entity.getQuantity());
}
public static List<CartItemRs> fromList(Iterable<CartItemEntity> entities) {
return StreamSupport.stream(entities.spliterator(), false)
.map(CartItemRs::from)
.toList();
}
}

View File

@@ -0,0 +1,68 @@
package com.example.server.api.genre;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicInteger;
import com.example.server.api.PageHelper;
import com.example.server.api.PageRs;
import com.example.server.error.NotFoundException;
import com.example.server.service.GenreService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import org.springframework.web.bind.annotation.*;
import com.example.server.configuration.Constants;
@RestController
@RequestMapping(Constants.API_URL + GenreController.URL)
public class GenreController {
public static final String URL = "/genre";
private final GenreService genreService;
public GenreController(GenreService genreService) {
this.genreService = genreService;
}
@GetMapping
public PageRs<GenreRs> getAll(
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "20") @Min(1) int size) {
return genreService.getAll(PageHelper.toPageable(page, size));
}
@GetMapping("/all")
public List<GenreRs> getAllWithoutPagination() {
return genreService.getAll();
}
@GetMapping("/{id}")
public GenreRs get(@PathVariable Long id) {
return genreService.get(id);
}
@PostMapping
public GenreRs create(@RequestBody @Valid GenreRq dto) {
return genreService.create(dto);
}
@PutMapping("/{id}")
public GenreRs update(@PathVariable Long id, @RequestBody @Valid GenreRq dto) {
return genreService.update(id, dto);
}
@DeleteMapping("/{id}")
public GenreRs delete(@PathVariable Long id) {
return genreService.delete(id);
}
@GetMapping("/stats")
public List<GenreStatsRs> getAllStats() {
return genreService.getAllGenresStats();
}
@GetMapping("/{id}/stats")
public GenreStatsRs getStats(@PathVariable Long id) {
return genreService.getGenreStats(id);
}
}

View File

@@ -0,0 +1,8 @@
package com.example.server.api.genre;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record GenreRq(
@NotBlank @Size(min = 2, max = 50) String name) {
}

View File

@@ -0,0 +1,24 @@
package com.example.server.api.genre;
import com.example.server.entity.GenreEntity;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
import java.util.stream.StreamSupport;
public record GenreRs(
Long id,
String name) {
public static GenreRs from(GenreEntity entity) {
return new GenreRs(entity.getId(), entity.getName());
}
public static List<GenreRs> fromList(Iterable<GenreEntity> entities) {
return StreamSupport.stream(entities.spliterator(), false)
.map(GenreRs::from)
.toList();
}
}

View File

@@ -0,0 +1,29 @@
package com.example.server.api.genre;
import com.example.server.entity.projection.GenreStatsProjection;
import java.util.List;
import java.util.stream.StreamSupport;
public record GenreStatsRs(
GenreRs genre,
Long totalBooks,
Long totalCartItems,
Double averagePrice) {
public static GenreStatsRs from(GenreStatsProjection projection) {
if (projection == null) {
return new GenreStatsRs(null, 0L, 0L, 0.0);
}
return new GenreStatsRs(
GenreRs.from(projection.getGenre()),
projection.getTotalBooks(),
projection.getTotalCartItems(),
projection.getAveragePrice());
}
public static List<GenreStatsRs> fromList(Iterable<GenreStatsProjection> projections) {
return StreamSupport.stream(projections.spliterator(), false)
.map(GenreStatsRs::from)
.toList();
}
}

View File

@@ -0,0 +1,9 @@
package com.example.server.configuration;
public class Constants {
public static final String DEV_ORIGIN = "http://localhost:5173";
public static final String API_URL = "/api/1.0";
private Constants() {
}
}

View File

@@ -0,0 +1,19 @@
package com.example.server.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.lang.NonNull;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfiguration implements WebMvcConfigurer{
@Override
public void addCorsMappings(@NonNull CorsRegistry registry) {
registry
.addMapping("/api/**") // Более широкий паттерн
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
.allowedOrigins("http://localhost:5173", "http://127.0.0.1:5173")
.allowedHeaders("*")
.allowCredentials(true);
}
}

View File

@@ -0,0 +1,25 @@
package com.example.server.entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.SequenceGenerator;
@MappedSuperclass
public abstract class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
@SequenceGenerator(name = "hibernate_sequence")
protected Long id;
protected BaseEntity() {}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
}

View File

@@ -0,0 +1,106 @@
package com.example.server.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "books")
public class BookEntity extends BaseEntity{
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String author;
private int price;
private String description;
@ManyToOne
@JoinColumn(name = "genre_id") // добавлена аннотация для связи
private GenreEntity genre;
private String image;
@OneToMany(mappedBy = "book")
@OrderBy("id ASC")
private Set<GenreBookEntity> genreBooks = new HashSet<>(); // добавлена обратная связь
public BookEntity() {
super();
}
public BookEntity(String title, String author, int price, String description, GenreEntity genre, String image) {
this();
this.title = title;
this.author = author;
this.price = price;
this.description = description;
this.genre = genre;
this.image = image;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public int getPrice() {
return price;
}
public void setPrice(int price) {
this.price = price;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
public GenreEntity getGenre() {
return genre;
}
public void setGenre(GenreEntity genre) {
this.genre = genre;
}
public Set<GenreBookEntity> getGenreBooks() {
return genreBooks;
}
public void addGenreBook(GenreBookEntity genreBook) {
if (genreBook.getBook() != this) {
genreBook.setBook(this);
}
genreBooks.add(genreBook);
}
}

View File

@@ -0,0 +1,43 @@
package com.example.server.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "cartitems")
public class CartItemEntity extends BaseEntity{
@ManyToOne
@JoinColumn(name = "book_id", nullable = false) // добавлена аннотация для связи
private BookEntity book;
private int quantity;
public CartItemEntity() {
super();
}
public CartItemEntity(BookEntity book, int quantity) {
this();
this.book = book;
this.quantity = quantity;
}
public BookEntity getBook() {
return book;
}
public void setBook(BookEntity book) {
this.book = book;
}
public int getQuantity() {
return quantity;
}
public void setQuantity(int quantity) {
this.quantity = quantity;
}
}

View File

@@ -0,0 +1,85 @@
package com.example.server.entity;
import java.time.LocalDate;
import java.util.Objects;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
import jakarta.persistence.Table;
@Entity
@Table(name = "genre_book")
public class GenreBookEntity {
@EmbeddedId
private GenreBookId id = new GenreBookId();
@ManyToOne
@MapsId("genreId")
@JoinColumn(name = "genre_id", nullable = false)
private GenreEntity genre;
@ManyToOne
@MapsId("bookId")
@JoinColumn(name = "book_id", nullable = false)
private BookEntity book;
private LocalDate date;
public GenreBookEntity() {
}
public GenreBookEntity(GenreEntity genre, BookEntity book, LocalDate date) {
this.genre = genre;
this.book = book;
this.date = date;
this.id = new GenreBookId(genre.getId(), book.getId());
}
public GenreBookId getId() {
return id;
}
public void setId(GenreBookId id) {
this.id = id;
}
public GenreEntity getGenre() {
return genre;
}
public void setGenre(GenreEntity genre) {
this.genre = genre;
}
public BookEntity getBook() {
return book;
}
public void setBook(BookEntity book) {
this.book = book;
}
public LocalDate getDate() {
return date;
}
public void setDate(LocalDate date) {
this.date = date;
}
@Override
public int hashCode() {
return Objects.hash(id);
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
GenreBookEntity other = (GenreBookEntity) obj;
return Objects.equals(id, other.id);
}
}

View File

@@ -0,0 +1,51 @@
package com.example.server.entity;
import java.util.Objects;
import jakarta.persistence.Embeddable;
@Embeddable
public class GenreBookId {
private Long genreId;
private Long bookId;
public GenreBookId() {
}
public GenreBookId(Long genreId, Long bookId) {
this.genreId = genreId;
this.bookId = bookId;
}
public Long getGenreId() {
return genreId;
}
public void setGenreId(Long genreId) {
this.genreId = genreId;
}
public Long getBookId() {
return bookId;
}
public void setBookId(Long bookId) {
this.bookId = bookId;
}
@Override
public int hashCode() {
return Objects.hash(genreId, bookId);
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
GenreBookId other = (GenreBookId) obj;
return Objects.equals(genreId, other.genreId) && Objects.equals(bookId, other.bookId);
}
}

View File

@@ -0,0 +1,47 @@
package com.example.server.entity;
import java.util.HashSet;
import java.util.Set;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
@Entity
@Table(name = "genres")
public class GenreEntity extends BaseEntity{
@Column(length = 100, nullable = false, unique = true)
private String name;
@OneToMany(mappedBy = "genre")
@OrderBy("id ASC")
private Set<GenreBookEntity> genreBooks = new HashSet<>();
public GenreEntity() {
super();
}
public GenreEntity(String name) {
this();
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Set<GenreBookEntity> getGenreBooks() {
return genreBooks;
}
public void addBook(GenreBookEntity genreBook) {
if (genreBook.getGenre() != this) {
genreBook.setGenre(this);
}
genreBooks.add(genreBook);
}
}

View File

@@ -0,0 +1,10 @@
package com.example.server.entity.projection;
import com.example.server.entity.GenreEntity;
public interface GenreStatsProjection {
GenreEntity getGenre();
Long getTotalBooks();
Long getTotalCartItems();
Double getAveragePrice();
}

View File

@@ -0,0 +1,26 @@
package com.example.server.error;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
@RestControllerAdvice
public class AdviceController {
private HttpStatus getStatus(HttpServletRequest request) {
final Integer code = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
final HttpStatus status = (code != null) ? HttpStatus.resolve(code) : null;
return (status != null) ? status : HttpStatus.INTERNAL_SERVER_ERROR;
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResponseEntity<AdviceErrorBody> handleAnyException(HttpServletRequest request, Throwable ex) {
final HttpStatus status = getStatus(request);
return new ResponseEntity<>(new AdviceErrorBody(status.value(), ex.getMessage()), status);
}
}

View File

@@ -0,0 +1,5 @@
package com.example.server.error;
public record AdviceErrorBody(int status, String message) {
}

View File

@@ -0,0 +1,11 @@
package com.example.server.error;
public class AlreadyExistsException extends RuntimeException {
public <T> AlreadyExistsException(Class<T> entClass, String name) {
super(String.format("%s with name %s is already exists", entClass.getSimpleName(), name));
}
public <T> AlreadyExistsException(Class<T> entClass, Long id1, Long id2) {
super(String.format("%s with id [%s, %s] is already exists", entClass.getSimpleName(), id1, id2));
}
}

View File

@@ -0,0 +1,10 @@
package com.example.server.error;
public class NotFoundException extends RuntimeException {
public <T> NotFoundException(Class<T> clazz, Long id){
super(String.format("%s with id %s is not found", clazz.getSimpleName(), id));
}
public <T> NotFoundException(Class<T> entClass, Long id1, Long id2) {
super(String.format("%s with id [%s, %s] is not found", entClass.getSimpleName(), id1, id2));
}
}

View File

@@ -0,0 +1,31 @@
package com.example.server.repository;
import com.example.server.entity.BookEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface BookRepository extends JpaRepository<BookEntity, Long> {
// Поиск книг по жанру с пагинацией
@Query("SELECT DISTINCT b FROM BookEntity b " +
"JOIN b.genreBooks gb " +
"JOIN gb.genre g " +
"WHERE g.id = :genreId")
Page<BookEntity> findByGenreId(@Param("genreId") Long genreId, Pageable pageable);
// Поиск книг по названию или автору (без учета регистра)
@Query("SELECT b FROM BookEntity b " +
"WHERE LOWER(b.title) LIKE LOWER(CONCAT('%', :query, '%')) " +
"OR LOWER(b.author) LIKE LOWER(CONCAT('%', :query, '%')) " +
"OR LOWER(b.description) LIKE LOWER(CONCAT('%', :query, '%'))")
Page<BookEntity> searchBooks(@Param("query") String query, Pageable pageable);
// Альтернативный вариант поиска по жанру (через многие-ко-многим)
@Query("SELECT b FROM BookEntity b " +
"WHERE EXISTS (SELECT 1 FROM b.genreBooks gb WHERE gb.genre.id = :genreId)")
List<BookEntity> findBooksByGenreId(@Param("genreId") Long genreId);
}

View File

@@ -0,0 +1,15 @@
package com.example.server.repository;
import com.example.server.entity.CartItemEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
public interface CartItemRepository extends JpaRepository<CartItemEntity, Long> {
@Query("SELECT ci FROM CartItemEntity ci WHERE ci.book.id = :bookId")
List<CartItemEntity> findByBookId(@Param("bookId") Long bookId);
}

View File

@@ -0,0 +1,11 @@
package com.example.server.repository;
import com.example.server.entity.GenreBookEntity;
import com.example.server.entity.GenreBookId;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface GenreBookRepository extends JpaRepository<GenreBookEntity, GenreBookId> {
Optional<GenreBookEntity> findOneByBookIdAndGenreId(Long bookId, Long genreId);
}

View File

@@ -0,0 +1,43 @@
package com.example.server.repository;
import com.example.server.entity.GenreEntity;
import com.example.server.entity.projection.GenreStatsProjection;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
public interface GenreRepository extends JpaRepository<GenreEntity, Long> {
Optional<GenreEntity> findOneByNameIgnoreCase(String name);
// Статистика по всем жанрам
@Query("select g as genre, " +
"count(b) as totalBooks, " +
"count(ci) as totalCartItems, " +
"avg(b.price) as averagePrice " +
"from GenreEntity g " +
"left join g.genreBooks gb " +
"left join gb.book b " +
"left join CartItemEntity ci on ci.book = b " +
"group by g " +
"having count(b) > 0 " +
"order by g.id")
List<GenreStatsProjection> getAllGenresStatistics();
// Статистика по конкретному жанру
@Query("select g as genre, " +
"count(b) as totalBooks, " +
"count(ci) as totalCartItems, " +
"avg(b.price) as averagePrice " +
"from GenreEntity g " +
"left join g.genreBooks gb " +
"left join gb.book b " +
"left join CartItemEntity ci on ci.book = b " +
"where g.id = :genreId " +
"group by g " +
"having count(b) > 0")
GenreStatsProjection getGenreStatistics(@Param("genreId") Long genreId);
}

View File

@@ -0,0 +1,184 @@
package com.example.server.service;
import com.example.server.api.PageRs;
import com.example.server.api.book.*;
import com.example.server.entity.BookEntity;
import com.example.server.entity.GenreBookEntity;
import com.example.server.entity.GenreEntity;
import com.example.server.error.AlreadyExistsException;
import com.example.server.error.NotFoundException;
import com.example.server.repository.BookRepository;
import com.example.server.repository.GenreBookRepository;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
@Service
public class BookService {
private final BookRepository repository;
private final GenreBookRepository genreBookRepository;
private final GenreService genreService;
public BookService(BookRepository repository,GenreBookRepository genreBookRepository, GenreService genreService) {
this.repository = repository;
this.genreBookRepository = genreBookRepository;
this.genreService = genreService;
}
@Transactional(propagation = Propagation.MANDATORY)
public BookEntity getEntity(Long id) {
return repository.findById(id)
.orElseThrow(() -> new NotFoundException(BookEntity.class, id));
}
@Transactional(readOnly = true)
public List<BookRs> getAll() {
List<BookEntity> books = repository.findAll();
// Инициализируем lazy-коллекции
books.forEach(book -> {
if (book.getGenreBooks() != null) {
book.getGenreBooks().size();
}
});
return BookRs.fromList(books);
}
@Transactional(readOnly = true)
public PageRs<BookRs> getAll(Pageable pageable) {
return PageRs.from(repository.findAll(pageable), BookRs::from);
}
@Transactional(readOnly = true)
public BookRs get(Long id) {
final BookEntity entity = getEntity(id);
// Инициализируем lazy-коллекцию
if (entity.getGenreBooks() != null) {
entity.getGenreBooks().size();
}
return BookRs.from(entity);
}
@Transactional
public BookRs create(BookRq dto) {
BookEntity entity = new BookEntity(
dto.title(),
dto.author(),
dto.price(),
dto.description(),
null, // genre будет установлен через связь
dto.image());
entity = repository.save(entity);
return BookRs.from(entity);
}
@Transactional
public BookRs update(Long id, BookRq dto) {
BookEntity entity = getEntity(id);
entity.setTitle(dto.title());
entity.setAuthor(dto.author());
entity.setPrice(dto.price());
entity.setDescription(dto.description());
entity.setImage(dto.image());
entity = repository.save(entity);
return BookRs.from(entity);
}
@Transactional
public BookRs delete(Long id) {
final BookEntity entity = getEntity(id);
repository.delete(entity);
return BookRs.from(entity);
}
@Transactional(readOnly = true)
public List<BookGenreRs> getBookGenres(Long id) {
return BookGenreRs.fromList(getEntity(id).getGenreBooks());
}
@Transactional
public BookGenreRs addGenre(Long bookId, BookGenreRq dto) {
final BookEntity book = getEntity(bookId);
final GenreEntity genre = genreService.getEntity(dto.genreId());
// Проверяем, существует ли уже такая связь
if (genreBookRepository.findOneByBookIdAndGenreId(book.getId(), genre.getId()).isPresent()) {
throw new AlreadyExistsException(GenreBookEntity.class, book.getId(), genre.getId());
}
final LocalDate date = LocalDate.parse(dto.date());
final GenreBookEntity genreBook = new GenreBookEntity(genre, book, date);
// Сохраняем связь напрямую в репозиторий
genreBookRepository.save(genreBook);
return BookGenreRs.from(genreBook);
}
@Transactional
public BookGenreRs updateGenre(Long bookId, Long genreId, BookGenreUpdateRq dto) {
final BookEntity book = getEntity(bookId);
final GenreEntity genre = genreService.getEntity(genreId);
final GenreBookEntity genreBook = genreBookRepository
.findOneByBookIdAndGenreId(book.getId(), genre.getId())
.orElseThrow(() -> new NotFoundException(GenreBookEntity.class, book.getId(), genre.getId()));
genreBook.setDate(LocalDate.parse(dto.date()));
genreBookRepository.save(genreBook);
return BookGenreRs.from(genreBook);
}
@Transactional
public BookGenreRs deleteGenre(Long bookId, Long genreId) {
final BookEntity book = getEntity(bookId);
final GenreEntity genre = genreService.getEntity(genreId);
final GenreBookEntity genreBook = genreBookRepository
.findOneByBookIdAndGenreId(book.getId(), genre.getId())
.orElseThrow(() -> new NotFoundException(GenreBookEntity.class, book.getId(), genre.getId()));
// Удаляем связь напрямую из репозитория
genreBookRepository.delete(genreBook);
return BookGenreRs.from(genreBook);
}
// ===== НОВЫЕ МЕТОДЫ ДЛЯ ПАГИНАЦИИ И ФИЛЬТРАЦИИ =====
@Transactional(readOnly = true)
public PageRs<BookRs> getBooksByGenre(Long genreId, Pageable pageable) {
// Проверяем, существует ли жанр
genreService.getEntity(genreId);
// Получаем книги по жанру с пагинацией
return PageRs.from(repository.findByGenreId(genreId, pageable), BookRs::from);
}
@Transactional(readOnly = true)
public PageRs<BookRs> searchBooks(String query, Pageable pageable) {
if (query == null || query.trim().isEmpty()) {
// Если запрос пустой, возвращаем все книги
return PageRs.from(repository.findAll(pageable), BookRs::from);
}
// Ищем книги по запросу
return PageRs.from(repository.searchBooks(query.trim(), pageable), BookRs::from);
}
@Transactional(readOnly = true)
public List<BookRs> getBooksByGenreWithoutPagination(Long genreId) {
// Проверяем, существует ли жанр
genreService.getEntity(genreId);
// Получаем книги по жанру без пагинации
List<BookEntity> books = repository.findBooksByGenreId(genreId);
// Инициализируем lazy-коллекции
books.forEach(book -> {
if (book.getGenreBooks() != null) {
book.getGenreBooks().size();
}
});
return BookRs.fromList(books);
}
}

Some files were not shown because too many files have changed in this diff Show More