This commit is contained in:
Zikil 2025-04-01 23:52:37 +07:00
parent 260636af5e
commit 639ac4f5ec
30 changed files with 1189 additions and 605 deletions

BIN
.DS_Store vendored

Binary file not shown.

59
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

View File

@ -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

View File

@ -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
View 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

View File

@ -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

Binary file not shown.

Binary file not shown.

View File

@ -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>

View File

@ -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

Binary file not shown.

View File

@ -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 {

View File

@ -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>
);
}

View File

@ -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>
);
}

Binary file not shown.

View File

@ -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
View 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
};
}
}

View File

@ -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);
// Сбрасываем глобальное состояние

Binary file not shown.

Binary file not shown.

64
init-letsencrypt.sh Executable file
View 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
View 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
View 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;
}
}