Compare commits
8 Commits
labWork5
...
branch-2.4
| Author | SHA1 | Date | |
|---|---|---|---|
| a27c99a267 | |||
| 03f774c8f4 | |||
| 9cb68ecec9 | |||
| 8a7eb58d7c | |||
| 8a19ddbffb | |||
| 8f9394c01d | |||
| 600eb67f5a | |||
| f4a1992f8d |
14
.vscode/launch.json
vendored
Normal 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
@@ -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"
|
||||
}
|
||||
18
MyWebSite/.vscode/launch.json
vendored
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
6
MyWebSite/.vscode/settings.json
vendored
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
@@ -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 /> Удалить
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
50
MyWebSite/components/PageSizeSelector.jsx
Normal 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;
|
||||
93
MyWebSite/components/Pagination.jsx
Normal 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;
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
5
MyWebSite/dist/assets/css/index-DvDKDXQB.css
vendored
Normal file
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 817 KiB After Width: | Height: | Size: 817 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 370 KiB After Width: | Height: | Size: 370 KiB |
64
MyWebSite/dist/assets/js/index-D_bqwX26.js
vendored
Normal file
1
MyWebSite/dist/assets/style-CVNOuhRW.css
vendored
@@ -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}}
|
||||
200
MyWebSite/dist/basket.html
vendored
@@ -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>
|
||||
211
MyWebSite/dist/catalog.html
vendored
@@ -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>
|
||||
156
MyWebSite/dist/contactUs.html
vendored
@@ -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>
|
||||
155
MyWebSite/dist/discounts.html
vendored
@@ -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>
|
||||
131
MyWebSite/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
BIN
MyWebSite/images/default-book.jpg
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -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",
|
||||
|
||||
226
MyWebSite/pages/BasketPage.jsx
Normal 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;
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
123
MyWebSite/pages/ContactUsPage.jsx
Normal 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;
|
||||
112
MyWebSite/pages/DiscountsPage.jsx
Normal 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;
|
||||
93
MyWebSite/pages/HomePage.jsx
Normal 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;
|
||||
@@ -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`),
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
BIN
MyWebSite/Отчёт_Лб6.docx
Normal file
BIN
build/resources/main/static/assets/background-oYp1cNqc.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
5
build/resources/main/static/assets/index-BLg3Q5Rn.css
Normal file
65
build/resources/main/static/assets/index-DbQzQPoN.js
Normal file
1
build/resources/main/static/assets/index-DbQzQPoN.js.map
Normal file
21
build/resources/main/static/index.html
Normal 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
@@ -0,0 +1,3 @@
|
||||
/gradlew text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.jar binary
|
||||
37
server/.gitignore
vendored
Normal 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
@@ -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
@@ -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
|
||||
}
|
||||
64
server/build.migrations.gradle
Normal 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
17746
server/data.trace.db
Normal file
6
server/data/bookstore.lock.db
Normal 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
1015
server/data/bookstore.trace.db
Normal file
BIN
server/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
server/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,2 @@
|
||||
rootProject.name = 'server'
|
||||
|
||||
118
server/src/main/java/com/example/server/ServerApplication.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
server/src/main/java/com/example/server/api/PageHelper.java
Normal 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"));
|
||||
}
|
||||
}
|
||||
39
server/src/main/java/com/example/server/api/PageRs.java
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
15
server/src/main/java/com/example/server/api/book/BookRq.java
Normal 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) {
|
||||
}
|
||||
48
server/src/main/java/com/example/server/api/book/BookRs.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
106
server/src/main/java/com/example/server/entity/BookEntity.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.example.server.error;
|
||||
|
||||
public record AdviceErrorBody(int status, String message) {
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
184
server/src/main/java/com/example/server/service/BookService.java
Normal 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);
|
||||
}
|
||||
}
|
||||