прод
This commit is contained in:
parent
260636af5e
commit
639ac4f5ec
59
.dockerignore
Normal file
59
.dockerignore
Normal file
@ -0,0 +1,59 @@
|
||||
# Общие файлы
|
||||
.git
|
||||
.github
|
||||
.gitignore
|
||||
.DS_Store
|
||||
README.md
|
||||
**/test
|
||||
**/.env
|
||||
|
||||
# Файлы и директории для Node.js
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/.next
|
||||
**/dist
|
||||
**/build
|
||||
**/.coverage
|
||||
**/coverage
|
||||
|
||||
# Файлы и директории для Python
|
||||
**/__pycache__
|
||||
**/*.py[cod]
|
||||
**/*.so
|
||||
**/*.egg
|
||||
**/*.egg-info
|
||||
**/dist
|
||||
**/build
|
||||
**/.pytest_cache
|
||||
**/.coverage
|
||||
**/htmlcov
|
||||
**/.tox
|
||||
**/.mypy_cache
|
||||
|
||||
# Виртуальное окружение Python
|
||||
**/venv
|
||||
**/.venv
|
||||
**/env
|
||||
**/.env
|
||||
|
||||
# Файлы базы данных
|
||||
**/*.sqlite
|
||||
**/*.db
|
||||
|
||||
# Логи и временные файлы
|
||||
**/logs
|
||||
**/*.log
|
||||
**/tmp
|
||||
**/*.tmp
|
||||
|
||||
# Файлы IDE
|
||||
**/.idea
|
||||
**/.vscode
|
||||
**/*.swp
|
||||
**/*.swo
|
||||
|
||||
# Загружаемые файлы, кроме необходимых для работы
|
||||
# backend/uploads/*
|
||||
# !backend/uploads/products
|
||||
# backend/uploads/products/*
|
||||
# !backend/uploads/products/.gitkeep
|
||||
17
.env.production
Normal file
17
.env.production
Normal file
@ -0,0 +1,17 @@
|
||||
# Доменное имя сайта без протокола (http/https)
|
||||
DOMAIN_NAME=dressedforsuccess.shop
|
||||
|
||||
# Протокол (http или https)
|
||||
PROTOCOL=https
|
||||
|
||||
# API URL для браузера (клиентская сторона)
|
||||
NEXT_PUBLIC_API_URL=${PROTOCOL}://${DOMAIN_NAME}/api
|
||||
|
||||
# Base URL для статических файлов
|
||||
NEXT_PUBLIC_BASE_URL=${PROTOCOL}://${DOMAIN_NAME}
|
||||
|
||||
# Режим отладки
|
||||
NEXT_PUBLIC_DEBUG=false
|
||||
|
||||
# Мокирование API
|
||||
NEXT_PUBLIC_MOCK_API=false
|
||||
28
Dockerfile.backend
Normal file
28
Dockerfile.backend
Normal file
@ -0,0 +1,28 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Установка зависимостей системы
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Установка зависимостей Python
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Копирование кода приложения
|
||||
COPY backend/ .
|
||||
|
||||
# Копирование .env.docker в .env для использования в контейнере
|
||||
COPY backend/.env.docker ./app/.env
|
||||
|
||||
# Создание директории для загрузок если её нет
|
||||
RUN mkdir -p /app/uploads/products
|
||||
|
||||
# Настройка разрешений для директории загрузок
|
||||
RUN chmod -R 777 /app/uploads
|
||||
|
||||
# Открытие порта
|
||||
EXPOSE 8000
|
||||
|
||||
# Запуск приложения с Uvicorn
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
24
Dockerfile.frontend
Normal file
24
Dockerfile.frontend
Normal file
@ -0,0 +1,24 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Копирование файлов package.json и package-lock.json
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
# Установка зависимостей с флагом --legacy-peer-deps
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
# Копирование исходного кода
|
||||
COPY frontend/ ./
|
||||
|
||||
# Копирование .env.docker в .env.local для использования в контейнере
|
||||
COPY frontend/.env.docker ./.env.local
|
||||
|
||||
# Сборка приложения
|
||||
RUN npm run build
|
||||
|
||||
# Открытие порта
|
||||
EXPOSE 3000
|
||||
|
||||
# Запуск приложения
|
||||
CMD ["npm", "start"]
|
||||
104
README.md
Normal file
104
README.md
Normal file
@ -0,0 +1,104 @@
|
||||
# Dressed for Success - Интернет-магазин одежды
|
||||
|
||||
## О проекте
|
||||
|
||||
Интернет-магазин модной одежды Dressed for Success, созданный с использованием современных технологий:
|
||||
|
||||
- **Фронтенд**: Next.js, React, TypeScript, Tailwind CSS
|
||||
- **Бэкенд**: FastAPI, SQLAlchemy, PostgreSQL
|
||||
- **Развертывание**: Docker, Docker Compose
|
||||
|
||||
## Требования
|
||||
|
||||
Для запуска проекта вам потребуются:
|
||||
|
||||
- Docker
|
||||
- Docker Compose
|
||||
|
||||
## Запуск проекта
|
||||
|
||||
### 1. Клонирование репозитория
|
||||
|
||||
```bash
|
||||
git clone https://github.com/username/dressed_for_success_store.git
|
||||
cd dressed_for_success_store
|
||||
```
|
||||
|
||||
### 2. Запуск через Docker Compose
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Это запустит:
|
||||
- Бэкенд на порту 8000
|
||||
- Фронтенд на порту 3000
|
||||
- PostgreSQL на порту 5432
|
||||
|
||||
### 3. Доступ к приложению
|
||||
|
||||
- **Фронтенд**: http://localhost:3000
|
||||
- **API бэкенда**: http://localhost:8000/api
|
||||
- **Документация API**: http://localhost:8000/docs
|
||||
|
||||
## Остановка проекта
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
Для удаления томов (данных базы данных и загруженных файлов):
|
||||
|
||||
```bash
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
## Разработка
|
||||
|
||||
### Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── backend/ # Бэкенд на FastAPI
|
||||
│ ├── app/ # Код приложения
|
||||
│ ├── uploads/ # Загружаемые файлы
|
||||
│ └── requirements.txt # Зависимости Python
|
||||
│
|
||||
├── frontend/ # Фронтенд на Next.js
|
||||
│ ├── app/ # Код Next.js приложения
|
||||
│ ├── components/ # React компоненты
|
||||
│ ├── lib/ # Библиотеки и утилиты
|
||||
│ └── public/ # Статические файлы
|
||||
│
|
||||
├── docker-compose.yml # Конфигурация Docker Compose
|
||||
├── Dockerfile.backend # Dockerfile для бэкенда
|
||||
└── Dockerfile.frontend # Dockerfile для фронтенда
|
||||
```
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
### Фронтенд
|
||||
|
||||
Основные переменные окружения для фронтенда (файл `.env.local` или `.env.docker`):
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000/api
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:8000
|
||||
NEXT_PUBLIC_DEBUG=false
|
||||
NEXT_PUBLIC_MOCK_API=false
|
||||
```
|
||||
|
||||
### Бэкенд
|
||||
|
||||
Основные переменные окружения для бэкенда (файл `.env` или `.env.docker`):
|
||||
|
||||
```
|
||||
DATABASE_URL=postgresql://postgres:postgres@postgres:5432/shop_db
|
||||
SECRET_KEY=supersecretkey
|
||||
DEBUG=0
|
||||
UPLOAD_DIRECTORY=/app/uploads
|
||||
```
|
||||
|
||||
## Лицензия
|
||||
|
||||
[MIT License](LICENSE)
|
||||
10
backend/.env.docker
Normal file
10
backend/.env.docker
Normal file
@ -0,0 +1,10 @@
|
||||
# Настройки базы данных
|
||||
# DATABASE_URL=postgresql://postgres:postgres@postgres:5432/shop_db
|
||||
|
||||
# Настройки приложения
|
||||
DEBUG=0
|
||||
SECRET_KEY=supersecretkey
|
||||
# UPLOAD_DIRECTORY=/app/uploads
|
||||
|
||||
# Настройки CORS
|
||||
FRONTEND_URL=http://frontend:3000
|
||||
BIN
backend/app.db
BIN
backend/app.db
Binary file not shown.
Binary file not shown.
@ -28,7 +28,8 @@ MAIL_TLS = True
|
||||
MAIL_SSL = False
|
||||
|
||||
# Настройки загрузки файлов
|
||||
UPLOAD_DIRECTORY = os.getenv("UPLOAD_DIRECTORY", "uploads")
|
||||
# UPLOAD_DIRECTORY = os.getenv("UPLOAD_DIRECTORY", "uploads")
|
||||
UPLOAD_DIRECTORY = "/"
|
||||
ALLOWED_UPLOAD_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"}
|
||||
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 МБ
|
||||
|
||||
@ -55,7 +56,7 @@ class Settings(BaseSettings):
|
||||
APP_DESCRIPTION: str = "API для интернет-магазина на FastAPI"
|
||||
|
||||
# Настройки базы данных
|
||||
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5434/shop_db")
|
||||
DATABASE_URL: str = "postgresql://gen_user:F%2BgEEiP3h7yB6d@93.183.81.86:5432/shop_db"
|
||||
|
||||
# Настройки безопасности
|
||||
SECRET_KEY: str = SECRET_KEY
|
||||
|
||||
@ -1,13 +1,108 @@
|
||||
fastapi==0.105.0
|
||||
uvicorn==0.24.0
|
||||
sqlalchemy==2.0.23
|
||||
pydantic==2.5.2
|
||||
|
||||
aiofiles==24.1.0
|
||||
aiogram==3.17.0
|
||||
aiohappyeyeballs==2.4.4
|
||||
aiohttp==3.11.11
|
||||
aiosignal==1.3.2
|
||||
amqp==5.3.1
|
||||
annotated-types==0.6.0
|
||||
anthropic==0.45.2
|
||||
anyio==4.2.0
|
||||
argcomplete==1.10.3
|
||||
asyncpg==0.30.0
|
||||
attrs==25.1.0
|
||||
billiard==4.2.1
|
||||
Brotli==1.1.0
|
||||
certifi==2025.1.31
|
||||
chardet==3.0.4
|
||||
click==8.1.7
|
||||
click-didyoumean==0.3.1
|
||||
click-plugins==1.1.1
|
||||
click-repl==0.3.0
|
||||
compressed_rtf==1.0.6
|
||||
distro==1.9.0
|
||||
docx2txt==0.8
|
||||
ebcdic==1.1.1
|
||||
et_xmlfile==2.0.0
|
||||
extract-msg==0.28.7
|
||||
fastapi==0.109.0
|
||||
frozenlist==1.5.0
|
||||
greenlet==3.0.3
|
||||
groq==0.16.0
|
||||
h11==0.14.0
|
||||
httpcore==1.0.7
|
||||
httpx==0.28.1
|
||||
idna==3.6
|
||||
IMAPClient==2.1.0
|
||||
importlib_metadata==8.6.1
|
||||
inflate64==1.0.1
|
||||
jiter==0.8.2
|
||||
kombu==5.4.2
|
||||
lxml==5.3.0
|
||||
magic-filter==1.0.12
|
||||
multidict==6.1.0
|
||||
multivolumefile==0.2.3
|
||||
numpy==2.2.2
|
||||
olefile==0.47
|
||||
openai==1.61.0
|
||||
openpyxl==3.1.5
|
||||
outcome==1.3.0.post0
|
||||
packaging==24.2
|
||||
pandas==2.2.3
|
||||
patool==3.1.0
|
||||
pdfminer.six==20191110
|
||||
pillow==11.1.0
|
||||
prompt_toolkit==3.0.50
|
||||
propcache==0.2.1
|
||||
psutil==6.1.1
|
||||
psycopg2-binary==2.9.9
|
||||
py7zr==0.22.0
|
||||
pybcj==1.0.3
|
||||
pycron==3.1.2
|
||||
pycryptodome==3.21.0
|
||||
pycryptodomex==3.21.0
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
pydantic_core==2.14.6
|
||||
pyppmd==1.1.1
|
||||
PySocks==1.7.1
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.0.0
|
||||
python-pptx==0.6.23
|
||||
pytz==2025.1
|
||||
pyzstd==0.16.2
|
||||
rarfile==4.2
|
||||
six==1.17.0
|
||||
sniffio==1.3.0
|
||||
sortedcontainers==2.4.0
|
||||
soupsieve==2.6
|
||||
SQLAlchemy==2.0.25
|
||||
starlette==0.35.1
|
||||
taskiq==0.11.10
|
||||
taskiq-dependencies==1.5.6
|
||||
taskiq-redis==1.0.2
|
||||
tenacity==9.0.0
|
||||
texttable==1.7.0
|
||||
tqdm==4.67.1
|
||||
trio==0.28.0
|
||||
trio-websocket==0.11.1
|
||||
typing_extensions==4.12.2
|
||||
tzdata==2025.1
|
||||
tzlocal==5.2
|
||||
urllib3==2.3.0
|
||||
uvicorn==0.34.0
|
||||
vine==5.1.0
|
||||
wcwidth==0.2.13
|
||||
websocket-client==1.8.0
|
||||
wsproto==1.2.0
|
||||
xlrd==1.2.0
|
||||
XlsxWriter==3.2.2
|
||||
yarl==1.18.3
|
||||
zipp==3.21.0
|
||||
python-jose==3.3.0
|
||||
passlib==1.7.4
|
||||
bcrypt<4.0.0
|
||||
python-multipart==0.0.6
|
||||
email-validator==2.1.0
|
||||
psycopg2-binary==2.9.6
|
||||
alembic==1.12.1
|
||||
python-dotenv==1.0.0
|
||||
bcrypt==4.0.1
|
||||
httpx==0.25.2
|
||||
setuptools==75.8.0
|
||||
cachetools
|
||||
|
||||
114
docker-compose.prod.yml
Normal file
114
docker-compose.prod.yml
Normal file
@ -0,0 +1,114 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
container_name: dressed-for-success-backend
|
||||
hostname: backend
|
||||
expose:
|
||||
- "8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/shop_db
|
||||
- DEBUG=0
|
||||
- SECRET_KEY=${SECRET_KEY:-supersecretkey}
|
||||
- UPLOAD_DIRECTORY=/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend/uploads:/app/uploads
|
||||
networks:
|
||||
app_network:
|
||||
aliases:
|
||||
- backend
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:8000/" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
container_name: dressed-for-success-frontend
|
||||
hostname: frontend
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=https://${DOMAIN_NAME}/api
|
||||
- NEXT_PUBLIC_BASE_URL=https://${DOMAIN_NAME}
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
app_network:
|
||||
aliases:
|
||||
- frontend
|
||||
restart: always
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: dressed-for-success-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.prod.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./backend/uploads:/app/uploads:ro
|
||||
- ./certbot/conf:/etc/letsencrypt:ro
|
||||
- ./certbot/www:/var/www/certbot:ro
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
networks:
|
||||
- app_network
|
||||
restart: always
|
||||
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
|
||||
|
||||
certbot:
|
||||
image: certbot/certbot
|
||||
container_name: dressed-for-success-certbot
|
||||
volumes:
|
||||
- ./certbot/conf:/etc/letsencrypt
|
||||
- ./certbot/www:/var/www/certbot
|
||||
networks:
|
||||
- app_network
|
||||
depends_on:
|
||||
- nginx
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: dressed-for-success-db
|
||||
hostname: postgres
|
||||
environment:
|
||||
POSTGRES_DB: shop_db
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
|
||||
expose:
|
||||
- "5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
app_network:
|
||||
aliases:
|
||||
- postgres
|
||||
restart: always
|
||||
|
||||
networks:
|
||||
app_network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
@ -1,77 +1,78 @@
|
||||
|
||||
version: '3.8'
|
||||
services:
|
||||
# backend:
|
||||
# build:
|
||||
# context: ./backend
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: backend
|
||||
# expose:
|
||||
# - "8000"
|
||||
# restart: always
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
container_name: dressed-for-success-backend
|
||||
hostname: backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/shop_db
|
||||
- DEBUG=0
|
||||
- SECRET_KEY=supersecretkey
|
||||
- UPLOAD_DIRECTORY=/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend/uploads:/app/uploads
|
||||
networks:
|
||||
app_network:
|
||||
aliases:
|
||||
- backend
|
||||
dns_search: .
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:8000/" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
# frontend:
|
||||
# build:
|
||||
# context: ./frontend
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: frontend
|
||||
# expose:
|
||||
# - "3000"
|
||||
# environment:
|
||||
# - NODE_ENV=production
|
||||
# restart: always
|
||||
# networks:
|
||||
# - sta_network
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
container_name: dressed-for-success-frontend
|
||||
hostname: frontend
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://0.0.0.0:8000/api
|
||||
- NEXT_PUBLIC_BASE_URL=http://0.0.0.0:8000
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
app_network:
|
||||
aliases:
|
||||
- frontend
|
||||
dns_search: .
|
||||
restart: always
|
||||
|
||||
# nginx:
|
||||
# image: nginx:latest
|
||||
# container_name: nginx
|
||||
# ports:
|
||||
# - "80:80"
|
||||
# volumes:
|
||||
# - ./nginx/sta_test.conf:/etc/nginx/conf.d/sta_test.conf:ro
|
||||
# depends_on:
|
||||
# - backend
|
||||
# - frontend
|
||||
# restart: always
|
||||
# networks:
|
||||
# - sta_network
|
||||
|
||||
|
||||
# backend:
|
||||
# build:
|
||||
# context: ./backend
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: backend
|
||||
# ports:
|
||||
# - "8000:8000"
|
||||
# expose:
|
||||
# - "8000"
|
||||
# environment:
|
||||
# - DEBUG=1
|
||||
# - DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5433/sta
|
||||
# - IS_DOCKER=1
|
||||
# depends_on:
|
||||
# postgres:
|
||||
# condition: service_healthy
|
||||
# redis:
|
||||
# condition: service_healthy
|
||||
# networks:
|
||||
# - sta_network
|
||||
# # dns:
|
||||
# # - 8.8.8.8
|
||||
# # - 8.8.4.4
|
||||
# # dns_opt:
|
||||
# # - ndots:1
|
||||
# # - timeout:3
|
||||
# # - attempts:5
|
||||
# # sysctls:
|
||||
# # - net.ipv4.tcp_keepalive_time=60
|
||||
# # - net.ipv4.tcp_keepalive_intvl=10
|
||||
# # - net.ipv4.tcp_keepalive_probes=6
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: dressed-for-success-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
# - "443:443" # Раскомментируйте для HTTPS
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./backend/uploads:/app/uploads:ro
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
networks:
|
||||
- app_network
|
||||
restart: always
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: dressed-for-success-db
|
||||
hostname: postgres
|
||||
environment:
|
||||
POSTGRES_DB: shop_db
|
||||
POSTGRES_USER: postgres
|
||||
@ -85,45 +86,19 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
app_network:
|
||||
aliases:
|
||||
- postgres
|
||||
dns_search: .
|
||||
restart: always
|
||||
|
||||
|
||||
# redis:
|
||||
# image: redis:7
|
||||
# ports:
|
||||
# - "6380:6379"
|
||||
# healthcheck:
|
||||
# test: ["CMD", "redis-cli", "ping"]
|
||||
# interval: 5s
|
||||
# timeout: 5s
|
||||
# retries: 5
|
||||
# networks:
|
||||
# - sta_network
|
||||
|
||||
# elasticsearch:
|
||||
# image: elasticsearch:8.17.2
|
||||
# environment:
|
||||
# - discovery.type=single-node
|
||||
# - xpack.security.enabled=false
|
||||
# - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
|
||||
# ports:
|
||||
# - "9200:9200"
|
||||
# volumes:
|
||||
# - elasticsearch_data:/usr/share/elasticsearch/data
|
||||
# healthcheck:
|
||||
# test: ["CMD", "curl", "-f", "http://localhost:9200"]
|
||||
# interval: 10s
|
||||
# timeout: 5s
|
||||
# retries: 5
|
||||
# networks:
|
||||
# - sta_network
|
||||
|
||||
# networks:
|
||||
# sta_network:
|
||||
# name: sta_network
|
||||
# driver: bridge
|
||||
networks:
|
||||
app_network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
# elasticsearch_data:
|
||||
# driver: local
|
||||
backend_uploads:
|
||||
driver: local
|
||||
BIN
frontend/.DS_Store
vendored
BIN
frontend/.DS_Store
vendored
Binary file not shown.
BIN
frontend/app/(main)/.DS_Store
vendored
BIN
frontend/app/(main)/.DS_Store
vendored
Binary file not shown.
@ -3,7 +3,7 @@
|
||||
import { Suspense, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { ArrowLeft, ChevronRight, Truck, RotateCcw, Heart, ShoppingBag, Mail, Phone, MapPin } from "lucide-react"
|
||||
import { ArrowLeft, ChevronRight, Truck, RotateCcw, Heart } from "lucide-react"
|
||||
import catalogService, { ProductDetails } from "@/lib/catalog"
|
||||
import { formatPrice } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
@ -77,98 +77,111 @@ export default function ProductPage({ params }: ProductPageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="bg-white min-h-screen">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Навигация */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<Link
|
||||
href="/catalog"
|
||||
className="flex items-center text-sm text-primary/70 hover:text-primary transition-colors group"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2 group-hover:transform group-hover:-translate-x-1 transition-transform" />
|
||||
<span>Вернуться в каталог</span>
|
||||
</Link>
|
||||
|
||||
{/* Хлебные крошки */}
|
||||
<motion.nav
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="flex items-center text-sm text-neutral-500 overflow-x-auto whitespace-nowrap pb-1"
|
||||
>
|
||||
<Link href="/" className="hover:text-primary transition-colors">
|
||||
Главная
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3 mx-1.5 text-neutral-400 flex-shrink-0" />
|
||||
<Link href="/catalog" className="hover:text-primary transition-colors">
|
||||
Каталог
|
||||
</Link>
|
||||
{product.category_name && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3 mx-1.5 text-neutral-400 flex-shrink-0" />
|
||||
<Link
|
||||
href={`/catalog?category_id=${product.category_id}`}
|
||||
className="hover:text-primary transition-colors"
|
||||
>
|
||||
{product.category_name}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<ChevronRight className="h-3 w-3 mx-1.5 text-neutral-400 flex-shrink-0" />
|
||||
<span className="text-primary font-medium truncate max-w-[200px]">{product.name}</span>
|
||||
</motion.nav>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Основной контент товара */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12">
|
||||
{/* Блок изображений */}
|
||||
<motion.div
|
||||
ref={imageRef}
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
animate={imageInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.7 }}
|
||||
className="order-1 md:order-none relative"
|
||||
<main className="bg-tertiary/5 min-h-screen">
|
||||
<div className="container mx-auto px-4 py-8 md:py-12">
|
||||
{/* Навигация */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<Link
|
||||
href="/catalog"
|
||||
className="flex items-center text-sm text-primary/70 hover:text-primary transition-colors group"
|
||||
>
|
||||
<Suspense fallback={<Skeleton className="aspect-square rounded-2xl" />}>
|
||||
<div className="relative rounded-2xl overflow-hidden bg-white shadow-md">
|
||||
<div className="absolute top-4 left-4 z-10 flex gap-2">
|
||||
{/* Используем проверку времени создания для выявления новинок (товары, созданные в течение последних 30 дней) */}
|
||||
{new Date(product.created_at).getTime() > Date.now() - 30 * 24 * 60 * 60 * 1000 && (
|
||||
<Badge className="bg-primary text-white rounded-full px-4 py-1 shadow-md">
|
||||
Новинка
|
||||
</Badge>
|
||||
)}
|
||||
{product.discount_price && (
|
||||
<Badge className="bg-secondary text-white rounded-full px-4 py-1 shadow-md">
|
||||
-{Math.round((1 - product.discount_price / product.price) * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ImageSlider
|
||||
images={product.images || []}
|
||||
productName={product.name}
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
<ArrowLeft className="h-4 w-4 mr-2 group-hover:transform group-hover:-translate-x-1 transition-transform" />
|
||||
<span>Вернуться в каталог</span>
|
||||
</Link>
|
||||
|
||||
{/* Блок информации о товаре */}
|
||||
<motion.div
|
||||
ref={detailsRef}
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
animate={detailsInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.7 }}
|
||||
className="order-2 md:order-none"
|
||||
{/* Хлебные крошки */}
|
||||
<motion.nav
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="flex items-center text-sm text-neutral-500 overflow-x-auto whitespace-nowrap pb-1"
|
||||
>
|
||||
<Link href="/" className="hover:text-primary transition-colors">
|
||||
Главная
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3 mx-1.5 text-neutral-400 flex-shrink-0" />
|
||||
<Link href="/catalog" className="hover:text-primary transition-colors">
|
||||
Каталог
|
||||
</Link>
|
||||
{product.category_name && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3 mx-1.5 text-neutral-400 flex-shrink-0" />
|
||||
<Link
|
||||
href={`/catalog?category_id=${product.category_id}`}
|
||||
className="hover:text-primary transition-colors"
|
||||
>
|
||||
{product.category_name}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<ChevronRight className="h-3 w-3 mx-1.5 text-neutral-400 flex-shrink-0" />
|
||||
<span className="text-primary font-medium truncate max-w-[200px]">{product.name}</span>
|
||||
</motion.nav>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Основной контент товара */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12">
|
||||
{/* Блок изображений */}
|
||||
<motion.div
|
||||
ref={imageRef}
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
animate={imageInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.7 }}
|
||||
className="order-1 md:order-none relative"
|
||||
>
|
||||
<Suspense fallback={<Skeleton className="aspect-square rounded-3xl" />}>
|
||||
<div className="relative rounded-3xl overflow-hidden bg-white shadow-md">
|
||||
<div className="absolute top-4 left-4 z-10 flex gap-2">
|
||||
{/* Используем проверку времени создания для выявления новинок (товары, созданные в течение последних 30 дней) */}
|
||||
{new Date(product.created_at).getTime() > Date.now() - 30 * 24 * 60 * 60 * 1000 && (
|
||||
<Badge className="bg-secondary text-white rounded-full px-4 py-1 shadow-md">
|
||||
Новинка
|
||||
</Badge>
|
||||
)}
|
||||
{product.discount_price && (
|
||||
<Badge variant="destructive" className="rounded-full px-4 py-1 shadow-md">
|
||||
-{Math.round((1 - product.discount_price / product.price) * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Кнопка добавления в избранное */}
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="rounded-full bg-white/40 backdrop-blur-sm text-primary/90 hover:bg-white/60 shadow-sm w-10 h-10"
|
||||
onClick={() => toast.success(`${product.name} добавлен в избранное`)}
|
||||
>
|
||||
<Heart className="h-5 w-5" />
|
||||
<span className="sr-only">Добавить в избранное</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ImageSlider
|
||||
images={product.images || []}
|
||||
productName={product.name}
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
|
||||
{/* Блок информации о товаре */}
|
||||
<motion.div
|
||||
ref={detailsRef}
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
animate={detailsInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.7 }}
|
||||
className="order-2 md:order-none"
|
||||
>
|
||||
<div className="bg-white rounded-3xl shadow-sm p-6 md:p-8">
|
||||
{/* Категория и название */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -218,7 +231,7 @@ export default function ProductPage({ params }: ProductPageProps) {
|
||||
className="mb-8"
|
||||
>
|
||||
<Tabs defaultValue="description" className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-2 bg-neutral-100 h-auto mb-4 rounded-lg p-1">
|
||||
<TabsList className="w-full grid grid-cols-2 bg-tertiary/10 h-auto mb-4 rounded-lg p-1">
|
||||
<TabsTrigger
|
||||
value="description"
|
||||
className="rounded-md data-[state=active]:bg-white data-[state=active]:shadow-sm text-sm py-2.5"
|
||||
@ -237,7 +250,7 @@ export default function ProductPage({ params }: ProductPageProps) {
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="bg-white rounded-lg p-5 border border-neutral-200"
|
||||
className="bg-white rounded-lg p-5 border border-tertiary/10"
|
||||
>
|
||||
<TabsContent value="description" className="text-primary/80 text-sm leading-relaxed mt-0">
|
||||
{product.description ? (
|
||||
@ -273,9 +286,9 @@ export default function ProductPage({ params }: ProductPageProps) {
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-4"
|
||||
>
|
||||
<div className="bg-neutral-50 rounded-lg p-4 border border-neutral-200 transition-all duration-300 hover:shadow-md hover:border-neutral-300 group">
|
||||
<div className="bg-tertiary/10 rounded-lg p-4 border border-tertiary/20 transition-all duration-300 hover:shadow-md group">
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="bg-primary p-2 rounded-full mr-3 text-white">
|
||||
<div className="bg-primary p-2 rounded-full mr-3 text-white group-hover:bg-primary/90 transition-all duration-300">
|
||||
<Truck className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="font-medium text-primary">Доставка</h3>
|
||||
@ -285,9 +298,9 @@ export default function ProductPage({ params }: ProductPageProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-neutral-50 rounded-lg p-4 border border-neutral-200 transition-all duration-300 hover:shadow-md hover:border-neutral-300 group">
|
||||
<div className="bg-tertiary/10 rounded-lg p-4 border border-tertiary/20 transition-all duration-300 hover:shadow-md group">
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="bg-primary p-2 rounded-full mr-3 text-white">
|
||||
<div className="bg-primary p-2 rounded-full mr-3 text-white group-hover:bg-primary/90 transition-all duration-300">
|
||||
<RotateCcw className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="font-medium text-primary">Возврат</h3>
|
||||
@ -297,20 +310,17 @@ export default function ProductPage({ params }: ProductPageProps) {
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
|
||||
</>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductSkeleton() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-tertiary/5">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-8">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
@ -319,43 +329,45 @@ function ProductSkeleton() {
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12">
|
||||
<div className="order-1 md:order-none">
|
||||
<Skeleton className="aspect-square w-full rounded-lg shadow-md" />
|
||||
<Skeleton className="aspect-square w-full rounded-3xl shadow-md" />
|
||||
</div>
|
||||
|
||||
<div className="order-2 md:order-none space-y-6">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-12 w-full sm:w-4/5" />
|
||||
<Skeleton className="h-8 w-40" />
|
||||
</div>
|
||||
|
||||
<Separator className="bg-primary/10" />
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="w-14 h-14 rounded-lg" />
|
||||
))}
|
||||
<div className="order-2 md:order-none">
|
||||
<Skeleton className="rounded-3xl p-6 space-y-6 h-full bg-white/50">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-12 w-full sm:w-4/5" />
|
||||
<Skeleton className="h-8 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-12 w-full rounded-full" />
|
||||
<Skeleton className="h-12 w-full rounded-full" />
|
||||
</div>
|
||||
|
||||
<Separator className="bg-primary/10" />
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-10 w-1/2 rounded-lg" />
|
||||
<Skeleton className="h-10 w-1/2 rounded-lg" />
|
||||
|
||||
<Separator className="bg-primary/10" />
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="w-14 h-14 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-12 w-full rounded-full" />
|
||||
<Skeleton className="h-12 w-full rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-40 w-full rounded-lg border border-neutral-200" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-4">
|
||||
<Skeleton className="h-24 w-full rounded-lg border border-neutral-200" />
|
||||
<Skeleton className="h-24 w-full rounded-lg border border-neutral-200" />
|
||||
</div>
|
||||
|
||||
<Separator className="bg-primary/10" />
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-10 w-1/2 rounded-lg" />
|
||||
<Skeleton className="h-10 w-1/2 rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="h-40 w-full rounded-lg border border-neutral-200" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-4">
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
</div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, ChevronLeft, Package } from "lucide-react";
|
||||
@ -10,7 +10,8 @@ import { formatPrice } from "@/lib/utils";
|
||||
import { OrderItem } from "@/types/order";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export default function CheckoutSuccessPage() {
|
||||
// Компонент с доступом к параметрам URL
|
||||
function CheckoutSuccessContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [orderInfo, setOrderInfo] = useState({
|
||||
@ -164,4 +165,30 @@ export default function CheckoutSuccessPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Компонент загрузки для Suspense
|
||||
function CheckoutSuccessLoading() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-16 md:py-24 max-w-3xl text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 animate-pulse bg-gray-200 rounded-full mb-6">
|
||||
</div>
|
||||
<h1 className="h-8 bg-gray-200 rounded w-3/4 mx-auto mb-4 animate-pulse"></h1>
|
||||
<p className="h-4 bg-gray-200 rounded w-2/3 mx-auto mb-6 animate-pulse"></p>
|
||||
<div className="bg-gray-50 p-6 rounded-lg mb-8">
|
||||
<div className="h-6 bg-gray-200 rounded w-1/2 mb-4 animate-pulse"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3 mb-2 animate-pulse"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4 mb-4 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Основной компонент страницы
|
||||
export default function CheckoutSuccessPage() {
|
||||
return (
|
||||
<Suspense fallback={<CheckoutSuccessLoading />}>
|
||||
<CheckoutSuccessContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
BIN
frontend/app/.DS_Store
vendored
BIN
frontend/app/.DS_Store
vendored
Binary file not shown.
@ -3,7 +3,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { BarChart3, Package, Tag, Users, ShoppingBag } from 'lucide-react';
|
||||
import { fetchDashboardStats, fetchRecentOrders, fetchPopularProducts, Order, Product } from '@/lib/api';
|
||||
import { fetchDashboardStats, fetchRecentOrders, fetchPopularProducts } from '@/lib/admin-api';
|
||||
import { Order } from '@/lib/orders';
|
||||
import { Product } from '@/lib/catalog';
|
||||
|
||||
// Компонент статистической карточки
|
||||
interface StatCardProps {
|
||||
|
||||
@ -1,107 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { useCart } from '@/hooks/useCart';
|
||||
import { CartItem } from '@/components/cart/CartItem';
|
||||
import { CartSummary } from '@/components/cart/CartSummary';
|
||||
import { EmptyCart } from '@/components/cart/EmptyCart';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Trash2, ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function CartPage() {
|
||||
const {
|
||||
cart,
|
||||
loading,
|
||||
error,
|
||||
updateCartItem,
|
||||
removeFromCart,
|
||||
clearCart
|
||||
} = useCart();
|
||||
|
||||
const hasItems = cart.items.length > 0;
|
||||
|
||||
const handleUpdateQuantity = async (id: number, quantity: number) => {
|
||||
await updateCartItem(id, quantity);
|
||||
};
|
||||
|
||||
const handleRemoveItem = async (id: number) => {
|
||||
await removeFromCart(id);
|
||||
};
|
||||
|
||||
const handleClearCart = async () => {
|
||||
await clearCart();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="w-10 h-10 animate-spin text-primary" />
|
||||
<p className="mt-4 text-lg text-gray-500">Загрузка корзины...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px]">
|
||||
<p className="text-lg text-red-500 mb-4">{error}</p>
|
||||
<Button onClick={() => window.location.reload()}>Попробовать снова</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasItems) {
|
||||
return <EmptyCart />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container px-4 py-8 md:py-12 mx-auto">
|
||||
<div className="flex flex-col space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Корзина</h1>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearCart}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>Очистить корзину</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
{cart.items.map((item) => (
|
||||
<CartItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onUpdateQuantity={handleUpdateQuantity}
|
||||
onRemove={handleRemoveItem}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="mt-8">
|
||||
<Link
|
||||
href="/catalog"
|
||||
className="inline-flex items-center text-sm font-medium text-primary hover:text-primary/90"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Продолжить покупки
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:sticky md:top-24 h-fit">
|
||||
<CartSummary
|
||||
itemsCount={cart.items_count}
|
||||
totalAmount={cart.total_amount}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,205 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useCart } from '@/hooks/useCart';
|
||||
import { AddressForm } from '@/components/checkout/AddressForm';
|
||||
import { PaymentMethodSelector } from '@/components/checkout/PaymentMethodSelector';
|
||||
import { CheckoutSummary } from '@/components/checkout/CheckoutSummary';
|
||||
import { OrderCreate, OrderItemCreate, PaymentMethod } from '@/types/order';
|
||||
import orderService from '@/lib/orders';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface AddressValues {
|
||||
address_line1: string;
|
||||
address_line2?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const { cart, loading: cartLoading, error: cartError } = useCart();
|
||||
const [address, setAddress] = useState<AddressValues | null>(null);
|
||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>('credit_card');
|
||||
const [activeTab, setActiveTab] = useState('address');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Перенаправляем неавторизованных пользователей на страницу входа
|
||||
useEffect(() => {
|
||||
if (status === 'unauthenticated') {
|
||||
router.push('/login?redirect=/checkout');
|
||||
}
|
||||
}, [status, router]);
|
||||
|
||||
// Перенаправляем пользователей с пустой корзиной обратно в корзину
|
||||
useEffect(() => {
|
||||
if (!cartLoading && cart && cart.items.length === 0) {
|
||||
toast({
|
||||
title: 'Корзина пуста',
|
||||
description: 'Перед оформлением заказа добавьте товары в корзину',
|
||||
variant: 'destructive',
|
||||
});
|
||||
router.push('/cart');
|
||||
}
|
||||
}, [cart, cartLoading, router, toast]);
|
||||
|
||||
if (status === 'loading' || cartLoading) {
|
||||
return (
|
||||
<div className="flex h-[70vh] items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return null; // Перенаправление происходит в useEffect
|
||||
}
|
||||
|
||||
if (cartError) {
|
||||
return (
|
||||
<div className="container max-w-4xl py-8">
|
||||
<div className="rounded-lg border bg-card p-8 text-center">
|
||||
<h2 className="text-2xl font-bold mb-4">Произошла ошибка</h2>
|
||||
<p className="text-muted-foreground mb-6">{cartError}</p>
|
||||
<Link href="/cart">
|
||||
<Button>Вернуться в корзину</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAddressSubmit = (values: AddressValues) => {
|
||||
setAddress(values);
|
||||
setActiveTab('payment');
|
||||
};
|
||||
|
||||
const handlePlaceOrder = async () => {
|
||||
if (!address || !cart) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Этот метод для демонстрации. В реальном приложении здесь должно быть создание адреса
|
||||
// и получение его ID для использования в заказе
|
||||
const shippingAddressId = 1; // Placeholder ID для демонстрации
|
||||
|
||||
// Создаем массив элементов заказа из товаров в корзине
|
||||
const orderItems: OrderItemCreate[] = cart.items.map(item => ({
|
||||
variant_id: item.variant_id,
|
||||
quantity: item.quantity
|
||||
}));
|
||||
|
||||
const orderData: OrderCreate = {
|
||||
shipping_address_id: shippingAddressId,
|
||||
payment_method: paymentMethod,
|
||||
order_items: orderItems
|
||||
};
|
||||
|
||||
const response = await orderService.createOrder(orderData);
|
||||
|
||||
toast({
|
||||
title: 'Заказ оформлен успешно',
|
||||
description: `Номер заказа: ${response.order?.id || 'не присвоен'}`,
|
||||
});
|
||||
|
||||
// Предполагается, что заказ был создан успешно
|
||||
router.push(`/orders/${response.order?.id || ''}`);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при создании заказа:', error);
|
||||
toast({
|
||||
title: 'Ошибка при оформлении заказа',
|
||||
description: 'Пожалуйста, попробуйте еще раз или обратитесь в службу поддержки',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container max-w-6xl py-8">
|
||||
<div className="flex items-center mb-6">
|
||||
<Button variant="ghost" size="icon" asChild className="mr-2">
|
||||
<Link href="/cart">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold">Оформление заказа</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="md:col-span-2">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="address">Адрес доставки</TabsTrigger>
|
||||
<TabsTrigger value="payment" disabled={!address}>Способ оплаты</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="address" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Адрес доставки</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AddressForm onSubmit={handleAddressSubmit} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="payment" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Способ оплаты</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PaymentMethodSelector
|
||||
value={paymentMethod}
|
||||
onChange={setPaymentMethod}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Separator className="my-6" />
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handlePlaceOrder}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Оформление...
|
||||
</>
|
||||
) : (
|
||||
"Оформить заказ"
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{cart && (
|
||||
<CheckoutSummary
|
||||
cartItems={cart.items}
|
||||
totalAmount={cart.total_amount}
|
||||
onPlaceOrder={handlePlaceOrder}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
frontend/components/.DS_Store
vendored
BIN
frontend/components/.DS_Store
vendored
Binary file not shown.
@ -56,7 +56,7 @@ export function ProductDetails({ product }: ProductDetailsProps) {
|
||||
.map(variant => ({
|
||||
id: variant.id,
|
||||
size_id: variant.size_id,
|
||||
size_name: variant.size?.name || variant.size?.code || '',
|
||||
size_name: variant.size?.name || variant.size?.value || '',
|
||||
stock: variant.stock
|
||||
}))
|
||||
.filter((size, index, self) =>
|
||||
@ -116,7 +116,7 @@ export function ProductDetails({ product }: ProductDetailsProps) {
|
||||
product_id: safeProduct.id,
|
||||
variant_id: selectedSize || 0,
|
||||
quantity: quantity
|
||||
})
|
||||
} as any)
|
||||
.then(() => {
|
||||
setAddedToCart(true);
|
||||
toast.success("Товар добавлен в корзину")
|
||||
@ -138,7 +138,7 @@ export function ProductDetails({ product }: ProductDetailsProps) {
|
||||
product_id: safeProduct.id,
|
||||
variant_id: selectedSize || 0,
|
||||
quantity: quantity
|
||||
})
|
||||
} as any)
|
||||
.then(() => {
|
||||
// Перенаправление на страницу корзины
|
||||
window.location.href = '/cart';
|
||||
@ -155,10 +155,11 @@ export function ProductDetails({ product }: ProductDetailsProps) {
|
||||
{safeProduct.variants && safeProduct.variants.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 flex justify-between items-center">
|
||||
<h3 className="text-sm font-medium text-neutral-900">РАЗМЕР</h3>
|
||||
<h3 className="text-sm font-medium text-primary">РАЗМЕР</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs underline text-neutral-600 hover:text-black transition-colors"
|
||||
className="text-xs underline text-primary/70 hover:text-primary transition-colors"
|
||||
onClick={() => window.location.href = '/size-guide'}
|
||||
>
|
||||
Таблица размеров
|
||||
</button>
|
||||
@ -175,13 +176,13 @@ export function ProductDetails({ product }: ProductDetailsProps) {
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
min-w-[3rem] h-12 px-3 rounded-md text-sm font-medium
|
||||
min-w-[3rem] h-12 px-3 rounded-lg text-sm font-medium
|
||||
transition-all duration-200
|
||||
${selectedSize === sizeOption.id
|
||||
? "bg-black text-white border-transparent"
|
||||
? "bg-primary text-white border-transparent"
|
||||
: isOutOfStock
|
||||
? "bg-neutral-100 text-neutral-400 border-transparent cursor-not-allowed"
|
||||
: "bg-white border border-neutral-200 hover:border-neutral-400"
|
||||
? "bg-tertiary/10 text-neutral-400 border-transparent cursor-not-allowed"
|
||||
: "bg-white border border-tertiary/20 hover:border-primary/30"
|
||||
}
|
||||
`}
|
||||
onClick={() => !isOutOfStock && setSelectedSize(sizeOption.id)}
|
||||
@ -224,11 +225,11 @@ export function ProductDetails({ product }: ProductDetailsProps) {
|
||||
|
||||
{/* Выбор количества */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium mb-3 text-neutral-900">КОЛИЧЕСТВО</h3>
|
||||
<div className="flex items-center h-12 w-32 border border-neutral-200 rounded-md overflow-hidden">
|
||||
<h3 className="text-sm font-medium mb-3 text-primary">КОЛИЧЕСТВО</h3>
|
||||
<div className="flex items-center h-12 w-32 border border-tertiary/20 rounded-lg overflow-hidden bg-white">
|
||||
<button
|
||||
type="button"
|
||||
className="w-10 h-full flex items-center justify-center text-neutral-500 hover:text-black transition-colors"
|
||||
className="w-10 h-full flex items-center justify-center text-primary/70 hover:text-primary transition-colors"
|
||||
onClick={decreaseQuantity}
|
||||
disabled={quantity <= 1}
|
||||
>
|
||||
@ -239,7 +240,7 @@ export function ProductDetails({ product }: ProductDetailsProps) {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-10 h-full flex items-center justify-center text-neutral-500 hover:text-black transition-colors"
|
||||
className="w-10 h-full flex items-center justify-center text-primary/70 hover:text-primary transition-colors"
|
||||
onClick={increaseQuantity}
|
||||
disabled={selectedSize ? quantity >= getStockForCurrentSize() : true}
|
||||
>
|
||||
@ -261,19 +262,22 @@ export function ProductDetails({ product }: ProductDetailsProps) {
|
||||
{addedToCart ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-12 rounded-md text-sm font-medium tracking-wide bg-emerald-600 hover:bg-emerald-700"
|
||||
className="w-full h-12 rounded-lg text-sm font-medium tracking-wide bg-emerald-600 hover:bg-emerald-700"
|
||||
disabled={true}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
ДОБАВЛЕНО В КОРЗИНУ
|
||||
</Button>
|
||||
) : (
|
||||
<AddToCartButton
|
||||
productId={safeProduct.id}
|
||||
variantId={selectedSize || 0}
|
||||
quantity={quantity}
|
||||
disabled={safeProduct.variants && safeProduct.variants.length > 0 && !selectedSize}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-12 rounded-lg text-sm font-medium tracking-wide bg-primary hover:bg-primary/90"
|
||||
onClick={handleAddToCart}
|
||||
disabled={loading || (safeProduct.variants && safeProduct.variants.length > 0 && !selectedSize)}
|
||||
>
|
||||
<ShoppingBag className="h-4 w-4 mr-2" />
|
||||
В КОРЗИНУ
|
||||
</Button>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
@ -281,7 +285,7 @@ export function ProductDetails({ product }: ProductDetailsProps) {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 rounded-md text-sm font-medium tracking-wide border-neutral-200 hover:bg-neutral-50 hover:border-neutral-400 text-neutral-900 hidden sm:flex lg:hidden xl:flex"
|
||||
className="h-12 rounded-lg text-sm font-medium tracking-wide border-tertiary/20 hover:bg-tertiary/5 hover:border-primary/30 text-primary"
|
||||
onClick={handleBuyNow}
|
||||
disabled={loading || (safeProduct.variants && safeProduct.variants.length > 0 && !selectedSize)}
|
||||
>
|
||||
|
||||
195
frontend/lib/admin-api.ts
Normal file
195
frontend/lib/admin-api.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import api from './api';
|
||||
import { Order } from './orders';
|
||||
import { Product } from './catalog';
|
||||
|
||||
/**
|
||||
* Интерфейс статистики для дашборда
|
||||
*/
|
||||
export interface DashboardStats {
|
||||
ordersCount: number;
|
||||
totalSales: number;
|
||||
customersCount: number;
|
||||
productsCount: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс админ-заказа, содержащий дополнительные поля
|
||||
*/
|
||||
export interface AdminOrder {
|
||||
id: number;
|
||||
user_id: number;
|
||||
user_name?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
status: 'pending' | 'paid' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
|
||||
total?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс админ-товара с дополнительными полями для админки
|
||||
*/
|
||||
export interface AdminProduct {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
description?: string;
|
||||
category?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
sales?: number;
|
||||
stock?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс ответа API
|
||||
*/
|
||||
interface ApiResponse<T> {
|
||||
data: T;
|
||||
status: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение статистики для дашборда
|
||||
*/
|
||||
export async function fetchDashboardStats(): Promise<ApiResponse<DashboardStats>> {
|
||||
try {
|
||||
const response = await api.get<DashboardStats>('/admin/dashboard/stats');
|
||||
return {
|
||||
data: response.data,
|
||||
status: response.status
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении статистики дашборда:', error);
|
||||
|
||||
// Возвращаем моковые данные для тестирования
|
||||
return {
|
||||
data: {
|
||||
ordersCount: 1248,
|
||||
totalSales: 2456789,
|
||||
customersCount: 3456,
|
||||
productsCount: 867
|
||||
},
|
||||
status: 200
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс параметров для получения заказов
|
||||
*/
|
||||
export interface OrdersParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
status?: string;
|
||||
sortBy?: string;
|
||||
sortDir?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение последних заказов
|
||||
*/
|
||||
export async function fetchRecentOrders(params?: OrdersParams): Promise<ApiResponse<AdminOrder[]>> {
|
||||
try {
|
||||
const response = await api.get<AdminOrder[]>('/admin/orders/recent', {
|
||||
params: {
|
||||
limit: params?.limit || 5,
|
||||
offset: params?.offset || 0,
|
||||
status: params?.status,
|
||||
sort_by: params?.sortBy,
|
||||
sort_dir: params?.sortDir
|
||||
}
|
||||
});
|
||||
return {
|
||||
data: response.data,
|
||||
status: response.status
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении последних заказов:', error);
|
||||
|
||||
// Возвращаем моковые данные для тестирования
|
||||
return {
|
||||
data: [
|
||||
{ id: 1, user_id: 101, user_name: 'Иван Иванов', created_at: '2023-03-15T14:30:00Z', status: 'delivered', total: 12500 },
|
||||
{ id: 2, user_id: 102, user_name: 'Анна Петрова', created_at: '2023-03-14T10:15:00Z', status: 'shipped', total: 8750 },
|
||||
{ id: 3, user_id: 103, user_name: 'Сергей Сидоров', created_at: '2023-03-13T18:45:00Z', status: 'processing', total: 15200 },
|
||||
{ id: 4, user_id: 104, user_name: 'Елена Смирнова', created_at: '2023-03-12T09:20:00Z', status: 'paid', total: 6300 }
|
||||
],
|
||||
status: 200
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс параметров для получения популярных товаров
|
||||
*/
|
||||
export interface ProductsParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: string;
|
||||
sortDir?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение популярных товаров
|
||||
*/
|
||||
export async function fetchPopularProducts(params?: ProductsParams): Promise<ApiResponse<AdminProduct[]>> {
|
||||
try {
|
||||
const response = await api.get<AdminProduct[]>('/admin/products/popular', {
|
||||
params: {
|
||||
limit: params?.limit || 5,
|
||||
offset: params?.offset || 0,
|
||||
sort_by: params?.sortBy,
|
||||
sort_dir: params?.sortDir
|
||||
}
|
||||
});
|
||||
return {
|
||||
data: response.data,
|
||||
status: response.status
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении популярных товаров:', error);
|
||||
|
||||
// Возвращаем моковые данные для тестирования
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Платье классическое',
|
||||
price: 7999,
|
||||
category: { id: 1, name: 'Платья' },
|
||||
sales: 124,
|
||||
stock: 23
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Блуза белая',
|
||||
price: 4999,
|
||||
category: { id: 2, name: 'Блузы' },
|
||||
sales: 98,
|
||||
stock: 15
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Брюки прямые',
|
||||
price: 5999,
|
||||
category: { id: 3, name: 'Брюки' },
|
||||
sales: 87,
|
||||
stock: 8
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Юбка миди',
|
||||
price: 4599,
|
||||
category: { id: 4, name: 'Юбки' },
|
||||
sales: 76,
|
||||
stock: 12
|
||||
}
|
||||
],
|
||||
status: 200
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,7 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode, useRef } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import api from "./api";
|
||||
import authService from "./auth";
|
||||
import { authApi } from "./auth-api";
|
||||
|
||||
// Расширенный интерфейс пользователя с API
|
||||
export interface User {
|
||||
@ -73,7 +73,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
console.log('Проверка токена в AuthProvider:', token ? token.substring(0, 20) + '...' : 'нет токена');
|
||||
|
||||
if (token) {
|
||||
const userProfile = await authService.getProfile() as UserProfile | null;
|
||||
const userProfile = await authApi.getProfile();
|
||||
|
||||
if (userProfile && 'id' in userProfile) {
|
||||
// Создаем объект пользователя
|
||||
@ -82,7 +82,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
email: userProfile.email || '',
|
||||
first_name: userProfile.first_name || '',
|
||||
last_name: userProfile.last_name || '',
|
||||
is_admin: userProfile.role === 'admin',
|
||||
is_admin: userProfile.is_admin,
|
||||
role: userProfile.role
|
||||
};
|
||||
|
||||
@ -96,8 +96,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
console.log("Пользователь авторизован при загрузке:", userData);
|
||||
} else {
|
||||
console.log("Токен недействителен или неверный формат данных");
|
||||
// Не удаляем токен, чтобы предотвратить циклические перезагрузки
|
||||
// localStorage.removeItem(TOKEN_KEY);
|
||||
setUser(null);
|
||||
|
||||
// Сбрасываем глобальное состояние
|
||||
@ -114,8 +112,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка при проверке аутентификации:", error);
|
||||
// Не удаляем токен, чтобы предотвратить циклические перезагрузки
|
||||
// localStorage.removeItem(TOKEN_KEY);
|
||||
setUser(null);
|
||||
|
||||
// Сбрасываем глобальное состояние
|
||||
@ -135,16 +131,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
console.log("Попытка входа:", email);
|
||||
|
||||
const response = await authService.login({
|
||||
username: email,
|
||||
password: password
|
||||
});
|
||||
const response = await authApi.login(email, password);
|
||||
|
||||
if (response && response.access_token) {
|
||||
// Токен уже сохранен в localStorage внутри authService.login
|
||||
|
||||
if (response && response.success) {
|
||||
// Получение данных пользователя
|
||||
const userProfile = await authService.getProfile() as UserProfile | null;
|
||||
const userProfile = await authApi.getProfile();
|
||||
|
||||
if (userProfile && 'id' in userProfile) {
|
||||
// Создаем объект пользователя
|
||||
@ -153,7 +144,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
email: userProfile.email || '',
|
||||
first_name: userProfile.first_name || '',
|
||||
last_name: userProfile.last_name || '',
|
||||
is_admin: userProfile.role === 'admin',
|
||||
is_admin: userProfile.is_admin,
|
||||
role: userProfile.role
|
||||
};
|
||||
|
||||
@ -190,7 +181,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// Функция выхода
|
||||
const logout = () => {
|
||||
authService.logout(); // Уже удаляет токен из localStorage
|
||||
authApi.logout(); // Удаляет токен из localStorage
|
||||
setUser(null);
|
||||
|
||||
// Сбрасываем глобальное состояние
|
||||
|
||||
BIN
frontend/public/.DS_Store
vendored
BIN
frontend/public/.DS_Store
vendored
Binary file not shown.
BIN
frontend/public/images/.DS_Store
vendored
BIN
frontend/public/images/.DS_Store
vendored
Binary file not shown.
64
init-letsencrypt.sh
Executable file
64
init-letsencrypt.sh
Executable file
@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Скрипт для первоначальной настройки Let's Encrypt
|
||||
# Использование: ./init-letsencrypt.sh yourdomain.com
|
||||
|
||||
if ! [ -x "$(command -v docker-compose)" ]; then
|
||||
echo 'Ошибка: docker-compose не установлен.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
domains=($1)
|
||||
rsa_key_size=4096
|
||||
data_path="./certbot"
|
||||
email="" # Введите email для уведомлений о сертификатах
|
||||
|
||||
if [ -z "$domains" ]; then
|
||||
echo "Ошибка: не указано доменное имя."
|
||||
echo "Использование: $0 yourdomain.com"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "$data_path" ]; then
|
||||
read -p "Каталог certbot уже существует. Удалить и создать заново? (y/N) " decision
|
||||
if [ "$decision" != "y" ] && [ "$decision" != "Y" ]; then
|
||||
exit
|
||||
fi
|
||||
|
||||
rm -rf "$data_path"
|
||||
fi
|
||||
|
||||
# Создаем директории для Let's Encrypt
|
||||
mkdir -p "$data_path/conf/live/$domains"
|
||||
mkdir -p "$data_path/www"
|
||||
|
||||
echo "### Создание временных самоподписанных сертификатов ..."
|
||||
path="/etc/letsencrypt/live/$domains"
|
||||
docker-compose run --rm --entrypoint "\
|
||||
openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\
|
||||
-keyout '$path/privkey.pem' \
|
||||
-out '$path/fullchain.pem' \
|
||||
-subj '/CN=localhost'" certbot
|
||||
|
||||
echo "### Запуск nginx ..."
|
||||
sed -i "s/yourdomain.com/$domains/g" ./nginx/nginx.prod.conf
|
||||
docker-compose up --force-recreate -d nginx
|
||||
|
||||
echo "### Удаление временных сертификатов ..."
|
||||
docker-compose run --rm --entrypoint "\
|
||||
rm -Rf /etc/letsencrypt/live/$domains && \
|
||||
rm -Rf /etc/letsencrypt/archive/$domains && \
|
||||
rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot
|
||||
|
||||
echo "### Запрос сертификата Let's Encrypt ..."
|
||||
#Присоединитесь к общей сети nginx и certbot
|
||||
docker-compose run --rm --entrypoint "\
|
||||
certbot certonly --webroot -w /var/www/certbot \
|
||||
$email_arg \
|
||||
-d $domains \
|
||||
--rsa-key-size $rsa_key_size \
|
||||
--agree-tos \
|
||||
--force-renewal" certbot
|
||||
|
||||
echo "### Перезапуск nginx ..."
|
||||
docker-compose exec nginx nginx -s reload
|
||||
63
nginx/nginx.conf
Normal file
63
nginx/nginx.conf
Normal file
@ -0,0 +1,63 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Размер загружаемых файлов
|
||||
client_max_body_size 20M;
|
||||
|
||||
# Frontend (Next.js)
|
||||
location / {
|
||||
proxy_pass http://frontend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Backend API
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Docs для API
|
||||
location /docs {
|
||||
proxy_pass http://backend:8000/docs;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
location /redoc {
|
||||
proxy_pass http://backend:8000/redoc;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
location /openapi.json {
|
||||
proxy_pass http://backend:8000/openapi.json;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Статические файлы и загруженные изображения
|
||||
location /uploads/ {
|
||||
alias /app/uploads/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
access_log off;
|
||||
}
|
||||
}
|
||||
111
nginx/nginx.prod.conf
Normal file
111
nginx/nginx.prod.conf
Normal file
@ -0,0 +1,111 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name dressedforsuccess.shop www.dressedforsuccess.shop test.dressedforsuccess.shop;
|
||||
|
||||
# Редирект всех HTTP запросов на HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# Для Let's Encrypt
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name yourdomain.com;
|
||||
|
||||
# SSL сертификаты
|
||||
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
|
||||
ssl_trusted_certificate /etc/letsencrypt/live/yourdomain.com/chain.pem;
|
||||
|
||||
# Настройки SSL
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384';
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:50m;
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
|
||||
# Размер загружаемых файлов
|
||||
client_max_body_size 20M;
|
||||
|
||||
# HSTS (предполагается использование сайта только через HTTPS)
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Frontend (Next.js)
|
||||
location / {
|
||||
proxy_pass http://frontend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
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_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Backend API
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
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_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Docs для API
|
||||
location /docs {
|
||||
proxy_pass http://backend:8000/docs;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
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_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
location /redoc {
|
||||
proxy_pass http://backend:8000/redoc;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
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_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
location /openapi.json {
|
||||
proxy_pass http://backend:8000/openapi.json;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
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_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Статические файлы и загруженные изображения
|
||||
location /uploads/ {
|
||||
alias /app/uploads/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
access_log off;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user