commit 38b382bdc9951461659a6e5607957c92027d11ca Author: abakumov-a-a Date: Sun Dec 7 13:34:02 2025 +0300 Initial commit diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..056f6d5 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,76 @@ +{ + "env": { + "node": true, + "es2022": true + }, + "extends": "eslint:recommended", + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "rules": { + "no-unused-vars": ["warn", { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + }], + "no-console": "off", + "no-undef": "error", + "no-redeclare": "error", + "no-unreachable": "error", + "no-duplicate-case": "error", + "no-empty": "warn", + "no-extra-semi": "warn", + "no-func-assign": "error", + "no-invalid-regexp": "error", + "no-irregular-whitespace": "error", + "no-obj-calls": "error", + "no-sparse-arrays": "error", + "no-unexpected-multiline": "error", + "no-unsafe-finally": "error", + "no-unsafe-negation": "error", + "use-isnan": "error", + "valid-typeof": "error", + "curly": ["warn", "all"], + "eqeqeq": ["warn", "always"], + "no-eval": "error", + "no-implied-eval": "error", + "no-new-wrappers": "error", + "no-throw-literal": "error", + "no-with": "error", + "radix": "warn", + "no-var": "error", + "prefer-const": "warn", + "prefer-arrow-callback": "warn", + "arrow-spacing": "warn", + "no-trailing-spaces": "warn", + "comma-dangle": ["warn", "never"], + "indent": ["warn", 4, { "SwitchCase": 1 }], + "quotes": ["warn", "single", { "avoidEscape": true }], + "semi": ["warn", "always"], + "space-before-blocks": "warn", + "space-before-function-paren": ["warn", { + "anonymous": "always", + "named": "never", + "asyncArrow": "always" + }], + "space-in-parens": ["warn", "never"], + "space-infix-ops": "warn", + "space-unary-ops": "warn", + "spaced-comment": ["warn", "always"], + "object-curly-spacing": ["warn", "always"], + "array-bracket-spacing": ["warn", "never"], + "comma-spacing": "warn", + "key-spacing": "warn", + "brace-style": ["warn", "1tbs"], + "camelcase": ["warn", { "properties": "always" }] + }, + "ignorePatterns": [ + "node_modules/**", + "test-configs/**", + "*.min.js", + "coverage/**", + "dist/**", + "build/**" + ] +} + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a82947 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Dependencies +node_modules/ +package-lock.json +yarn.lock + +# Environment variables +.env +.env.local +.env.*.local + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Build +dist/ +build/ + +# Coverage +coverage/ +.nyc_output/ + diff --git a/NGINX_SETUP.md b/NGINX_SETUP.md new file mode 100644 index 0000000..e27b983 --- /dev/null +++ b/NGINX_SETUP.md @@ -0,0 +1,130 @@ +# Настройка Nginx для работы с бэкендом и фронтендом + +## Проблема +В текущей конфигурации nginx проксирует только фронтенд (порт 3000), но не проксирует API запросы на бэкенд. +Из-за этого изображения не загружаются, так как запросы к `/api/v1/files/*` не доходят до бэкенда. + +## Решение +Нужно добавить location для `/api` в конфигурацию nginx. + +## Обновленная конфигурация nginx + +```nginx +server { + listen 443 ssl; + server_name app.mylocaldomain.online; + + ssl_certificate /etc/nginx/ssl/mylocaldomain.online.crt; + ssl_certificate_key /etc/nginx/ssl/mylocaldomain.online.key; + ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; + + # ВАЖНО: location /api должен быть ПЕРЕД location /, + # так как nginx выбирает наиболее специфичное совпадение + + # Проксирование API запросов на бэкенд (включая /api/v1/files/* для изображений) + location /api { + # Укажите IP и порт вашего бэкенда + # Если бэкенд на том же сервере: + proxy_pass http://127.0.0.1:443; + # Или если бэкенд на другом сервере (например, 10.15.5.113:443): + # proxy_pass http://10.15.5.113:443; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + + # Важно для работы с большими файлами (изображениями) + proxy_read_timeout 36000s; + proxy_connect_timeout 60s; + proxy_send_timeout 36000s; + proxy_buffering off; # Отключаем буферизацию для потоковой передачи файлов + + # Важно для работы с WebSocket (если используется) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Увеличиваем размер буферов для больших файлов + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + } + + # Проксирование всех остальных запросов на фронтенд (React/Vite) + location / { + proxy_pass http://10.15.5.252:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_read_timeout 36000s; + proxy_buffers 16 4k; + proxy_buffer_size 2k; + + # Важно для работы с WebSocket + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +``` + +## Шаги для применения + +1. Отредактируйте конфигурацию nginx: + ```bash + sudo nano /etc/nginx/sites-available/app.mylocaldomain.online + # или + sudo nano /etc/nginx/conf.d/app.mylocaldomain.online.conf + ``` + +2. Добавьте location `/api` ПЕРЕД location `/` (порядок важен!) + +3. Укажите правильный IP и порт бэкенда в `proxy_pass` для location `/api` + +4. Проверьте конфигурацию: + ```bash + sudo nginx -t + ``` + +5. Перезагрузите nginx: + ```bash + sudo systemctl reload nginx + # или + sudo service nginx reload + ``` + +## Проверка работы + +После применения конфигурации проверьте: + +1. API должен быть доступен: `https://app.mylocaldomain.online/api/v1/products` +2. Изображения должны загружаться: `https://app.mylocaldomain.online/api/v1/files/...` +3. В логах nginx должны появиться запросы к `/api/v1/files/*` + +## Отладка + +Если изображения все еще не загружаются: + +1. Проверьте логи nginx: + ```bash + sudo tail -f /var/log/nginx/access.log + sudo tail -f /var/log/nginx/error.log + ``` + +2. Проверьте логи бэкенда - должны быть запросы к `/api/v1/files/*` + +3. Проверьте, что бэкенд запущен и слушает на указанном порту: + ```bash + netstat -tlnp | grep :443 + # или + ss -tlnp | grep :443 + ``` + +4. Проверьте доступность бэкенда напрямую (если возможно): + ```bash + curl -k https://127.0.0.1:443/api/v1/products + ``` diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..293c5e6 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,98 @@ +# Быстрый старт + +## Установка и запуск всего проекта + +### 1. Установка зависимостей + +```bash +# Backend +npm install + +# Frontend +cd client +npm install +cd .. +``` + +### 2. Настройка базы данных + +Убедитесь, что PostgreSQL запущен и создайте базу данных: + +```bash +# Выполните SQL скрипты +psql -U postgres -d shop_platform -f sql/1_schema.sql +psql -U postgres -d shop_platform -f sql/2_seed_data.sql +psql -U postgres -d shop_platform -f sql/3_triggers.sql +psql -U postgres -d shop_platform -f sql/4_indexes.sql +``` + +### 3. Настройка переменных окружения (опционально) + +Создайте файл `.env` в корне проекта или настройте `conf/config.yml` + +### 4. Запуск + +#### Вариант 1: Запуск в отдельных терминалах + +**Терминал 1 - Backend:** +```bash +npm run dev +``` + +**Терминал 2 - Frontend:** +```bash +npm run client:dev +``` + +#### Вариант 2: Использование npm скриптов + +```bash +# Backend +npm run dev + +# Frontend (в другом терминале) +npm run client:dev +``` + +### 5. Доступ к приложению + +- **Frontend**: http://localhost:3000 +- **Backend API**: http://localhost:80 +- **Swagger UI**: http://localhost:80/docs + +## Структура URL + +- Главная: http://localhost:3000/ +- Каталог товаров: http://localhost:3000/products +- Категории: http://localhost:3000/categories +- Корзина: http://localhost:3000/cart + +## Настройка API URL для фронтенда + +По умолчанию фронтенд обращается к `http://localhost:80/api/v1`. + +Для изменения создайте файл `client/.env`: + +```env +VITE_API_URL=http://your-backend-url/api/v1 +``` + +## Troubleshooting + +### CORS ошибки + +Убедитесь, что в бэкенде включен CORS и разрешены запросы с `http://localhost:3000` + +### Порт занят + +Измените порт фронтенда в `client/vite.config.js`: + +```js +server: { + port: 3001, // вместо 3000 +} +``` + +### API не отвечает + +Проверьте, что бэкенд запущен и доступен по адресу, указанному в `VITE_API_URL` diff --git a/README.md b/README.md new file mode 100644 index 0000000..c546805 --- /dev/null +++ b/README.md @@ -0,0 +1,317 @@ +# Shop Platform + +Полнофункциональный интернет-магазин с RESTful API на Fastify и современным React фронтендом. + +## ✨ Возможности + +- 🛍️ Каталог товаров с фильтрацией и поиском +- 🛒 Корзина покупок +- 📱 Адаптивный дизайн +- 🔍 Поиск товаров +- 📂 Категории товаров +- 📄 Детальные страницы товаров +- 🎨 Современный и красивый UI + +## 🚀 Технологии + +### Backend +- **Fastify** - быстрый веб-фреймворк для Node.js +- **PostgreSQL** - реляционная база данных +- **Swagger/OpenAPI** - документация API +- **DAO Pattern** - паттерн доступа к данным +- **Keycloak** - интеграция с системой аутентификации (опционально) +- **YAML Config** - конфигурация через YAML файл + +### Frontend +- **React 18** - современная UI библиотека +- **Vite** - быстрый сборщик и dev-сервер +- **React Router** - клиентский роутинг +- **Axios** - HTTP клиент для API запросов + +## 📋 Требования + +- Node.js >= 18.0.0 +- PostgreSQL >= 12.0 +- npm или yarn + +## 🛠️ Установка + +### Backend + +1. Клонируйте репозиторий или используйте существующий проект + +2. Установите зависимости: +```bash +npm install +``` + +### Frontend + +1. Перейдите в папку клиента: +```bash +cd client +npm install +``` + +3. Настройте базу данных: + - Создайте базу данных PostgreSQL + - Выполните SQL скрипты в порядке: + ```bash + psql -U postgres -d shop_platform -f sql/schema.sql + psql -U postgres -d shop_platform -f sql/indexes.sql + psql -U postgres -d shop_platform -f sql/triggers.sql + psql -U postgres -d shop_platform -f sql/seed_data.sql + ``` + +4. Настройте конфигурацию: + - Отредактируйте `config.yaml` или установите переменные окружения + - Формат переменных: `${VAR_NAME:default_value}` + +## ⚙️ Конфигурация + +Конфигурация загружается из файла `config.yaml` с поддержкой переменных окружения. + +### Формат конфигурации: + +```yaml +keycloak: + host: ${KEYCLOAK_HOST:158.160.87.50} + port: ${KEYCLOAK_PORT:10090} + realm: ${KEYCLOAK_REALM:sso} + # ... + +postgres: + host: ${POSTGRES_HOST:158.160.87.50} + port: ${POSTGRES_PORT:11432} + # ... + +web: + http_port: ${WEB_HTTP_PORT:80} + https_port: ${WEB_HTTPS_PORT:443} + # ... +``` + +### Переменные окружения: + +Вы можете переопределить значения через переменные окружения: +```bash +export POSTGRES_HOST=localhost +export POSTGRES_PORT=5432 +export KEYCLOAK_HOST=keycloak.example.com +``` + +## 🏃 Запуск + +### Backend + +#### Режим разработки +```bash +npm run dev +``` + +#### Продакшн режим +```bash +npm start +``` + +Сервер будет доступен по адресу из конфигурации (по умолчанию `http://0.0.0.0:80`) + +### Frontend + +#### Режим разработки +```bash +cd client +npm run dev +``` + +Фронтенд будет доступен по адресу `http://localhost:3000` + +#### Сборка для продакшена +```bash +cd client +npm run build +``` + +Собранные файлы будут в папке `client/dist/` + +## 📚 API Документация + +После запуска сервера Swagger документация доступна по адресу: +- **Swagger UI**: http://localhost:80/docs (или указанный порт) +- **Swagger JSON**: http://localhost:80/docs/json + +## 📁 Структура проекта + +``` +shop-platform/ +├── client/ # React фронтенд +│ ├── src/ +│ │ ├── api/ # API клиенты +│ │ ├── components/ # React компоненты +│ │ ├── pages/ # Страницы приложения +│ │ └── App.jsx # Главный компонент +│ ├── package.json +│ └── vite.config.js +├── conf/ +│ └── config.yml # Конфигурационный файл +├── src/ +│ ├── dao/ # Data Access Objects +│ ├── routes/ # Маршруты API +│ ├── plugins/ # Fastify плагины +│ └── utils/ # Утилиты +├── sql/ # SQL скрипты +├── tests/ # Тесты +├── app.js # Главный файл приложения +└── package.json +├── package.json +└── README.md +``` + +## 🔌 API Endpoints + +### Аутентификация (Auth) +- `POST /auth/register` - Регистрация по email и паролю +- `POST /auth/login` - Вход по email и паролю +- `POST /auth/telegram` - Вход/регистрация по Telegram ID +- `GET /auth/me` - Получить текущего пользователя (требует аутентификации) +- `POST /auth/change-password` - Изменить пароль (требует аутентификации) + +### Товары (Products) +- `GET /products` - Получить список товаров +- `GET /products/:id` - Получить товар по ID +- `POST /products` - Создать товар +- `PUT /products/:id` - Обновить товар +- `DELETE /products/:id` - Удалить товар + +### Категории (Categories) +- `GET /categories` - Получить список категорий +- `GET /categories/tree` - Получить дерево категорий +- `GET /categories/:id` - Получить категорию по ID + +## 🔐 Аутентификация + +Система поддерживает два способа аутентификации: + +### 1. По email и паролю +```bash +# Регистрация +curl -X POST http://localhost:80/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "password": "password123", + "first_name": "Иван", + "last_name": "Иванов" + }' + +# Вход +curl -X POST http://localhost:80/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "password": "password123" + }' +``` + +### 2. По Telegram ID +```bash +curl -X POST http://localhost:80/auth/telegram \ + -H "Content-Type: application/json" \ + -d '{ + "telegram_id": 123456789, + "first_name": "Иван", + "last_name": "Иванов", + "phone": "+79001234567" + }' +``` + +### Использование токена +После успешной аутентификации вы получите JWT токен. Используйте его в заголовке: +```bash +curl http://localhost:80/auth/me \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +## 🔑 Keycloak интеграция + +Система поддерживает интеграцию с Keycloak для аутентификации. Настройки находятся в `config.yaml`: + +```yaml +keycloak: + host: ${KEYCLOAK_HOST:158.160.87.50} + port: ${KEYCLOAK_PORT:10090} + realm: ${KEYCLOAK_REALM:sso} + ssl: ${KEYCLOAK_TLS:false} + client_id: ${KEYCLOAK_CLIENT_ID:sso} + client_secret: ${KEYCLOAK_CLIENT_SECRET:...} +``` + +## 🔒 TLS/SSL + +Для включения HTTPS настройте в `config.yaml`: + +```yaml +web: + tsl: true + https_port: 443 + +tsl: + path: ./tsl + key: key.pem + cert: cert.pem +``` + +Сертификаты должны находиться в указанной директории. + +## 📝 Переменные окружения + +Все настройки можно переопределить через переменные окружения. Формат в `config.yaml`: `${VAR_NAME:default_value}` + +Основные переменные: +- `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` +- `KEYCLOAK_HOST`, `KEYCLOAK_PORT`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_CLIENT_SECRET` +- `WEB_HTTP_PORT`, `WEB_HTTPS_PORT`, `WEB_HOST`, `WEB_TSL` +- `JWT_SECRET`, `JWT_EXPIRES_IN` +- `SYNC_TOKEN` + +## 🧪 Разработка + +### Добавление нового DAO + +1. Создайте файл в `src/dao/`: +```javascript +import { BaseDAO } from './BaseDAO.js'; + +export class NewDAO extends BaseDAO { + constructor() { + super('table_name'); + } + + // Кастомные методы +} +``` + +### Добавление новых роутов + +1. Создайте файл в `src/routes/`: +```javascript +export async function newRoutes(fastify, options) { + fastify.get('/new', async (request, reply) => { + return { success: true }; + }); +} +``` + +2. Зарегистрируйте в `src/server.js`: +```javascript +import { newRoutes } from './routes/new.routes.js'; +await fastify.register(newRoutes); +``` + +## 📄 Лицензия + +ISC + +## 👥 Авторы + +Проект создан для интернет-магазина. diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..b14054c --- /dev/null +++ b/USAGE.md @@ -0,0 +1,242 @@ +# Руководство по использованию + +## Быстрый старт + +### 1. Установка зависимостей +```bash +npm install +``` + +###2. Настройка базы данных + +Создайте базу данных PostgreSQL: +```sql +CREATE DATABASE shop_platform; +``` + +Выполните SQL скрипты: +```bash +psql -U postgres -d shop_platform -f sql/schema.sql +psql -U postgres -d shop_platform -f sql/indexes.sql +psql -U postgres -d shop_platform -f sql/triggers.sql +psql -U postgres -d shop_platform -f sql/seed_data.sql +``` + +### 3. Настройка переменных окружения + +Создайте файл `.env`: +```env +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=shop_platform +DB_USER=postgres +DB_PASSWORD=your_password + +PORT=3000 +HOST=0.0.0.0 +NODE_ENV=development +``` + +### 4. Запуск сервера +```bash +npm run dev +``` + +## Использование API + +### Получить список товаров +```bash +curl http://localhost:3000/products +``` + +### Получить товар по ID +```bash +curl http://localhost:3000/products/1 +``` + +### Создать товар +```bash +curl -X POST http://localhost:3000/products \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Новый товар", + "slug": "new-product", + "sku": "SKU-001", + "price": 1000, + "stock_quantity": 10 + }' +``` + +### Получить категории +```bash +curl http://localhost:3000/categories +``` + +## Работа с DAO + +### Пример использования ProductDAO + +```javascript +import { ProductDAO } from './dao/ProductDAO.js'; + +const productDAO = new ProductDAO(); + +// Получить товар по ID +const product = await productDAO.findById(1); + +// Получить товары с деталями +const products = await productDAO.findAllWithDetails({ + limit: 20, + offset: 0, + categoryId: 1, + isFeatured: true +}); + +// Поиск товаров +const searchResults = await productDAO.search('телефон', { + limit: 10, + offset: 0 +}); + +// Создать товар +const newProduct = await productDAO.create({ + name: 'Новый товар', + slug: 'new-product', + sku: 'SKU-001', + price: 1000, + stock_quantity: 10 +}); + +// Обновить товар +const updated = await productDAO.update(1, { + price: 1200 +}); + +// Удалить товар +await productDAO.delete(1); +``` + +### Пример использования CategoryDAO + +```javascript +import { CategoryDAO } from './dao/CategoryDAO.js'; + +const categoryDAO = new CategoryDAO(); + +// Получить все категории с иерархией +const categories = await categoryDAO.findAllWithHierarchy(); + +// Получить дерево категорий +const tree = await categoryDAO.findTree(); + +// Получить дочерние категории +const children = await categoryDAO.findChildren(1); +``` + +## Добавление новых роутов + +### 1. Создайте DAO класс + +```javascript +// src/dao/ReviewDAO.js +import { BaseDAO } from './BaseDAO.js'; + +export class ReviewDAO extends BaseDAO { + constructor() { + super('reviews'); + } + + async findByProductId(productId) { + const query = ` + SELECT r.*, u.first_name, u.last_name + FROM reviews r + JOIN users u ON r.user_id = u.id + WHERE r.product_id = $1 AND r.is_approved = TRUE + ORDER BY r.created_at DESC + `; + const result = await this.query(query, [productId]); + return result.rows; + } +} +``` + +### 2. Создайте схемы валидации + +```javascript +// src/schemas/review.schema.js +export const reviewSchemas = { + createReview: { + body: { + type: 'object', + required: ['product_id', 'rating'], + properties: { + product_id: { type: 'integer' }, + rating: { type: 'integer', minimum: 1, maximum: 5 }, + title: { type: 'string' }, + comment: { type: 'string' } + } + } + } +}; +``` + +### 3. Создайте роуты + +```javascript +// src/routes/reviews.routes.js +import { ReviewDAO } from '../dao/ReviewDAO.js'; +import { reviewSchemas } from '../schemas/review.schema.js'; + +export async function reviewRoutes(fastify, options) { + const reviewDAO = new ReviewDAO(); + + fastify.get('/products/:productId/reviews', { + schema: { + tags: ['reviews'], + summary: 'Получить отзывы на товар' + } + }, async (request, reply) => { + const { productId } = request.params; + const reviews = await reviewDAO.findByProductId(productId); + return { success: true, data: reviews }; + }); +} +``` + +### 4. Зарегистрируйте роуты + +```javascript +// src/server.js +import { reviewRoutes } from './routes/reviews.routes.js'; + +async function registerRoutes() { + await fastify.register(productRoutes); + await fastify.register(categoryRoutes); + await fastify.register(reviewRoutes); // Добавьте это +} +``` + +## Swagger документация + +После запуска сервера откройте в браузере: +- http://localhost:3000/docs + +Здесь вы найдете интерактивную документацию API с возможностью тестирования запросов. + +## Обработка ошибок + +Все ошибки автоматически обрабатываются и возвращаются в формате: +```json +{ + "success": false, + "error": "Описание ошибки" +} +``` + +## Безопасность + +- Все запросы валидируются через схемы +- Rate limiting ограничивает количество запросов +- Helmet защищает от распространенных уязвимостей +- CORS настроен для безопасных кросс-доменных запросов + diff --git a/app.js b/app.js new file mode 100644 index 0000000..ec8ad8c --- /dev/null +++ b/app.js @@ -0,0 +1,237 @@ +import { YmlParser } from "./src/utils/yml-parser.js"; +import daoPlugin from './src/plugins/dao.js'; +import pino from "pino"; +import pretty from 'pino-pretty'; +import autoload from '@fastify/autoload'; +import fastifySwagger from "@fastify/swagger"; +import fastifySwaggerUi from "@fastify/swagger-ui"; +import fastifyCors from '@fastify/cors'; +import fastifyMultipart from '@fastify/multipart'; +import Fastify from 'fastify'; +import path from 'path'; +import fs from "node:fs"; + +import { fileURLToPath } from 'url'; +import CertManager from "./src/utils/certManager.js"; +import fastifyJwt from '@fastify/jwt'; +import crypto from 'crypto'; +import jwtPlugin from "./src/plugins/authenticate.js"; +import authorizePlugin from "./src/plugins/authorize.js"; +import fastifyHealthcheck from "fastify-healthcheck"; +import minioPlugin from "./src/plugins/minio.js"; + +(async () => { + let streams; + if (process.env.FILE_LOG_LEVEL !== undefined) { + + const logFileStream = fs.createWriteStream(`./logs/event-log.log`, { flags: 'a' }); + logFileStream.on('error', (err) => { + console.error('Ошибка при создании потока для файла логов:', err); + }); + streams = [ + { level: process.env.FILE_LOG_LEVEL || 'debug', stream: logFileStream }, + { level: process.env.STDOUT_LOG_LEVEL || 'trace', stream: pretty({ colorize: true }) } + ]; + } + else streams = [ + { level: process.env.STDOUT_LOG_LEVEL || 'trace', stream: pretty({ colorize: true }) } + ]; + + const logger = pino( + { level: process.env.LOG_LEVEL || 'info' }, + pino.multistream(streams)); + + let yml = new YmlParser; + const settings = yml.loadConfig("./conf/config.yml"); + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + logger.info("Settings:", settings) + + + + const JWT_SECRET = crypto.randomBytes(32).toString('hex'); + + let option = { + pluginTimeout: 10000, + logger: { + level: process.env.LOG_LEVEL || 'info', + stream: pino.multistream(streams), + }, + trustProxy: true // Включаем доверие к прокси для правильной обработки X-Forwarded-* заголовков от nginx + }; + + if (settings.web.tsl) { + let sert = new CertManager(settings.tsl) + option.https = await sert.getOptionTSL(); + } + + + const app = Fastify(option); + + // Настройка CORS для фронтенда + await app.register(fastifyCors, { + origin: true, // Разрешить все источники в разработке + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'] + }); + + // Регистрируем multipart для загрузки файлов + await app.register(fastifyMultipart, { + limits: { + fileSize: 10 * 1024 * 1024, // 10MB + files: 10 + }, + attachFieldsToBody: false // Не прикрепляем поля к body, обрабатываем через parts() + }); + + // Настройка Swagger + const swaggerOptions = { + openapi: { + openapi: '3.0.0', + info: { + title: 'Shop Platform API', + description: 'RESTful API для интернет-магазина с полным функционалом управления товарами, заказами, пользователями и корзиной', + version: '1.0.0', + contact: { + name: 'Shop Platform Support' + } + }, + servers: [ + { + url: settings.web.tsl + ? `https://${settings.tsl.host}:${settings.web.https_port}` + : `http://${settings.web.host}:${settings.web.http_port}`, + description: 'Shop Platform Server' + } + ], + tags: [ + { name: 'products', description: 'Операции с товарами' }, + { name: 'product-images', description: 'Управление изображениями товаров' }, + { name: 'categories', description: 'Операции с категориями товаров' }, + { name: 'users', description: 'Управление пользователями' }, + { name: 'roles', description: 'Управление ролями и правами доступа' }, + { name: 'permissions', description: 'Управление правами доступа' }, + { name: 'resources', description: 'Управление ресурсами системы' }, + { name: 'actions', description: 'Управление действиями над ресурсами' }, + { name: 'orders', description: 'Управление заказами' }, + { name: 'cart', description: 'Операции с корзиной покупок' }, + { name: 'reviews', description: 'Управление отзывами на товары' }, + { name: 'addresses', description: 'Управление адресами доставки' }, + { name: 'auth', description: 'Авторизация и регистрация пользователей' }, + { name: 'settings', description: 'Настройки системы' }, + { name: 'example', description: 'Примеры endpoints' } + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'JWT токен авторизации' + } + }, + schemas: { + Error: { + type: 'object', + properties: { + success: { type: 'boolean', example: false }, + message: { type: 'string' } + } + }, + Success: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + data: { type: 'object' } + } + }, + Pagination: { + type: 'object', + properties: { + page: { type: 'integer', example: 1 }, + limit: { type: 'integer', example: 20 }, + total: { type: 'integer', example: 150 }, + totalPages: { type: 'integer', example: 8 }, + hasNext: { type: 'boolean', example: true }, + hasPrev: { type: 'boolean', example: false } + } + } + } + } + }, + exposeRoute: true + }; + + const swaggerUiOptions = { + routePrefix: '/swagger', + uiConfig: { + docExpansion: 'list', + deepLinking: true, + filter: true, + showExtensions: true, + showCommonExtensions: true, + tryItOutEnabled: true + }, + uiHooks: { + onRequest: function (request, reply, next) { next() }, + preHandler: function (request, reply, next) { next() } + }, + staticCSP: true, + transformStaticCSP: (header) => header, + transformSpecification: (swaggerObject, request, reply) => { + return swaggerObject; + }, + transformSpecificationClone: true + }; + + // Регистрируем Swagger (должен быть зарегистрирован до Swagger UI) + await app.register(fastifySwagger, swaggerOptions); + await app.register(fastifySwaggerUi, swaggerUiOptions); + + app.register(fastifyJwt, { + secret: JWT_SECRET, + sign: { expiresIn: '1h' }, + // Настройка извлечения токена из заголовков + decode: { + complete: false + }, + // Формат токена: Bearer + formatUser: (payload) => { + return payload; + } + }); + app.register(jwtPlugin, settings); + app.register(authorizePlugin); // Плагин для проверки прав доступа + + // Регистрируем DAO плагин ПЕРЕД роутами (роуты используют fastify.dao) + await app.register(daoPlugin, settings.database); + + // Регистрируем MinIO плагин + if (settings.minio) { + await app.register(minioPlugin, settings.minio); + } + + app.register(fastifyHealthcheck); + + // Автозагрузка роутов из src/routes/v1 (после DAO, чтобы fastify.dao был доступен) + await app.register(autoload, { + dir: path.join(__dirname, 'src/routes/v1'), + options: { prefix: '/api/v1/' }, + ignorePattern: /^.*\.test\.js$/, + maxDepth: 1 + }); + app.listen({ port: settings.web.tsl ? settings.web.https_port : settings.web.http_port,host:settings.web.host }, (err, address) => { + if (err) { + console.error(err); + process.exit(1); + } + logger.info(`Server listening at ${address}`); + }) + + +}) + + (); + + diff --git a/client/.eslintrc.cjs b/client/.eslintrc.cjs new file mode 100644 index 0000000..c0f1d02 --- /dev/null +++ b/client/.eslintrc.cjs @@ -0,0 +1,21 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + settings: { react: { version: '18.2' } }, + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + 'react/prop-types': 'off', + }, +} diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..61006d6 --- /dev/null +++ b/client/README.md @@ -0,0 +1,82 @@ +# Shop Platform - Frontend + +React фронтенд для интернет-магазина Shop Platform. + +## Технологии + +- **React 18** - UI библиотека +- **Vite** - Сборщик и dev-сервер +- **React Router** - Роутинг +- **Axios** - HTTP клиент + +## Установка + +```bash +cd client +npm install +``` + +## Запуск + +### Режим разработки + +```bash +npm run dev +``` + +Приложение будет доступно по адресу `http://localhost:3000` + +### Сборка для продакшена + +```bash +npm run build +``` + +Собранные файлы будут в папке `dist/` + +### Просмотр продакшен сборки + +```bash +npm run preview +``` + +## Структура проекта + +``` +client/ +├── src/ +│ ├── api/ # API клиенты +│ ├── components/ # React компоненты +│ ├── pages/ # Страницы приложения +│ ├── App.jsx # Главный компонент +│ └── main.jsx # Точка входа +├── index.html +├── vite.config.js +└── package.json +``` + +## Настройка API + +По умолчанию API запросы идут на `http://localhost:80/api/v1`. + +Для изменения базового URL создайте файл `.env`: + +```env +VITE_API_URL=http://localhost:80/api/v1 +``` + +## Функционал + +- ✅ Каталог товаров с фильтрацией +- ✅ Детальная страница товара +- ✅ Корзина покупок (localStorage) +- ✅ Категории товаров +- ✅ Поиск товаров +- ✅ Адаптивный дизайн + +## Интеграция с бэкендом + +Фронтенд использует REST API бэкенда: +- `/api/v1/products` - Товары +- `/api/v1/categories` - Категории +- `/api/v1/cart` - Корзина diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..37e7793 --- /dev/null +++ b/client/index.html @@ -0,0 +1,14 @@ + + + + + + + Интернет-магазин + + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..a2791e0 --- /dev/null +++ b/client/package.json @@ -0,0 +1,27 @@ +{ + "name": "shop-platform-client", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "axios": "^1.6.2" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^8.55.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "vite": "^5.0.8" + } +} diff --git a/client/public/vite.svg b/client/public/vite.svg new file mode 100644 index 0000000..98430a2 --- /dev/null +++ b/client/public/vite.svg @@ -0,0 +1 @@ + diff --git a/client/src/App.css b/client/src/App.css new file mode 100644 index 0000000..8f514a0 --- /dev/null +++ b/client/src/App.css @@ -0,0 +1,188 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + /* Telegram Dark Theme Colors */ + --primary-color: #3390ec; + --primary-hover: #5288c1; + --secondary-color: #5d6d7e; + --success-color: #4caf50; + --danger-color: #f44336; + --warning-color: #ff9800; + --bg-color: #1a1a1a; + --bg-secondary: #0e1621; + --bg-tertiary: #212121; + --bg-card: #1e1e1e; + --text-primary: #ffffff; + --text-secondary: #e1e1e1; + --text-muted: #999999; + --border-color: #2f2f2f; + --border-light: #333333; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.4); + --radius: 8px; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + color: var(--text-primary); + background-color: var(--bg-secondary); + line-height: 1.6; + min-height: 100vh; +} + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.main-content { + flex: 1; + padding: 2rem 0; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1.5rem; + font-size: 1rem; + font-weight: 500; + border: none; + border-radius: var(--radius); + cursor: pointer; + transition: all 0.2s; + text-decoration: none; +} + +.btn-primary { + background-color: var(--primary-color); + color: white; +} + +.btn-primary:hover { + background-color: var(--primary-hover); +} + +.btn-secondary { + background-color: var(--secondary-color); + color: white; +} + +.btn-secondary:hover { + background-color: #475569; +} + +.btn-outline { + background-color: transparent; + border: 2px solid var(--primary-color); + color: var(--primary-color); +} + +.btn-outline:hover { + background-color: var(--primary-color); + color: white; +} + +.grid { + display: grid; + gap: 1.5rem; +} + +.grid-2 { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); +} + +.grid-3 { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); +} + +.grid-4 { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); +} + +.card { + background: var(--bg-card); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); + border: 1px solid var(--border-color); + overflow: hidden; + transition: transform 0.2s, box-shadow 0.2s; + color: var(--text-primary); +} + +.card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-md); + border-color: var(--border-light); +} + +.loading { + text-align: center; + padding: 3rem; + color: var(--text-secondary); +} + +.error { + text-align: center; + padding: 3rem; + color: var(--danger-color); +} + +@media (max-width: 768px) { + .main-content { + padding: 1rem 0; + } + + .container { + padding: 0 0.75rem; + } + + .grid-2, + .grid-3, + .grid-4 { + grid-template-columns: 1fr; + gap: 1rem; + } + + .btn { + padding: 0.625rem 1.25rem; + font-size: 0.9rem; + } +} + +@media (max-width: 480px) { + .main-content { + padding: 0.5rem 0; + } + + .container { + padding: 0 0.5rem; + } + + .grid-2, + .grid-3, + .grid-4 { + gap: 0.75rem; + } + + .btn { + padding: 0.5rem 1rem; + font-size: 0.875rem; + } +} diff --git a/client/src/App.jsx b/client/src/App.jsx new file mode 100644 index 0000000..653f217 --- /dev/null +++ b/client/src/App.jsx @@ -0,0 +1,113 @@ +import { Routes, Route, Navigate } from 'react-router-dom' +import Header from './components/Header' +import FloatingCartPanel from './components/FloatingCartPanel' +import FloatingOrdersPanel from './components/FloatingOrdersPanel' +import FloatingSettingsPanel from './components/FloatingSettingsPanel' +import Home from './pages/Home' +import Products from './pages/Products' +import ProductDetail from './pages/ProductDetail' +import Cart from './pages/Cart' +import Categories from './pages/Categories' +import Orders from './pages/Orders' +import Profile from './pages/Profile' +import Login from './pages/Login' +import AdminLayout from './components/admin/AdminLayout' +import Dashboard from './pages/admin/Dashboard' +import AdminProducts from './pages/admin/Products' +import AdminCategories from './pages/admin/Categories' +import AdminOrders from './pages/admin/Orders' +import AdminUsers from './pages/admin/Users' +import AdminRoles from './pages/admin/Roles' +import AdminReviews from './pages/admin/Reviews' +import Settings from './pages/admin/Settings' +import ProtectedRoute from './components/ProtectedRoute' +import AdminRoute from './components/AdminRoute' +import './App.css' + +function App() { + return ( +
+ + + + + } /> + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + +
+
+ +
+ + } /> + +
+
+ +
+ + } /> + +
+
+ +
+ + } /> + +
+
+ +
+ + } /> + +
+
+ +
+ + } /> + + <> +
+
+ +
+ + + } /> + + <> +
+
+ +
+ + + } /> + +
+ ) +} + +export default App diff --git a/client/src/api/addresses.js b/client/src/api/addresses.js new file mode 100644 index 0000000..5f0ecb8 --- /dev/null +++ b/client/src/api/addresses.js @@ -0,0 +1,85 @@ +import apiClient from './client' +import { getUser, isAuthenticated } from '../utils/auth' + +export const addressesApi = { + // Получить адреса текущего пользователя + getMyAddresses: async () => { + if (!isAuthenticated()) { + return { success: true, data: [] } + } + + try { + // Используем новый endpoint без userId - бэкенд сам определит из токена + const response = await apiClient.get('/addresses') + return response.data + } catch (error) { + // Если новый endpoint не работает, пробуем старый + if (error.response?.status === 404 || error.response?.status === 400) { + const user = getUser() + const userId = user?.id || localStorage.getItem('userId') + + if (userId) { + try { + const response = await apiClient.get(`/addresses/user/${userId}`) + return response.data + } catch (fallbackError) { + if (fallbackError.response?.status === 401) { + return { success: true, data: [] } + } + throw fallbackError + } + } + } + + if (error.response?.status === 401) { + return { success: true, data: [] } + } + throw error + } + }, + + // Создать адрес + create: async (addressData) => { + if (!isAuthenticated()) { + const error = new Error('Пользователь не авторизован') + error.code = 'UNAUTHORIZED' + throw error + } + + const user = getUser() + const userId = user?.id || localStorage.getItem('userId') + + // Бэкенд сам определит user_id из токена, если не передан + const requestData = { + ...addressData, + user_id: addressData.user_id || (userId ? parseInt(userId) : undefined) + } + + const response = await apiClient.post('/addresses', requestData) + return response.data + }, + + // Обновить адрес + update: async (id, addressData) => { + if (!isAuthenticated()) { + const error = new Error('Пользователь не авторизован') + error.code = 'UNAUTHORIZED' + throw error + } + + const response = await apiClient.put(`/addresses/${id}`, addressData) + return response.data + }, + + // Удалить адрес + delete: async (id) => { + if (!isAuthenticated()) { + const error = new Error('Пользователь не авторизован') + error.code = 'UNAUTHORIZED' + throw error + } + + const response = await apiClient.delete(`/addresses/${id}`) + return response.data + } +} diff --git a/client/src/api/admin/categories.js b/client/src/api/admin/categories.js new file mode 100644 index 0000000..8fd5c0d --- /dev/null +++ b/client/src/api/admin/categories.js @@ -0,0 +1,48 @@ +import apiClient from '../client' + +export const adminCategoriesApi = { + // Получить все категории + getAll: async (params = {}) => { + const { page, limit } = params + const queryParams = new URLSearchParams() + if (page) queryParams.append('page', page) + if (limit) queryParams.append('limit', limit) + + const url = queryParams.toString() + ? `/categories?${queryParams.toString()}` + : '/categories' + + const response = await apiClient.get(url) + return response.data + }, + + // Получить категорию по ID + getById: async (id) => { + const response = await apiClient.get(`/categories/${id}`) + return response.data + }, + + // Создать категорию + create: async (categoryData) => { + const response = await apiClient.post('/categories', categoryData) + return response.data + }, + + // Обновить категорию + update: async (id, categoryData) => { + const response = await apiClient.put(`/categories/${id}`, categoryData) + return response.data + }, + + // Удалить категорию + delete: async (id) => { + const response = await apiClient.delete(`/categories/${id}`) + return response.data + }, + + // Получить корневые категории + getRoot: async () => { + const response = await apiClient.get('/categories/root') + return response.data + }, +} diff --git a/client/src/api/admin/orders.js b/client/src/api/admin/orders.js new file mode 100644 index 0000000..f048116 --- /dev/null +++ b/client/src/api/admin/orders.js @@ -0,0 +1,105 @@ +import apiClient from '../client' + +export const adminOrdersApi = { + // Получить все заказы с фильтрацией + getAll: async (params = {}) => { + const { page = 1, limit = 20, status_id, user_id, orderBy, orderDirection } = params + const queryParams = new URLSearchParams() + if (page) queryParams.append('page', page) + if (limit) queryParams.append('limit', limit) + if (status_id) queryParams.append('status_id', status_id) + if (user_id) queryParams.append('user_id', user_id) + if (orderBy) queryParams.append('orderBy', orderBy) + if (orderDirection) queryParams.append('orderDirection', orderDirection) + + const response = await apiClient.get(`/orders?${queryParams.toString()}`) + return response.data + }, + + // Получить заказ по ID + getById: async (id) => { + const response = await apiClient.get(`/orders/${id}`) + return response.data + }, + + // Обновить заказ + update: async (id, orderData) => { + const response = await apiClient.put(`/orders/${id}`, orderData) + return response.data + }, + + // Удалить заказ + delete: async (id) => { + const response = await apiClient.delete(`/orders/${id}`) + return response.data + }, + + // Получить статусы заказов + getStatuses: async () => { + const response = await apiClient.get('/orders/statuses') + return response.data + }, + + // Получить статусы платежей + getPaymentStatuses: async () => { + const response = await apiClient.get('/orders/payment-statuses') + return response.data + }, + + // Обновить статус заказа + updateStatus: async (id, statusId) => { + const response = await apiClient.put(`/orders/${id}`, { status_id: statusId }) + return response.data + }, + + // Обновить статус платежа + updatePaymentStatus: async (id, paymentStatusId) => { + const response = await apiClient.put(`/orders/${id}`, { payment_status_id: paymentStatusId }) + return response.data + }, + + // Получить количество необработанных заказов + getPendingCount: async () => { + try { + // Получаем статусы "pending" и "processing" + const statusesResponse = await apiClient.get('/orders/statuses') + if (statusesResponse.data.success) { + const statuses = statusesResponse.data.data || [] + const pendingStatus = statuses.find(s => s.slug === 'pending') + const processingStatus = statuses.find(s => s.slug === 'processing') + + let totalCount = 0 + + // Считаем заказы со статусом "pending" + if (pendingStatus) { + try { + const response = await apiClient.get(`/orders?status_id=${pendingStatus.id}&limit=1`) + if (response.data.success && response.data.pagination) { + totalCount += response.data.pagination.total || 0 + } + } catch (error) { + // Игнорируем ошибки для отдельных статусов + } + } + + // Считаем заказы со статусом "processing" + if (processingStatus) { + try { + const response = await apiClient.get(`/orders?status_id=${processingStatus.id}&limit=1`) + if (response.data.success && response.data.pagination) { + totalCount += response.data.pagination.total || 0 + } + } catch (error) { + // Игнорируем ошибки для отдельных статусов + } + } + + return totalCount + } + return 0 + } catch (error) { + console.error('Ошибка получения количества необработанных заказов:', error) + return 0 + } + } +} diff --git a/client/src/api/admin/permissions.js b/client/src/api/admin/permissions.js new file mode 100644 index 0000000..cf68aeb --- /dev/null +++ b/client/src/api/admin/permissions.js @@ -0,0 +1,28 @@ +import apiClient from '../client' + +export const adminPermissionsApi = { + // Получить все права + getAll: async (params = {}) => { + const queryParams = new URLSearchParams() + if (params.page) queryParams.append('page', params.page) + if (params.limit) queryParams.append('limit', params.limit) + if (params.resource_id) queryParams.append('resource_id', params.resource_id) + if (params.is_active !== undefined) queryParams.append('is_active', params.is_active) + + const queryString = queryParams.toString() + const response = await apiClient.get(`/permissions${queryString ? `?${queryString}` : ''}`) + return response.data + }, + + // Получить право по ID + getById: async (id) => { + const response = await apiClient.get(`/permissions/${id}`) + return response.data + }, + + // Получить активные права + getActive: async () => { + const response = await apiClient.get('/permissions?is_active=true') + return response.data + } +} diff --git a/client/src/api/admin/product-images.js b/client/src/api/admin/product-images.js new file mode 100644 index 0000000..6b11250 --- /dev/null +++ b/client/src/api/admin/product-images.js @@ -0,0 +1,69 @@ +import apiClient from '../client' + +export const adminProductImagesApi = { + // Получить все изображения товара + getByProductId: async (productId) => { + const response = await apiClient.get(`/product-images/product/${productId}`) + return response.data + }, + + // Получить основное изображение товара + getPrimary: async (productId) => { + const response = await apiClient.get(`/product-images/product/${productId}/primary`) + return response.data + }, + + // Загрузить изображение товара (файл) + upload: async (file, productId, options = {}) => { + const formData = new FormData() + formData.append('file', file) + // Убеждаемся, что product_id передается как строка (число будет преобразовано на бэкенде) + formData.append('product_id', String(productId)) + + if (options.alt_text) { + formData.append('alt_text', options.alt_text) + } + + if (options.is_primary !== undefined) { + formData.append('is_primary', options.is_primary ? 'true' : 'false') + } + + if (options.sort_order !== undefined) { + formData.append('sort_order', options.sort_order.toString()) + } + + // axios автоматически установит Content-Type с boundary для FormData + const response = await apiClient.post('/product-images/upload', formData) + return response.data + }, + + // Создать изображение товара (только URL) + create: async (imageData) => { + const response = await apiClient.post('/product-images', imageData) + return response.data + }, + + // Обновить изображение + update: async (id, imageData) => { + const response = await apiClient.put(`/product-images/${id}`, imageData) + return response.data + }, + + // Установить изображение как основное + setPrimary: async (id, productId) => { + const response = await apiClient.post(`/product-images/${id}/set-primary`, { product_id: productId }) + return response.data + }, + + // Удалить изображение + delete: async (id) => { + const response = await apiClient.delete(`/product-images/${id}`) + return response.data + }, + + // Удалить все изображения товара + deleteByProductId: async (productId) => { + const response = await apiClient.delete(`/product-images/product/${productId}`) + return response.data + } +} diff --git a/client/src/api/admin/products.js b/client/src/api/admin/products.js new file mode 100644 index 0000000..143f43e --- /dev/null +++ b/client/src/api/admin/products.js @@ -0,0 +1,88 @@ +import apiClient from '../client' + +export const adminProductsApi = { + // Получить все товары с пагинацией + getAll: async (params = {}) => { + const { page = 1, limit = 20, category_id, is_active, orderBy, orderDirection } = params + const queryParams = new URLSearchParams() + if (page) queryParams.append('page', page) + if (limit) queryParams.append('limit', limit) + if (category_id) queryParams.append('category_id', category_id) + if (is_active !== undefined) queryParams.append('is_active', is_active) + if (orderBy) queryParams.append('orderBy', orderBy) + if (orderDirection) queryParams.append('orderDirection', orderDirection) + + const url = `/products?${queryParams.toString()}` + console.log('📡 [adminProductsApi.getAll] Запрос:', url) + console.log('📡 [adminProductsApi.getAll] Параметры:', params) + + try { + const response = await apiClient.get(url) + console.log('📥 [adminProductsApi.getAll] Сырой ответ:', { + status: response.status, + statusText: response.statusText, + headers: response.headers, + data: response.data + }) + console.log('📦 [adminProductsApi.getAll] Структура ответа:', { + hasSuccess: 'success' in (response.data || {}), + success: response.data?.success, + hasData: 'data' in (response.data || {}), + dataType: Array.isArray(response.data?.data) ? 'array' : typeof response.data?.data, + dataLength: response.data?.data?.length, + hasPagination: 'pagination' in (response.data || {}), + pagination: response.data?.pagination, + fullData: JSON.stringify(response.data, null, 2) + }) + + if (response.data?.data && Array.isArray(response.data.data) && response.data.data.length > 0) { + console.log('🔍 [adminProductsApi.getAll] Первый товар из ответа:', JSON.stringify(response.data.data[0], null, 2)) + console.log('🔍 [adminProductsApi.getAll] Все поля первого товара:', Object.keys(response.data.data[0])) + console.log('🔍 [adminProductsApi.getAll] Значения полей первого товара:', { + id: response.data.data[0].id, + name: response.data.data[0].name, + sku: response.data.data[0].sku, + price: response.data.data[0].price, + priceType: typeof response.data.data[0].price, + stock_quantity: response.data.data[0].stock_quantity, + is_active: response.data.data[0].is_active, + category_id: response.data.data[0].category_id + }) + } + + return response.data + } catch (error) { + console.error('❌ [adminProductsApi.getAll] Ошибка:', { + message: error.message, + response: error.response?.data, + status: error.response?.status, + config: error.config + }) + throw error + } + }, + + // Получить товар по ID + getById: async (id) => { + const response = await apiClient.get(`/products/${id}`) + return response.data + }, + + // Создать товар + create: async (productData) => { + const response = await apiClient.post('/products', productData) + return response.data + }, + + // Обновить товар + update: async (id, productData) => { + const response = await apiClient.put(`/products/${id}`, productData) + return response.data + }, + + // Удалить товар + delete: async (id) => { + const response = await apiClient.delete(`/products/${id}`) + return response.data + }, +} diff --git a/client/src/api/admin/reviews.js b/client/src/api/admin/reviews.js new file mode 100644 index 0000000..8cc15bf --- /dev/null +++ b/client/src/api/admin/reviews.js @@ -0,0 +1,40 @@ +import apiClient from '../client' + +export const adminReviewsApi = { + // Получить отзывы на модерации + getPending: async (options = {}) => { + const params = new URLSearchParams() + if (options.page) params.append('page', options.page) + if (options.limit) params.append('limit', options.limit) + + const queryString = params.toString() + const url = `/reviews/pending${queryString ? '?' + queryString : ''}` + const response = await apiClient.get(url) + return response.data + }, + + // Одобрить отзыв + approve: async (reviewId) => { + const response = await apiClient.post(`/reviews/${reviewId}/approve`) + return response.data + }, + + // Удалить отзыв (отклонить) + reject: async (reviewId) => { + const response = await apiClient.delete(`/reviews/${reviewId}`) + return response.data + }, + + // Получить все отзывы + getAll: async (options = {}) => { + const params = new URLSearchParams() + if (options.page) params.append('page', options.page) + if (options.limit) params.append('limit', options.limit) + if (options.approved !== undefined) params.append('approved', options.approved) + + const queryString = params.toString() + const url = `/reviews${queryString ? '?' + queryString : ''}` + const response = await apiClient.get(url) + return response.data + } +} diff --git a/client/src/api/admin/roles.js b/client/src/api/admin/roles.js new file mode 100644 index 0000000..50595bd --- /dev/null +++ b/client/src/api/admin/roles.js @@ -0,0 +1,51 @@ +import apiClient from '../client' + +export const adminRolesApi = { + // Получить все роли + getAll: async () => { + const response = await apiClient.get('/roles') + return response.data + }, + + // Получить роль по ID + getById: async (id) => { + const response = await apiClient.get(`/roles/${id}`) + return response.data + }, + + // Создать роль + create: async (roleData) => { + const response = await apiClient.post('/roles', roleData) + return response.data + }, + + // Обновить роль + update: async (id, roleData) => { + const response = await apiClient.put(`/roles/${id}`, roleData) + return response.data + }, + + // Удалить роль + delete: async (id) => { + const response = await apiClient.delete(`/roles/${id}`) + return response.data + }, + + // Получить права роли + getPermissions: async (id) => { + const response = await apiClient.get(`/roles/${id}/permissions`) + return response.data + }, + + // Добавить право к роли + addPermission: async (id, permissionId) => { + const response = await apiClient.post(`/roles/${id}/permissions`, { permission_id: permissionId }) + return response.data + }, + + // Удалить право из роли + removePermission: async (id, permissionId) => { + const response = await apiClient.delete(`/roles/${id}/permissions/${permissionId}`) + return response.data + }, +} diff --git a/client/src/api/admin/users.js b/client/src/api/admin/users.js new file mode 100644 index 0000000..75079a8 --- /dev/null +++ b/client/src/api/admin/users.js @@ -0,0 +1,40 @@ +import apiClient from '../client' + +export const adminUsersApi = { + // Получить всех пользователей с пагинацией + getAll: async (params = {}) => { + const { page = 1, limit = 20, role_id, search } = params + const queryParams = new URLSearchParams() + if (page) queryParams.append('page', page) + if (limit) queryParams.append('limit', limit) + if (role_id) queryParams.append('role_id', role_id) + if (search) queryParams.append('search', search) + + const response = await apiClient.get(`/users?${queryParams.toString()}`) + return response.data + }, + + // Получить пользователя по ID + getById: async (id) => { + const response = await apiClient.get(`/users/${id}`) + return response.data + }, + + // Создать пользователя + create: async (userData) => { + const response = await apiClient.post('/users', userData) + return response.data + }, + + // Обновить пользователя + update: async (id, userData) => { + const response = await apiClient.put(`/users/${id}`, userData) + return response.data + }, + + // Удалить пользователя + delete: async (id) => { + const response = await apiClient.delete(`/users/${id}`) + return response.data + }, +} diff --git a/client/src/api/auth.js b/client/src/api/auth.js new file mode 100644 index 0000000..1ef6009 --- /dev/null +++ b/client/src/api/auth.js @@ -0,0 +1,90 @@ +import apiClient from './client' +import { clearAuth } from '../utils/auth' + +export const authApi = { + // Регистрация + register: async (userData) => { + try { + console.log('📡 Отправка запроса на /auth/register') + const response = await apiClient.post('/auth/register', userData) + console.log('📥 Ответ получен:', response.status, response.data) + return response.data + } catch (error) { + console.error('❌ Ошибка в register API:', error) + throw error + } + }, + + // Логин по email и паролю + login: async (email, password) => { + try { + console.log('📡 Отправка запроса на /auth/login') + const response = await apiClient.post('/auth/login', { email, password }) + console.log('📥 Ответ получен:', response.status, response.data) + return response.data + } catch (error) { + console.error('❌ Ошибка в login API:', error) + throw error + } + }, + + // Регистрация через Telegram + registerTelegram: async (telegramData) => { + const response = await apiClient.post('/auth/register/telegram', telegramData) + return response.data + }, + + // Логин через Telegram + loginTelegram: async (telegram_id) => { + const response = await apiClient.post('/auth/login/telegram', { telegram_id }) + return response.data + }, + + // Получить текущего пользователя + getMe: async () => { + const response = await apiClient.get('/auth/me') + // response.data содержит { success: true, data: {...} } + // Возвращаем весь объект ответа для совместимости + return response.data + }, + + // Обновить профиль + updateProfile: async (userData) => { + const response = await apiClient.put('/auth/me', userData) + return response.data + }, + + // Сменить пароль + changePassword: async (oldPassword, newPassword) => { + const response = await apiClient.post('/auth/change-password', { + old_password: oldPassword, + new_password: newPassword + }) + return response.data + }, + + // Загрузить аватар + uploadAvatar: async (file) => { + const formData = new FormData() + formData.append('avatar', file) + const response = await apiClient.post('/auth/avatar', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) + return response.data + }, + + // Обновить токен + refreshToken: async () => { + const response = await apiClient.post('/auth/refresh') + return response.data + }, + + // Выход (локальный) + logout: () => { + clearAuth() + console.log('✅ Авторизация очищена') + window.location.href = '/' + } +} diff --git a/client/src/api/cart.js b/client/src/api/cart.js new file mode 100644 index 0000000..2b2a98b --- /dev/null +++ b/client/src/api/cart.js @@ -0,0 +1,128 @@ +import apiClient from './client' +import { getUser, isAuthenticated } from '../utils/auth' + +// Получает userId из localStorage или из переданного параметра +const getUserId = (userId) => { + if (userId) return userId + const user = getUser() + return user?.id || localStorage.getItem('userId') +} + +export const cartApi = { + // Получить корзину пользователя + getCart: async (userId = null) => { + // Проверяем авторизацию + if (!isAuthenticated()) { + return { success: true, data: { items: [], total: 0, itemCount: 0 } } + } + + try { + // Используем новый endpoint без userId - бэкенд сам определит user_id из токена + const response = await apiClient.get('/cart') + return response.data + } catch (error) { + // Если ошибка 401 или 403, возвращаем пустую корзину + if (error.response?.status === 401 || error.response?.status === 403) { + console.warn('Нет доступа к корзине:', error.response?.data?.message || error.message) + return { success: true, data: { items: [], total: 0, itemCount: 0 } } + } + throw error + } + }, + + // Добавить товар в корзину + addItem: async (productId, quantity = 1, userId = null) => { + // Проверяем авторизацию + if (!isAuthenticated()) { + const error = new Error('Пользователь не авторизован') + error.code = 'UNAUTHORIZED' + throw error + } + + // Пытаемся получить user_id из разных источников (для обратной совместимости) + let id = userId + if (!id) { + const user = getUser() + id = user?.id + } + if (!id) { + id = localStorage.getItem('userId') + } + + // Отправляем запрос - бэкенд сам определит user_id из токена, если не передан + const requestBody = { + product_id: productId, + quantity: quantity + } + + // Добавляем user_id только если он есть (опционально) + if (id) { + requestBody.user_id = parseInt(id) + } + + const response = await apiClient.post('/cart', requestBody) + return response.data + }, + + // Обновить количество товара в корзине + updateQuantity: async (productId, quantity, userId = null) => { + // Проверяем авторизацию + if (!isAuthenticated()) { + const error = new Error('Пользователь не авторизован') + error.code = 'UNAUTHORIZED' + throw error + } + + // Пытаемся получить user_id из разных источников (для обратной совместимости) + let id = userId + if (!id) { + const user = getUser() + id = user?.id + } + if (!id) { + id = localStorage.getItem('userId') + } + + // Отправляем запрос - бэкенд сам определит user_id из токена, если не передан + const requestBody = { + product_id: productId, + quantity: quantity + } + + // Добавляем user_id только если он есть (опционально) + if (id) { + requestBody.user_id = parseInt(id) + } + + const response = await apiClient.put('/cart', requestBody) + return response.data + }, + + // Удалить товар из корзины + removeItem: async (productId, userId = null) => { + // Проверяем авторизацию + if (!isAuthenticated()) { + const error = new Error('Пользователь не авторизован') + error.code = 'UNAUTHORIZED' + throw error + } + + // Используем новый endpoint - бэкенд сам определит user_id из токена + const response = await apiClient.delete(`/cart/${productId}`) + return response.data + }, + + // Очистить корзину + clearCart: async (userId = null) => { + // Проверяем авторизацию + if (!isAuthenticated()) { + const error = new Error('Пользователь не авторизован') + error.code = 'UNAUTHORIZED' + throw error + } + + // Используем новый endpoint - бэкенд сам определит user_id из токена + const response = await apiClient.delete('/cart') + return response.data + }, +} diff --git a/client/src/api/categories.js b/client/src/api/categories.js new file mode 100644 index 0000000..6451275 --- /dev/null +++ b/client/src/api/categories.js @@ -0,0 +1,42 @@ +import apiClient from './client' + +export const categoriesApi = { + // Получить все категории + getAll: async (params = {}) => { + const { page, limit } = params + const queryParams = new URLSearchParams() + if (page) queryParams.append('page', page) + if (limit) queryParams.append('limit', limit) + + const url = queryParams.toString() + ? `/categories?${queryParams.toString()}` + : '/categories' + + const response = await apiClient.get(url) + return response.data + }, + + // Получить категорию по ID + getById: async (id) => { + const response = await apiClient.get(`/categories/${id}`) + return response.data + }, + + // Получить категорию по slug + getBySlug: async (slug) => { + const response = await apiClient.get(`/categories/slug/${slug}`) + return response.data + }, + + // Получить корневые категории + getRoot: async () => { + const response = await apiClient.get('/categories/root') + return response.data + }, + + // Получить дочерние категории + getChildren: async (id) => { + const response = await apiClient.get(`/categories/${id}/children`) + return response.data + }, +} diff --git a/client/src/api/client.js b/client/src/api/client.js new file mode 100644 index 0000000..e413300 --- /dev/null +++ b/client/src/api/client.js @@ -0,0 +1,73 @@ +import axios from 'axios' + +// В режиме разработки используем относительный путь для прокси Vite +// В продакшене можно использовать переменную окружения VITE_API_URL +const API_BASE_URL = import.meta.env.VITE_API_URL || '/api/v1' + +const apiClient = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// Interceptor для добавления токена авторизации +apiClient.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + // Логирование для отладки (можно убрать в продакшене) + if (import.meta.env.DEV) { + console.log('Запрос с токеном:', config.url, 'Token:', token.substring(0, 20) + '...') + } + } else { + if (import.meta.env.DEV) { + console.log('Запрос без токена:', config.url) + } + } + + // Для FormData не устанавливаем Content-Type, браузер установит его автоматически с boundary + if (config.data instanceof FormData) { + delete config.headers['Content-Type'] + } + + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// Interceptor для обработки ошибок +apiClient.interceptors.response.use( + (response) => { + if (import.meta.env.DEV) { + console.log('✅ Успешный ответ:', response.config.url, response.status) + } + return response + }, + (error) => { + console.error('❌ Ошибка API:', { + url: error.config?.url, + method: error.config?.method, + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + message: error.message + }) + + if (error.response?.status === 401) { + // Не редиректим на логин, если мы уже на странице логина + if (!window.location.pathname.includes('/login')) { + localStorage.removeItem('token') + localStorage.removeItem('user') + localStorage.removeItem('userId') + window.location.href = '/login' + } + } + return Promise.reject(error) + } +) + +export default apiClient diff --git a/client/src/api/orders.js b/client/src/api/orders.js new file mode 100644 index 0000000..6ffccf9 --- /dev/null +++ b/client/src/api/orders.js @@ -0,0 +1,68 @@ +import apiClient from './client' +import { getUser, isAuthenticated } from '../utils/auth' + +export const ordersApi = { + // Создать заказ из корзины + create: async (orderData) => { + // Проверяем авторизацию + if (!isAuthenticated()) { + const error = new Error('Пользователь не авторизован') + error.code = 'UNAUTHORIZED' + throw error + } + + // Бэкенд сам определит user_id из токена, поэтому не передаем его + // user_id можно передать опционально для обратной совместимости + const requestData = { ...orderData } + + // Пытаемся получить user_id из localStorage (опционально, для обратной совместимости) + // Если не получится - бэкенд сам определит из токена + if (!requestData.user_id) { + const user = getUser() + const userId = user?.id || localStorage.getItem('userId') + if (userId) { + requestData.user_id = parseInt(userId) + } + } + + // Отправляем запрос - бэкенд сам определит user_id из токена, если не передан + const response = await apiClient.post('/orders', requestData) + return response.data + }, + + // Получить заказы текущего пользователя + getMyOrders: async (params = {}) => { + if (!isAuthenticated()) { + const error = new Error('Пользователь не авторизован') + error.code = 'UNAUTHORIZED' + throw error + } + + const user = getUser() + const userId = user?.id || localStorage.getItem('userId') + + if (!userId) { + return { success: true, data: [], pagination: null } + } + + const queryParams = new URLSearchParams() + if (params.page) queryParams.append('page', params.page) + if (params.limit) queryParams.append('limit', params.limit) + queryParams.append('user_id', userId) + + const response = await apiClient.get(`/orders?${queryParams.toString()}`) + return response.data + }, + + // Получить заказ по ID + getById: async (id) => { + if (!isAuthenticated()) { + const error = new Error('Пользователь не авторизован') + error.code = 'UNAUTHORIZED' + throw error + } + + const response = await apiClient.get(`/orders/${id}`) + return response.data + } +} diff --git a/client/src/api/products.js b/client/src/api/products.js new file mode 100644 index 0000000..ddb0d18 --- /dev/null +++ b/client/src/api/products.js @@ -0,0 +1,77 @@ +import apiClient from './client' + +export const productsApi = { + // Получить все товары с пагинацией + getAll: async (params = {}) => { + const { page = 1, limit = 20, category_id, search, is_active } = params + const queryParams = new URLSearchParams() + if (page) queryParams.append('page', page) + if (limit) queryParams.append('limit', limit) + if (category_id) queryParams.append('category_id', category_id) + if (search) queryParams.append('q', search) + // is_active передаем только если явно указан + if (is_active !== undefined) { + queryParams.append('is_active', is_active) + } + + console.log('📦 Запрос товаров:', `/products?${queryParams.toString()}`) + try { + const response = await apiClient.get(`/products?${queryParams.toString()}`) + console.log('📥 Сырой ответ от сервера:', { + status: response.status, + statusText: response.statusText, + data: response.data, + dataType: typeof response.data, + hasSuccess: 'success' in (response.data || {}), + hasData: 'data' in (response.data || {}), + dataIsArray: Array.isArray(response.data?.data), + dataLength: response.data?.data?.length, + fullResponse: response.data + }) + + // Проверяем структуру ответа + if (response.data) { + // Если данные пришли напрямую как массив + if (Array.isArray(response.data)) { + console.log('⚠️ Данные пришли как массив напрямую') + return { + success: true, + data: response.data, + pagination: null + } + } + // Если данные в стандартном формате { success, data, pagination } + return response.data + } + + console.error('❌ Пустой ответ от сервера') + return { success: false, data: [], pagination: null } + } catch (error) { + console.error('❌ Ошибка запроса товаров:', { + message: error.message, + response: error.response?.data, + status: error.response?.status, + fullError: error + }) + throw error + } + }, + + // Получить товар по ID + getById: async (id) => { + const response = await apiClient.get(`/products/${id}`) + return response.data + }, + + // Получить товар по slug + getBySlug: async (slug) => { + const response = await apiClient.get(`/products/slug/${slug}`) + return response.data + }, + + // Поиск товаров + search: async (query) => { + const response = await apiClient.get(`/products/search?q=${encodeURIComponent(query)}`) + return response.data + }, +} diff --git a/client/src/api/reviews.js b/client/src/api/reviews.js new file mode 100644 index 0000000..48e5559 --- /dev/null +++ b/client/src/api/reviews.js @@ -0,0 +1,65 @@ +import apiClient from './client' + +export const reviewsApi = { + // Получить отзывы на товар + getByProductId: async (productId, options = {}) => { + const params = new URLSearchParams() + if (options.page) params.append('page', options.page) + if (options.limit) params.append('limit', options.limit) + if (options.approved_only !== undefined) params.append('approved_only', options.approved_only) + + const queryString = params.toString() + const url = `/reviews/product/${productId}${queryString ? '?' + queryString : ''}` + const response = await apiClient.get(url) + return response.data + }, + + // Получить рейтинг товара + getRating: async (productId) => { + const response = await apiClient.get(`/reviews/product/${productId}/rating`) + return response.data + }, + + // Создать отзыв + create: async (reviewData) => { + const response = await apiClient.post('/reviews', reviewData) + return response.data + }, + + // Обновить отзыв + update: async (reviewId, reviewData) => { + const response = await apiClient.put(`/reviews/${reviewId}`, reviewData) + return response.data + }, + + // Удалить отзыв + delete: async (reviewId) => { + const response = await apiClient.delete(`/reviews/${reviewId}`) + return response.data + }, + + // Получить отзыв текущего пользователя на товар + getMyReview: async (productId) => { + try { + const response = await apiClient.get(`/reviews/my?product_id=${productId}`) + if (response.data.success && response.data.data) { + return response.data.data + } + return null + } catch (error) { + return null + } + }, + + // Получить все отзывы текущего пользователя + getMyReviews: async (options = {}) => { + const params = new URLSearchParams() + if (options.page) params.append('page', options.page) + if (options.limit) params.append('limit', options.limit) + + const queryString = params.toString() + const url = `/reviews/my${queryString ? '?' + queryString : ''}` + const response = await apiClient.get(url) + return response.data + } +} diff --git a/client/src/api/settings.js b/client/src/api/settings.js new file mode 100644 index 0000000..2784b0d --- /dev/null +++ b/client/src/api/settings.js @@ -0,0 +1,45 @@ +import apiClient from './client' + +const settingsApi = { + // Получить все настройки + getAll: async () => { + const response = await apiClient.get('/settings') + return response.data + }, + + // Получить конкретную настройку + get: async (key) => { + const response = await apiClient.get(`/settings/${key}`) + return response.data + }, + + // Получить значение настройки (упрощенный метод) + getValue: async (key, defaultValue = null) => { + try { + const response = await settingsApi.get(key) + return response.success && response.data ? response.data.value : defaultValue + } catch (error) { + return defaultValue + } + } +} + +const adminSettingsApi = { + // Получить все настройки с деталями (для админов) + getAll: async () => { + const response = await apiClient.get('/admin/settings') + return response.data + }, + + // Обновить настройку + update: async (key, value, description = null, type = 'string') => { + const response = await apiClient.put(`/settings/${key}`, { + value, + description, + type + }) + return response.data + } +} + +export { settingsApi, adminSettingsApi } diff --git a/client/src/components/AdminRoute.jsx b/client/src/components/AdminRoute.jsx new file mode 100644 index 0000000..4430a75 --- /dev/null +++ b/client/src/components/AdminRoute.jsx @@ -0,0 +1,31 @@ +import { Navigate, useLocation } from 'react-router-dom' +import { isAuthenticated, isAdmin, hasOrderAccess } from '../utils/auth' + +function AdminRoute({ children, requireOrderAccess = false }) { + const location = useLocation() + const token = localStorage.getItem('token') + + if (!token) { + // Сохраняем текущий путь для редиректа после логина + return + } + + // Если требуется доступ к заказам (для страницы /orders) + if (requireOrderAccess) { + if (!hasOrderAccess()) { + // Если пользователь не имеет доступа к заказам - перенаправляем на главную + return + } + return children + } + + // Для остальных админских страниц проверяем только isAdmin + if (!isAdmin()) { + // Если пользователь не админ - перенаправляем на главную + return + } + + return children +} + +export default AdminRoute diff --git a/client/src/components/FloatingCartPanel.css b/client/src/components/FloatingCartPanel.css new file mode 100644 index 0000000..7c677bf --- /dev/null +++ b/client/src/components/FloatingCartPanel.css @@ -0,0 +1,342 @@ +.floating-cart-button { + position: fixed; + bottom: 20px; + right: 20px; + width: 56px; + height: 56px; + border-radius: 12px; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + color: white; + border: 1px solid rgba(255, 255, 255, 0.2); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + z-index: 1000; + transition: all 0.3s ease; + padding: 0; +} + +.floating-cart-button:hover { + background: rgba(0, 0, 0, 0.75); + transform: translateY(-2px); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3); +} + +.floating-cart-button:active { + transform: scale(0.95); +} + +.floating-cart-badge { + position: absolute; + top: -4px; + right: -4px; + background-color: var(--danger-color, #dc3545); + color: white; + font-size: 0.75rem; + font-weight: 600; + padding: 0.25rem 0.5rem; + border-radius: 12px; + min-width: 20px; + text-align: center; + border: 2px solid white; +} + +.floating-cart-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1001; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.floating-cart-panel { + position: fixed; + bottom: 88px; + right: 20px; + width: 90vw; + max-width: 400px; + max-height: 70vh; + background: var(--bg-card); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--border-color); + border-radius: 16px; + box-shadow: var(--shadow-lg); + z-index: 1002; + display: flex; + flex-direction: column; + animation: slideUp 0.3s ease; + overflow: hidden; + color: var(--text-primary); +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.floating-cart-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color); +} + +.floating-cart-header h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); +} + +.floating-cart-close { + background: none; + border: none; + cursor: pointer; + padding: 0.5rem; + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background-color 0.2s; +} + +.floating-cart-close:hover { + background-color: var(--bg-tertiary); +} + +.floating-cart-content { + flex: 1; + overflow-y: auto; + padding: 1rem; +} + +.floating-cart-empty { + text-align: center; + padding: 2rem 1rem; + color: var(--text-secondary); +} + +.floating-cart-empty p { + margin-bottom: 1rem; +} + +.floating-cart-empty a { + color: var(--primary-color, #007bff); + text-decoration: none; + font-weight: 500; +} + +.floating-cart-empty a:hover { + text-decoration: underline; +} + +.floating-cart-items { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.floating-cart-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 0.75rem; + background-color: #f9f9f9; + border-radius: 8px; + gap: 1rem; +} + +.floating-cart-item-info { + flex: 1; + min-width: 0; +} + +.floating-cart-item-info h4 { + margin: 0 0 0.5rem 0; + font-size: 0.95rem; + font-weight: 500; + color: #333; + word-wrap: break-word; +} + +.floating-cart-item-price { + margin: 0; + font-size: 0.875rem; + color: #666; +} + +.floating-cart-item-remove { + background: none; + border: none; + cursor: pointer; + padding: 0.5rem; + color: #999; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; + flex-shrink: 0; +} + +.floating-cart-item-remove:hover { + background-color: rgba(244, 67, 54, 0.2); + color: var(--danger-color); +} + +.floating-cart-item-remove:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.floating-cart-footer { + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-color); + background-color: var(--bg-tertiary); +} + +.floating-cart-total { + margin-bottom: 1rem; + font-size: 1.125rem; + color: var(--text-primary); + text-align: center; +} + +.floating-cart-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.floating-cart-view-full { + width: 100%; + padding: 0.625rem 1.5rem; + background-color: transparent; + color: var(--primary-color, #007bff); + border: 1px solid var(--primary-color, #007bff); + border-radius: 8px; + font-size: 0.95rem; + font-weight: 500; + text-align: center; + text-decoration: none; + cursor: pointer; + transition: all 0.2s; + display: block; +} + +.floating-cart-view-full:hover { + background-color: var(--primary-color, #007bff); + color: white; +} + +.floating-cart-checkout { + width: 100%; + padding: 0.75rem 1.5rem; + background-color: var(--primary-color, #007bff); + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; +} + +.floating-cart-checkout:hover { + background-color: var(--primary-color-hover, #0056b3); +} + +.floating-cart-checkout:active { + transform: scale(0.98); +} + +/* Адаптация для мобильных устройств */ +@media (max-width: 768px) { + .floating-cart-button { + width: 52px; + height: 52px; + bottom: 16px; + right: 16px; + } + + .floating-cart-panel { + bottom: 80px; + right: 16px; + left: 16px; + width: auto; + max-height: 75vh; + } + + .floating-orders-button { + bottom: 80px; + } + + .floating-settings-button { + bottom: 144px; + } + + .floating-cart-header { + padding: 0.875rem 1.25rem; + } + + .floating-cart-header h3 { + font-size: 1.125rem; + } + + .floating-cart-content { + padding: 0.875rem; + } + + .floating-cart-footer { + padding: 0.875rem 1.25rem; + } +} + +@media (max-width: 480px) { + .floating-cart-button { + width: 48px; + height: 48px; + bottom: 12px; + right: 12px; + } + + .floating-cart-panel { + bottom: 72px; + right: 12px; + left: 12px; + max-height: 80vh; + } + + .floating-orders-button { + bottom: 72px; + } + + .floating-settings-button { + bottom: 132px; + } +} diff --git a/client/src/components/FloatingCartPanel.jsx b/client/src/components/FloatingCartPanel.jsx new file mode 100644 index 0000000..75b8c39 --- /dev/null +++ b/client/src/components/FloatingCartPanel.jsx @@ -0,0 +1,205 @@ +import { useState, useEffect } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { cartApi } from '../api/cart' +import { isAuthenticated } from '../utils/auth' +import './FloatingCartPanel.css' + +function FloatingCartPanel() { + const [cartCount, setCartCount] = useState(0) + const [isOpen, setIsOpen] = useState(false) + const [cartItems, setCartItems] = useState([]) + const [loading, setLoading] = useState(false) + const navigate = useNavigate() + + useEffect(() => { + const loadCart = async () => { + if (!isAuthenticated()) { + setCartCount(0) + setCartItems([]) + return + } + + try { + const response = await cartApi.getCart() + if (response && response.success && response.data) { + setCartCount(response.data.itemCount || 0) + setCartItems(response.data.items || []) + } else { + setCartCount(0) + setCartItems([]) + } + } catch (error) { + // Игнорируем ошибки доступа (401, 403) - просто показываем пустую корзину + if (error.response?.status !== 401 && error.response?.status !== 403) { + console.error('Ошибка загрузки корзины:', error) + } + setCartCount(0) + setCartItems([]) + } + } + + loadCart() + + // Обновлять каждые 5 секунд + const interval = setInterval(loadCart, 5000) + + // Слушать события обновления корзины + const handleCartUpdate = () => { + loadCart() + } + window.addEventListener('cartUpdated', handleCartUpdate) + + return () => { + clearInterval(interval) + window.removeEventListener('cartUpdated', handleCartUpdate) + } + }, []) + + const formatPrice = (price) => { + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: 'RUB', + minimumFractionDigits: 0, + maximumFractionDigits: 2 + }).format(price) + } + + const handleRemoveItem = async (itemId) => { + try { + setLoading(true) + await cartApi.removeItem(itemId) + const response = await cartApi.getCart() + if (response && response.success && response.data) { + setCartCount(response.data.itemCount || 0) + setCartItems(response.data.items || []) + } + window.dispatchEvent(new Event('cartUpdated')) + } catch (error) { + console.error('Ошибка удаления товара:', error) + } finally { + setLoading(false) + } + } + + const calculateTotal = () => { + return cartItems.reduce((sum, item) => sum + (item.final_price || item.price || 0) * item.quantity, 0) + } + + const handleCheckout = () => { + setIsOpen(false) + navigate('/cart') + } + + if (!isAuthenticated()) { + return null + } + + // Скрываем корзину в админке + if (window.location.pathname.startsWith('/admin')) { + return null + } + + return ( + <> + {/* Плавающая кнопка корзины */} + + + {/* Панель корзины */} + {isOpen && ( + <> +
setIsOpen(false)} + /> +
+
+

Корзина

+ +
+ +
+ {cartItems.length === 0 ? ( +
+

Корзина пуста

+ setIsOpen(false)}> + Перейти к товарам + +
+ ) : ( + <> +
+ {cartItems.map((item) => ( +
+
+

{item.product_name || 'Товар'}

+

+ {formatPrice(item.final_price || item.price || 0)} × {item.quantity} +

+
+ +
+ ))} +
+
+
+ Итого: {formatPrice(calculateTotal())} +
+
+ setIsOpen(false)} + > + Открыть корзину + + +
+
+ + )} +
+
+ + )} + + ) +} + +export default FloatingCartPanel diff --git a/client/src/components/FloatingOrdersPanel.css b/client/src/components/FloatingOrdersPanel.css new file mode 100644 index 0000000..22d0f9f --- /dev/null +++ b/client/src/components/FloatingOrdersPanel.css @@ -0,0 +1,283 @@ +.floating-orders-button { + position: fixed; + bottom: 88px; + right: 20px; + width: 56px; + height: 56px; + border-radius: 12px; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + color: white; + border: 1px solid rgba(255, 255, 255, 0.2); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + z-index: 1000; + transition: all 0.3s ease; + padding: 0; +} + +.floating-orders-button:hover { + background: rgba(0, 0, 0, 0.75); + transform: translateY(-2px); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3); +} + +.floating-orders-button:active { + transform: scale(0.95); +} + +.floating-orders-badge { + position: absolute; + top: -4px; + right: -4px; + background-color: var(--danger-color, #dc3545); + color: white; + font-size: 0.75rem; + font-weight: 600; + padding: 0.25rem 0.5rem; + border-radius: 12px; + min-width: 20px; + text-align: center; + border: 2px solid white; +} + +.floating-orders-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1001; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.floating-orders-panel { + position: fixed; + bottom: 156px; + right: 20px; + width: 90vw; + max-width: 400px; + max-height: 70vh; + background: var(--bg-card); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--border-color); + border-radius: 16px; + box-shadow: var(--shadow-lg); + z-index: 1002; + display: flex; + flex-direction: column; + animation: slideUp 0.3s ease; + overflow: hidden; + color: var(--text-primary); +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.floating-orders-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color); +} + +.floating-orders-header h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); +} + +.floating-orders-close { + background: none; + border: none; + cursor: pointer; + padding: 0.5rem; + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background-color 0.2s; +} + +.floating-orders-close:hover { + background-color: var(--bg-tertiary); +} + +.floating-orders-content { + flex: 1; + overflow-y: auto; + padding: 1rem; +} + +.floating-orders-empty { + text-align: center; + padding: 2rem 1rem; + color: var(--text-secondary); +} + +.floating-orders-empty p { + margin-bottom: 1rem; +} + +.floating-orders-pending { + text-align: center; + padding: 1.5rem 1rem; +} + +.floating-orders-pending p { + margin-bottom: 1rem; + font-size: 1.125rem; + color: #333; +} + +.floating-orders-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.floating-orders-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 0.75rem; + background-color: #f9f9f9; + border-radius: 8px; + gap: 1rem; + cursor: pointer; + transition: background-color 0.2s; +} + +.floating-orders-item:hover { + background-color: #f0f0f0; +} + +.floating-orders-item-info { + flex: 1; + min-width: 0; +} + +.floating-orders-item-info h4 { + margin: 0 0 0.5rem 0; + font-size: 0.95rem; + font-weight: 500; + color: #333; + word-wrap: break-word; +} + +.floating-orders-item-date { + margin: 0 0 0.25rem 0; + font-size: 0.875rem; + color: #666; +} + +.floating-orders-item-total { + margin: 0; + font-size: 0.875rem; + font-weight: 600; + color: #333; +} + +.floating-orders-item-status { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + background-color: #e3f2fd; + color: #1976d2; + border-radius: 4px; + white-space: nowrap; + flex-shrink: 0; +} + +.floating-orders-view-all { + width: 100%; + margin-top: 1rem; + padding: 0.75rem 1.5rem; + background-color: var(--success-color, #28a745); + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; +} + +.floating-orders-view-all:hover { + background-color: var(--success-color-hover, #218838); +} + +.floating-orders-view-all:active { + transform: scale(0.98); +} + +/* Адаптация для мобильных устройств */ +@media (max-width: 768px) { + .floating-orders-button { + width: 52px; + height: 52px; + bottom: 80px; + right: 16px; + } + + .floating-orders-panel { + bottom: 144px; + right: 16px; + left: 16px; + width: auto; + max-height: 75vh; + } + + .floating-orders-header { + padding: 0.875rem 1.25rem; + } + + .floating-orders-header h3 { + font-size: 1.125rem; + } + + .floating-orders-content { + padding: 0.875rem; + } +} + +@media (max-width: 480px) { + .floating-orders-button { + width: 48px; + height: 48px; + bottom: 72px; + right: 12px; + } + + .floating-orders-panel { + bottom: 72px; + right: 12px; + left: 12px; + max-height: 80vh; + } +} diff --git a/client/src/components/FloatingOrdersPanel.jsx b/client/src/components/FloatingOrdersPanel.jsx new file mode 100644 index 0000000..6278ce5 --- /dev/null +++ b/client/src/components/FloatingOrdersPanel.jsx @@ -0,0 +1,214 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { ordersApi } from '../api/orders' +import { adminOrdersApi } from '../api/admin/orders' +import { isAuthenticated, isAdmin, hasOrderAccess, hasOrderEditAccess } from '../utils/auth' +import './FloatingOrdersPanel.css' + +function FloatingOrdersPanel() { + const [pendingCount, setPendingCount] = useState(0) + const [isOpen, setIsOpen] = useState(false) + const [orders, setOrders] = useState([]) + const [loading, setLoading] = useState(false) + const navigate = useNavigate() + const userHasOrderAccess = hasOrderAccess() + + useEffect(() => { + const loadOrders = async () => { + if (!isAuthenticated()) { + setPendingCount(0) + setOrders([]) + return + } + + try { + if (userHasOrderAccess) { + // Для пользователей с доступом к заказам загружаем необработанные заказы + const count = await adminOrdersApi.getPendingCount() + setPendingCount(count || 0) + } else { + // Для обычного пользователя загружаем последние заказы + const response = await ordersApi.getMyOrders({ page: 1, limit: 5 }) + if (response && response.success && response.data) { + setOrders(response.data.orders || []) + setPendingCount(0) + } + } + } catch (error) { + setPendingCount(0) + setOrders([]) + } + } + + loadOrders() + + // Обновлять каждые 10 секунд + const interval = setInterval(loadOrders, 10000) + + return () => { + clearInterval(interval) + } + }, [userHasOrderAccess]) + + const formatDate = (dateString) => { + if (!dateString) return '—' + const date = new Date(dateString) + return date.toLocaleDateString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }) + } + + const formatPrice = (price) => { + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: 'RUB', + minimumFractionDigits: 0, + maximumFractionDigits: 2 + }).format(price) + } + + const handleViewOrder = (orderId) => { + setIsOpen(false) + navigate('/orders') + // Можно добавить логику для открытия конкретного заказа + } + + if (!isAuthenticated()) { + return null + } + + return ( + <> + {/* Плавающая кнопка заказов */} + + + {/* Панель заказов */} + {isOpen && ( + <> +
setIsOpen(false)} + /> +
+
+

{userHasOrderAccess ? 'Заказы' : 'Мои заказы'}

+ +
+ +
+ {userHasOrderAccess ? ( +
+ {pendingCount > 0 ? ( +
+

Необработанных заказов: {pendingCount}

+ +
+ ) : ( +
+

Нет необработанных заказов

+ +
+ )} +
+ ) : ( + <> + {orders.length === 0 ? ( +
+

У вас пока нет заказов

+ +
+ ) : ( +
+ {orders.map((order) => ( +
handleViewOrder(order.id)} + > +
+

Заказ {order.order_number || order.id}

+

+ {formatDate(order.created_at)} +

+

+ {formatPrice(order.total_amount || 0)} +

+
+ {order.status_name && ( + + {order.status_name} + + )} +
+ ))} + +
+ )} + + )} +
+
+ + )} + + ) +} + +export default FloatingOrdersPanel diff --git a/client/src/components/FloatingSettingsPanel.css b/client/src/components/FloatingSettingsPanel.css new file mode 100644 index 0000000..4b84d7d --- /dev/null +++ b/client/src/components/FloatingSettingsPanel.css @@ -0,0 +1,204 @@ +.floating-settings-button { + position: fixed; + bottom: 156px; + right: 20px; + width: 56px; + height: 56px; + border-radius: 12px; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + color: white; + border: 1px solid rgba(255, 255, 255, 0.2); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + z-index: 1000; + transition: all 0.3s ease; + padding: 0; +} + +.floating-settings-button:hover { + background: rgba(0, 0, 0, 0.75); + transform: translateY(-2px); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3); +} + +.floating-settings-button:active { + transform: scale(0.95); +} + +.floating-settings-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1001; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.floating-settings-panel { + position: fixed; + bottom: 90px; + right: 160px; + width: 90vw; + max-width: 350px; + max-height: 70vh; + background-color: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: var(--shadow-lg); + z-index: 1002; + display: flex; + flex-direction: column; + animation: slideUp 0.3s ease; + overflow: hidden; + color: var(--text-primary); +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.floating-settings-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color); +} + +.floating-settings-header h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); +} + +.floating-settings-close { + background: none; + border: none; + cursor: pointer; + padding: 0.5rem; + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background-color 0.2s; +} + +.floating-settings-close:hover { + background-color: var(--bg-tertiary); +} + +.floating-settings-content { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +.floating-settings-menu { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.floating-settings-menu-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.875rem 1rem; + background: none; + border: none; + border-radius: 8px; + cursor: pointer; + text-align: left; + font-size: 0.95rem; + color: var(--text-primary); + transition: background-color 0.2s; + width: 100%; +} + +.floating-settings-menu-item:hover { + background-color: var(--bg-tertiary); +} + +.floating-settings-menu-item:active { + transform: scale(0.98); +} + +.floating-settings-menu-item svg { + flex-shrink: 0; + color: #666; +} + +.floating-settings-menu-item span { + flex: 1; +} + +/* Адаптация для мобильных устройств */ +@media (max-width: 768px) { + .floating-settings-button { + width: 52px; + height: 52px; + bottom: 144px; + right: 16px; + } + + .floating-settings-panel { + bottom: 208px; + right: 16px; + left: 16px; + width: auto; + max-height: 75vh; + } + + .floating-settings-header { + padding: 0.875rem 1.25rem; + } + + .floating-settings-header h3 { + font-size: 1.125rem; + } + + .floating-settings-content { + padding: 0.5rem; + } +} + +@media (max-width: 480px) { + .floating-settings-button { + width: 48px; + height: 48px; + bottom: 132px; + right: 12px; + } + + .floating-settings-panel { + bottom: 72px; + right: 12px; + left: 12px; + max-height: 80vh; + } +} diff --git a/client/src/components/FloatingSettingsPanel.jsx b/client/src/components/FloatingSettingsPanel.jsx new file mode 100644 index 0000000..393656c --- /dev/null +++ b/client/src/components/FloatingSettingsPanel.jsx @@ -0,0 +1,178 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { isAuthenticated, isAdmin } from '../utils/auth' +import './FloatingSettingsPanel.css' + +function FloatingSettingsPanel() { + const [isOpen, setIsOpen] = useState(false) + const navigate = useNavigate() + const userIsAdmin = isAdmin() + + useEffect(() => { + // Панель настроек доступна только для админов + }, []) + + if (!isAuthenticated() || !userIsAdmin) { + return null + } + + const handleSettingsClick = () => { + setIsOpen(false) + navigate('/admin/settings') + } + + return ( + <> + {/* Плавающая кнопка настроек */} + + + {/* Панель настроек */} + {isOpen && ( + <> +
setIsOpen(false)} + /> +
+
+

Настройки

+ +
+ +
+
+ + + + + + + + +
+
+
+ + )} + + ) +} + +export default FloatingSettingsPanel diff --git a/client/src/components/Footer.css b/client/src/components/Footer.css new file mode 100644 index 0000000..0fbaa21 --- /dev/null +++ b/client/src/components/Footer.css @@ -0,0 +1,61 @@ +.footer { + background-color: var(--text-primary); + color: white; + margin-top: auto; + padding: 3rem 0 1rem; +} + +.footer-content { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 2rem; + margin-bottom: 2rem; +} + +.footer-section h3, +.footer-section h4 { + margin-bottom: 1rem; + color: white; +} + +.footer-section p { + color: rgba(255, 255, 255, 0.8); + line-height: 1.8; +} + +.footer-section ul { + list-style: none; + padding: 0; +} + +.footer-section ul li { + margin-bottom: 0.5rem; +} + +.footer-section ul li a { + color: rgba(255, 255, 255, 0.8); + text-decoration: none; + transition: color 0.2s; +} + +.footer-section ul li a:hover { + color: white; +} + +.footer-bottom { + border-top: 1px solid rgba(255, 255, 255, 0.1); + padding-top: 1rem; + text-align: center; + color: rgba(255, 255, 255, 0.6); +} + +@media (max-width: 768px) { + .footer { + padding: 2rem 0 1rem; + } + + .footer-content { + grid-template-columns: 1fr; + gap: 1.5rem; + } +} diff --git a/client/src/components/Footer.jsx b/client/src/components/Footer.jsx new file mode 100644 index 0000000..39cfbbc --- /dev/null +++ b/client/src/components/Footer.jsx @@ -0,0 +1,34 @@ +import './Footer.css' + +function Footer() { + return ( +
+
+
+
+

+

Современный интернет-магазин с удобным интерфейсом

+
+
+

Навигация

+ +
+
+

Контакты

+

Email: info@shopplatform.com

+

Телефон: +7 (999) 123-45-67

+
+
+
+

© 2024. Все права защищены.

+
+
+
+ ) +} + +export default Footer diff --git a/client/src/components/Header.css b/client/src/components/Header.css new file mode 100644 index 0000000..23a81c0 --- /dev/null +++ b/client/src/components/Header.css @@ -0,0 +1,587 @@ +.header { + background-color: var(--bg-color); + box-shadow: var(--shadow-sm); + position: sticky; + top: 0; + z-index: 1000; + width: 100% !important; + max-width: 100% !important; + margin: 0 !important; + padding: 0 !important; + left: 0; + right: 0; +} + +@media (min-width: 769px) { + .header { + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border-bottom: 1px solid rgba(255, 255, 255, 0.2); + } + + .nav-link { + background-color: transparent !important; + background: transparent !important; + color: #ffffff; + } + + .nav-link:hover { + background-color: transparent !important; + background: transparent !important; + color: #ffffff; + } + + .user-avatar-link, + .logout-icon-btn { + color: #ffffff !important; + } + + .user-avatar-link svg, + .logout-icon-btn svg { + stroke: #ffffff !important; + color: #ffffff !important; + } + + .user-avatar-icon { + background: rgba(255, 255, 255, 0.2) !important; + border: 2px solid rgba(255, 255, 255, 0.3) !important; + } + + .user-avatar-icon svg { + stroke: #ffffff !important; + color: #ffffff !important; + } +} + +@media (max-width: 768px) { + .header { + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border-bottom: 1px solid rgba(255, 255, 255, 0.2); + width: 100% !important; + max-width: 100vw !important; + margin: 0 !important; + padding: 0 !important; + left: 0 !important; + right: 0 !important; + } + + .header .container { + width: 100% !important; + max-width: 100% !important; + margin: 0 !important; + padding: 0 !important; + } + + .header-content { + width: 100% !important; + max-width: 100% !important; + padding-left: 1rem; + padding-right: 1rem; + } + + .mobile-menu-toggle { + color: #ffffff !important; + } + + .mobile-menu-toggle svg { + stroke: #ffffff !important; + color: #ffffff !important; + } + + .user-avatar-link, + .logout-icon-btn { + color: #ffffff !important; + } + + .user-avatar-link svg, + .logout-icon-btn svg { + stroke: #ffffff !important; + color: #ffffff !important; + } + + .user-avatar-icon { + background: rgba(255, 255, 255, 0.2) !important; + border: 2px solid rgba(255, 255, 255, 0.3) !important; + } + + .user-avatar-icon svg { + stroke: #ffffff !important; + color: #ffffff !important; + } +} + +.header-content { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 0; + gap: 0; + width: 100%; + max-width: 100%; +} + +@media (min-width: 769px) { + .header .container { + padding: 0; + margin: 0; + } + + .header-content { + padding-left: 1rem; + } +} + +.header .container { + margin: 0 !important; + padding: 0 !important; + max-width: 100% !important; + width: 100% !important; +} + +.logo { + text-decoration: none; + color: var(--text-primary); + display: none; /* Скрываем логотип, так как текст убран */ +} + +.logo h1 { + font-size: 1.5rem; + font-weight: 700; + color: var(--primary-color); + margin: 0; +} + +.nav { + display: flex; + gap: 2rem; + margin-left: 0; +} + +/* Десктоп версия nav */ +.nav-desktop { + display: flex; + gap: 2rem; + margin-left: 0; +} + +/* Мобильная версия nav */ +.nav-mobile { + position: fixed; + top: 0; + left: -280px; + width: 280px; + height: 100vh; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border-right: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + flex-direction: column; + align-items: stretch; + padding: 4rem 0 2rem 0; + gap: 0; + transition: left 0.3s ease; + z-index: 1000; + overflow-y: auto; + pointer-events: auto; + margin: 0; +} + +.nav-mobile.nav-open { + left: 0; +} + +.nav-link { + text-decoration: none; + color: var(--text-primary); + font-weight: 500; + transition: color 0.2s; + position: relative; +} + +.nav-link:hover { + color: var(--primary-color); +} + +.nav-link::after { + content: ''; + position: absolute; + bottom: -4px; + left: 0; + width: 0; + height: 2px; + background-color: var(--primary-color); + transition: width 0.2s; +} + +.nav-link:hover::after { + width: 100%; +} + +.header-actions { + display: flex; + align-items: center; + gap: 1rem; +} + +.user-avatar-link { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + text-decoration: none; + border-radius: 50%; + overflow: hidden; + transition: all 0.2s; + margin-left: 0.5rem; + flex-shrink: 0; +} + +.user-avatar-link:hover { + transform: scale(1.05); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.user-avatar { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +} + +.user-avatar-initials { + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--primary-color, #4169E1) 0%, #6B8EFF 100%); + color: white; + font-weight: 600; + font-size: 0.875rem; + text-transform: uppercase; + border: 2px solid rgba(255, 255, 255, 0.2); +} + +.user-avatar-icon { + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary, #f5f5f5); + color: var(--text-primary); + border: 2px solid var(--border-color, #e0e0e0); +} + +.user-avatar-icon svg { + color: var(--text-primary); +} + +.orders-link-icon { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + color: var(--text-primary); + text-decoration: none; + border-radius: var(--radius); + transition: background-color 0.2s; +} + +.orders-link-icon:hover { + background-color: var(--bg-secondary); + color: var(--primary-color); +} + +.orders-badge { + position: absolute; + top: -4px; + right: -4px; + background-color: var(--danger-color, #dc3545); + color: white; + font-size: 0.75rem; + font-weight: 600; + padding: 0.125rem 0.375rem; + border-radius: 10px; + min-width: 20px; + text-align: center; + line-height: 1.2; +} + +.admin-link-icon { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + color: var(--text-primary); + text-decoration: none; + border-radius: var(--radius); + transition: all 0.2s; +} + +.admin-link-icon:hover { + background-color: var(--bg-secondary); + color: var(--primary-color); +} + +.login-link { + padding: 0.5rem 1rem; + background-color: var(--primary-color); + color: white; + border-radius: var(--radius); + font-weight: 600; + transition: background-color 0.2s; + text-decoration: none; +} + +.login-link:hover { + background-color: var(--primary-color-dark, #2980b9); + color: white; +} + +.logout-icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background-color: transparent; + border: none; + color: var(--text-primary); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.2s; + padding: 0; +} + +.logout-icon-btn:hover { + background-color: var(--bg-secondary); + color: var(--primary-color); +} + +.cart-link { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + color: var(--text-primary); + text-decoration: none; + border-radius: var(--radius); + transition: background-color 0.2s; +} + +.cart-link:hover { + background-color: var(--bg-secondary); +} + +.cart-badge { + position: absolute; + top: -4px; + right: -4px; + background-color: var(--danger-color); + color: white; + font-size: 0.75rem; + font-weight: 600; + padding: 0.125rem 0.375rem; + border-radius: 10px; + min-width: 20px; + text-align: center; +} + +.mobile-menu-toggle { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--text-primary); + cursor: pointer; + padding: 0.5rem; + border-radius: var(--radius); + transition: background-color 0.2s; + order: 1; + z-index: 1001; + position: relative; +} + +.mobile-menu-toggle:hover { + background-color: var(--bg-secondary); +} + +.menu-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 999; + animation: fadeIn 0.3s ease; + pointer-events: auto; + touch-action: none; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* Стили для ссылок в мобильном меню */ +.nav-mobile .nav-link { + padding: 1rem 1.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + width: 100%; + text-align: left; + display: flex; + align-items: center; + transition: background-color 0.2s, color 0.2s; + position: relative; + z-index: 1001; + pointer-events: auto; + cursor: pointer; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0.1); + user-select: none; + -webkit-user-select: none; + touch-action: manipulation; + color: white; +} + +.nav-mobile .nav-link:hover { + background-color: rgba(255, 255, 255, 0.1); + color: white; +} + +.nav-mobile .nav-link:last-child { + border-bottom: none; +} + +.nav-mobile .nav-link::after { + display: none; +} + +@media (min-width: 769px) { + .mobile-menu-toggle { + display: none; + } + + .nav-mobile { + display: none; + } + + .nav-desktop { + position: static; + width: auto; + height: auto; + background-color: transparent; + box-shadow: none; + flex-direction: row; + align-items: center; + padding: 0; + gap: 2rem; + margin-left: 0; + margin-right: auto; + justify-content: flex-start; + overflow-y: visible; + order: 1; + } + + .header-content .logo { + order: 0; + margin-right: 0; + } + + .header-content .header-actions { + order: 2; + margin-left: auto; + } + + .nav-desktop .nav-link { + padding: 0; + border-bottom: none; + width: auto; + text-align: center; + display: inline-block; + background-color: transparent !important; + background: transparent !important; + color: #ffffff !important; + } + + .nav-desktop .nav-link:hover { + background-color: transparent !important; + background: transparent !important; + color: #ffffff !important; + } + + .nav-desktop .nav-link::after { + display: block; + } + + .menu-overlay { + display: none; + } +} + +@media (max-width: 768px) { + .nav-desktop { + display: none; + } + + .nav-mobile { + display: flex; + } +} + +@media (max-width: 768px) { + .header-content { + flex-wrap: nowrap; + position: relative; + z-index: 1001; + } + + .mobile-menu-toggle { + order: 1; + margin-right: 0.5rem; + position: relative; + z-index: 1002; + } + + .logo { + order: 2; + flex: 1; + display: block; + position: relative; + z-index: 1001; + } + + .logo h1 { + font-size: 1.25rem; + } + + .header-actions { + gap: 0.5rem; + order: 3; + position: relative; + z-index: 1001; + } + + .user-avatar-link, + .admin-link-icon, + .logout-icon-btn { + width: 36px; + height: 36px; + position: relative; + z-index: 1001; + } +} diff --git a/client/src/components/Header.jsx b/client/src/components/Header.jsx new file mode 100644 index 0000000..320d95b --- /dev/null +++ b/client/src/components/Header.jsx @@ -0,0 +1,246 @@ +import { Link, useNavigate } from 'react-router-dom' +import { useState, useEffect } from 'react' +import { authApi } from '../api/auth' +import { cartApi } from '../api/cart' +import { adminOrdersApi } from '../api/admin/orders' +import { isAuthenticated, isAdmin, setAuth, getUser } from '../utils/auth' +import './Header.css' + +function Header() { + const navigate = useNavigate() + const [user, setUser] = useState(null) + const [userIsAdmin, setUserIsAdmin] = useState(false) + const [mobileMenuOpen, setMobileMenuOpen] = useState(false) + const [isMobile, setIsMobile] = useState(window.innerWidth <= 768) + + useEffect(() => { + const handleResize = () => { + setIsMobile(window.innerWidth <= 768) + } + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, []) + + useEffect(() => { + // Загрузить информацию о пользователе + const loadUser = async () => { + const userStr = localStorage.getItem('user') + if (userStr) { + try { + const userData = JSON.parse(userStr) + setUser(userData) + setUserIsAdmin(isAdmin()) + + // Пытаемся загрузить актуальные данные с сервера для получения аватара + if (isAuthenticated()) { + try { + const response = await authApi.getMe() + console.log('Полный ответ getMe:', response) + console.log('Тип response:', typeof response) + console.log('response.success:', response?.success) + console.log('response.data:', response?.data) + console.log('JSON response:', JSON.stringify(response, null, 2)) + + let userData = null; + + // Проверяем разные варианты структуры ответа + if (response && response.success && response.data) { + userData = response.data + console.log('Извлечен userData из response.data:', userData) + } else if (response && response.id) { + // Если ответ - это сам объект пользователя + userData = response + console.log('Используем response напрямую как userData') + } else if (response && typeof response === 'object') { + // Пробуем использовать response напрямую + userData = response + console.log('Используем response как userData (fallback)') + } + + console.log('Итоговый userData:', userData) + console.log('Тип userData:', typeof userData) + console.log('userData === null:', userData === null) + console.log('userData === undefined:', userData === undefined) + console.log('Ключи userData:', userData ? Object.keys(userData) : 'null/undefined') + console.log('Длина ключей:', userData ? Object.keys(userData).length : 0) + + if (userData && typeof userData === 'object' && Object.keys(userData).length > 0) { + console.log('Устанавливаем userData в состояние:', userData) + setUser(userData) + // Обновляем данные в localStorage через setAuth + setAuth(null, userData) + } else { + console.warn('Получен пустой объект пользователя', userData) + console.warn('Тип:', typeof userData, 'Ключи:', userData ? Object.keys(userData) : []) + // Если данные не загрузились, но есть локальные - используем их + const localUser = getUser() + if (localUser && Object.keys(localUser).length > 0) { + console.log('Используем локальные данные пользователя') + setUser(localUser) + } + } + } catch (error) { + console.error('Ошибка загрузки данных пользователя с сервера:', error) + console.error('Детали ошибки:', error.response?.data || error.message) + // Используем локальные данные при ошибке + const localUser = getUser() + if (localUser && Object.keys(localUser).length > 0) { + console.log('Используем локальные данные при ошибке') + setUser(localUser) + } + } + } + } catch (e) { + console.error('Ошибка парсинга пользователя:', e) + } + } else { + setUserIsAdmin(false) + } + } + + loadUser() + + // Обновлять данные пользователя каждые 5 секунд + const interval = setInterval(() => { + if (isAuthenticated()) { + loadUser() + } + }, 5000) + + // Слушаем событие обновления пользователя + const handleUserUpdate = () => { + loadUser() + } + window.addEventListener('userUpdated', handleUserUpdate) + + return () => { + clearInterval(interval) + window.removeEventListener('userUpdated', handleUserUpdate) + } + + return () => { + clearInterval(interval) + } + }, []) + + const handleLogout = () => { + authApi.logout() + setUser(null) + navigate('/') + } + + return ( + <> + {/* Оверлей для затемнения фона */} + {mobileMenuOpen && ( +
setMobileMenuOpen(false)} + /> + )} + +
+
+
+ + + +

+ + + + +
+ {user ? ( + <> + + {user.avatar_url ? ( + {user.first_name { + console.error('Ошибка загрузки аватара:', user.avatar_url) + e.target.style.display = 'none' + e.target.nextSibling?.style?.display === 'none' && (e.target.nextSibling.style.display = 'flex') + }} + /> + ) : null} + {!user.avatar_url && user.first_name && user.last_name ? ( +
+ {user.first_name.charAt(0).toUpperCase()}{user.last_name.charAt(0).toUpperCase()} +
+ ) : !user.avatar_url ? ( +
+ + + + +
+ ) : null} + + + + ) : ( + + Войти + + )} +
+
+
+
+ + ) +} + +export default Header diff --git a/client/src/components/ProductCard.css b/client/src/components/ProductCard.css new file mode 100644 index 0000000..0b388f1 --- /dev/null +++ b/client/src/components/ProductCard.css @@ -0,0 +1,186 @@ +.product-card { + display: flex; + flex-direction: column; + text-decoration: none; + color: inherit; + height: 100%; +} + +.product-image { + position: relative; + width: 100%; + padding-top: 75%; /* 4:3 aspect ratio */ + overflow: hidden; + background-color: var(--bg-secondary); +} + +.product-image img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s; +} + +.product-card:hover .product-image img { + transform: scale(1.05); +} + +.placeholder-image { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + font-size: 0.875rem; +} + +.out-of-stock-badge { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background-color: var(--danger-color); + color: white; + padding: 0.25rem 0.5rem; + border-radius: var(--radius); + font-size: 0.75rem; + font-weight: 600; +} + +.product-info { + padding: 1rem; + display: flex; + flex-direction: column; + flex: 1; +} + +.product-name { + font-size: 1.125rem; + font-weight: 600; + margin: 0 0 0.5rem 0; + color: var(--text-primary); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.product-category { + font-size: 0.875rem; + color: var(--text-secondary); + margin: 0 0 0.75rem 0; +} + +.product-rating { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.review-count { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.product-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: auto; + padding-top: 0.75rem; + border-top: 1px solid var(--border-color); + flex-wrap: wrap; + gap: 0.5rem; +} + +.product-pricing { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.product-price { + font-size: 1.25rem; + font-weight: 700; + color: var(--primary-color); +} + +.product-price.discounted { + color: #e74c3c; +} + +.product-price-original { + font-size: 1rem; + color: var(--text-secondary); + text-decoration: line-through; + font-weight: 400; +} + +.product-discount-badge { + background: #e74c3c; + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; +} + +.discount-badge { + position: absolute; + top: 0.5rem; + left: 0.5rem; + background: #e74c3c; + color: white; + padding: 0.4rem 0.6rem; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 700; + z-index: 10; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); +} + +.product-stock { + font-size: 0.875rem; + color: var(--text-secondary); +} + +@media (max-width: 480px) { + .product-info { + padding: 0.75rem; + } + + .product-name { + font-size: 1rem; + } + + .product-price { + font-size: 1.125rem; + } + + .product-price-original { + font-size: 0.875rem; + } + + .product-footer { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .product-pricing { + width: 100%; + } + + .discount-badge { + font-size: 0.75rem; + padding: 0.3rem 0.5rem; + } +} diff --git a/client/src/components/ProductCard.jsx b/client/src/components/ProductCard.jsx new file mode 100644 index 0000000..a48bd9b --- /dev/null +++ b/client/src/components/ProductCard.jsx @@ -0,0 +1,165 @@ +import { useState, useEffect } from 'react' +import { Link } from 'react-router-dom' +import StarRating from './StarRating' +import './ProductCard.css' + +// Функция для проверки настройки из localStorage +function areRatingsEnabled() { + try { + const stored = localStorage.getItem('shop_settings') + if (stored) { + const settings = JSON.parse(stored) + const value = settings.ratings_enabled + + // Если настройка не найдена, возвращаем true по умолчанию + if (value === null || value === undefined) { + return true + } + + // Преобразуем значение в boolean + if (value === true || value === 'true' || value === 1 || value === '1') { + return true + } + + // Все остальные случаи = отключено + return false + } + } catch (error) { + console.error('Error reading settings from localStorage:', error) + } + + // Если настройки нет в localStorage, возвращаем true по умолчанию + return true +} + +function ProductCard({ product }) { + // Проверяем настройку из localStorage и обновляем при изменении + const [ratingsEnabled, setRatingsEnabled] = useState(areRatingsEnabled()) + + useEffect(() => { + // Обновляем при изменении настроек + const handleSettingsChange = () => { + const newValue = areRatingsEnabled() + console.log('[ProductCard] Settings changed, ratingsEnabled:', newValue) + setRatingsEnabled(newValue) + } + + // Проверяем начальное значение + const initialValue = areRatingsEnabled() + console.log('[ProductCard] Initial ratingsEnabled:', initialValue) + if (initialValue !== ratingsEnabled) { + setRatingsEnabled(initialValue) + } + + window.addEventListener('settingsChanged', handleSettingsChange) + window.addEventListener('settingsUpdated', handleSettingsChange) + + return () => { + window.removeEventListener('settingsChanged', handleSettingsChange) + window.removeEventListener('settingsUpdated', handleSettingsChange) + } + }, []) + + const formatPrice = (price) => { + if (!price && price !== 0) { + console.warn('⚠️ Товар без цены:', product) + return 'Цена не указана' + } + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: 'RUB', + minimumFractionDigits: 0, + }).format(price) + } + + // Рассчитываем цены со скидкой + // product.price - это исходная цена товара + let originalPrice = null + let finalPrice = product.price || 0 + let discountPercent = 0 + + const discountPercentValue = product.discount_percent || 0 + + // Приоритет: если указан compare_at_price, используем его как исходную цену + if (product.compare_at_price && product.compare_at_price > product.price) { + // Если указана старая цена (compare_at_price) + originalPrice = product.compare_at_price + finalPrice = product.price // price уже является ценой со скидкой + discountPercent = Math.round((1 - finalPrice / originalPrice) * 100) + } else if (discountPercentValue > 0 && discountPercentValue <= 100) { + // Если указан процент скидки (и нет compare_at_price) + originalPrice = product.price + finalPrice = product.price * (1 - discountPercentValue / 100) + discountPercent = discountPercentValue + } + + if (!product) { + console.error('❌ ProductCard получил пустой product') + return null + } + + // Убираем избыточное логирование в продакшене + if (import.meta.env.DEV) { + console.log('🛍️ Отображение товара:', product.id, product.name, product.is_active) + } + + return ( + +
+ {product.images && product.images.length > 0 ? ( + {product.name} { + e.target.src = '/placeholder-image.jpg' + }} + /> + ) : ( +
Нет изображения
+ )} + {product.stock_quantity === 0 && ( + Нет в наличии + )} + {discountPercent > 0 && ( + -{discountPercent}% + )} +
+
+

{product.name}

+ {product.category && ( +

{product.category.name}

+ )} + {ratingsEnabled === true && ((product.rating !== null && product.rating !== undefined) || product.review_count) ? ( +
+ + {product.review_count > 0 && ( + ({product.review_count}) + )} +
+ ) : null} +
+
+ {discountPercent > 0 && originalPrice && ( + <> + {formatPrice(originalPrice)} + -{discountPercent}% + + )} + 0 ? 'discounted' : ''}`}> + {formatPrice(finalPrice)} + +
+ {product.stock_quantity > 0 && ( + В наличии: {product.stock_quantity} + )} +
+
+ + ) +} + +export default ProductCard diff --git a/client/src/components/ProtectedRoute.jsx b/client/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..f38187c --- /dev/null +++ b/client/src/components/ProtectedRoute.jsx @@ -0,0 +1,15 @@ +import { Navigate, useLocation } from 'react-router-dom' + +function ProtectedRoute({ children }) { + const location = useLocation() + const token = localStorage.getItem('token') + + if (!token) { + // Сохраняем текущий путь для редиректа после логина + return + } + + return children +} + +export default ProtectedRoute diff --git a/client/src/components/ReviewForm.css b/client/src/components/ReviewForm.css new file mode 100644 index 0000000..638a76a --- /dev/null +++ b/client/src/components/ReviewForm.css @@ -0,0 +1,60 @@ +.review-form-container { + background: #f9f9f9; + padding: 1.5rem; + border-radius: 8px; + margin-bottom: 2rem; +} + +.review-form-container h3 { + margin-top: 0; + margin-bottom: 1rem; + color: #333; +} + +.review-form .form-group { + margin-bottom: 1.5rem; +} + +.review-form .form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #333; +} + +.review-form .form-group input, +.review-form .form-group textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + font-family: inherit; + transition: border-color 0.2s; +} + +.review-form .form-group input:focus, +.review-form .form-group textarea:focus { + outline: none; + border-color: #3498db; +} + +.review-form .form-group textarea { + resize: vertical; + min-height: 120px; +} + +.review-form .form-actions { + display: flex; + gap: 1rem; + margin-top: 1.5rem; +} + +.error-message { + background: #fee; + color: #c33; + padding: 0.75rem; + border-radius: 4px; + margin-bottom: 1rem; + border: 1px solid #fcc; +} diff --git a/client/src/components/ReviewForm.jsx b/client/src/components/ReviewForm.jsx new file mode 100644 index 0000000..e21d450 --- /dev/null +++ b/client/src/components/ReviewForm.jsx @@ -0,0 +1,117 @@ +import { useState, useEffect } from 'react' +import { reviewsApi } from '../api/reviews' +import { getUser } from '../utils/auth' +import StarRating from './StarRating' +import './ReviewForm.css' + +function ReviewForm({ productId, existingReview, onSuccess, onCancel }) { + const [rating, setRating] = useState(existingReview?.rating || 0) + const [title, setTitle] = useState(existingReview?.title || '') + const [comment, setComment] = useState(existingReview?.comment || '') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async (e) => { + e.preventDefault() + + if (rating === 0) { + setError('Пожалуйста, выберите оценку') + return + } + + if (!comment.trim()) { + setError('Пожалуйста, напишите отзыв') + return + } + + try { + setSubmitting(true) + setError(null) + + const reviewData = { + product_id: productId, + rating, + title: title.trim() || null, + comment: comment.trim() + } + + if (existingReview) { + await reviewsApi.update(existingReview.id, reviewData) + } else { + await reviewsApi.create(reviewData) + } + + if (onSuccess) { + onSuccess() + } + + // Сброс формы + if (!existingReview) { + setRating(0) + setTitle('') + setComment('') + } + } catch (err) { + console.error('Ошибка сохранения отзыва:', err) + setError(err.response?.data?.message || 'Не удалось сохранить отзыв') + } finally { + setSubmitting(false) + } + } + + return ( +
+

{existingReview ? 'Редактировать отзыв' : 'Оставить отзыв'}

+ {error &&
{error}
} + +
+
+ + +
+ +
+ + setTitle(e.target.value)} + placeholder="Краткое описание вашего опыта" + maxLength={255} + /> +
+ +
+ +