Обновлены зависимости в файле requirements.txt, включая FastAPI и Alembic. Добавлены новые настройки для CDEK API в конфигурацию приложения. Обновлены компоненты фронтенда, включая стили и структуру, для улучшения пользовательского интерфейса. Удалены устаревшие файлы и исправлены ошибки в обработке изображений.
@ -3,129 +3,162 @@ description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Вы — эксперт в разработке веб-приложений с использованием **Python, FastAPI, SQLAlchemy, Next.js, React, TypeScript, Tailwind CSS** и **Shadcn UI**.
|
||||
Ты — мой ИИ-ассистент для разработки веб-приложения. Ты эксперт в стеке: Python, FastAPI, SQLAlchemy 2.0, PostgreSQL (бэкенд) и Next.js 15 (App Router), React, TypeScript, Tailwind CSS, Shadcn UI, Radix UI (фронтенд).
|
||||
|
||||
### Ключевые принципы
|
||||
Твоя главная цель: Помогать мне в улучшении существующего кода и написании нового, строго следуя приведенным ниже гайдлайнам, ориентируясь на существующие паттерны в проекте, и используя предоставленный контекст.
|
||||
|
||||
- Пишите лаконичные технические ответы с точными примерами как на Python, так и на TypeScript.
|
||||
- Используйте **функциональные и декларативные паттерны программирования**; избегайте классов, если они не необходимы.
|
||||
- Предпочитайте **итерацию и модуляризацию** вместо дублирования кода.
|
||||
- Используйте описательные имена переменных с вспомогательными глаголами (например, `is_active`, `has_permission`, `isLoading`, `hasError`).
|
||||
- Следуйте правильным **соглашениям об именовании**:
|
||||
- Для Python: используйте нижний регистр с подчеркиваниями (например, `routers/user_routes.py`).
|
||||
- Для TypeScript: используйте нижний регистр с дефисами для директорий (например, `components/auth-wizard`).
|
||||
Основные Директивы
|
||||
Анализ Контекста и Существующего Кода: Прежде чем генерировать новый код (компоненты, функции, эндпоинты), проанализируй существующие аналогичные части проекта (используй @symbol для доступа к файлам/символам). Следуй установленным паттернам, структуре и стилю. Консистентность с текущей кодовой базой — ключ.
|
||||
|
||||
- никогда не запускай сервера бекенд и фронтенд, я сам это делаю!
|
||||
- комментируй что ты делаешь
|
||||
Лаконичность, Оптимизация, Точность: Пиши лаконичный и оптимизированный код. Отвечай технически точно, кратко и по делу. Приводи конкретные, рабочие примеры кода на Python и TypeScript.
|
||||
|
||||
### Структура проекта
|
||||
Стиль Программирования:
|
||||
|
||||
- **Фронтенд**:
|
||||
- **Язык**: TypeScript
|
||||
- **Фреймворк**: Next.js 15 с App Router
|
||||
- **UI Библиотеки**: Tailwind CSS, Shadcn UI, Radix UI
|
||||
- **Директории**:
|
||||
- `frontend/app/`: Основной код с маршрутизацией (App Router)
|
||||
- `frontend/components/`: Компоненты React
|
||||
- `frontend/hooks/`: React хуки
|
||||
- `frontend/lib/`: Служебные функции и утилиты, также работа с api бекенда
|
||||
- `frontend/public/`: Статические файлы
|
||||
- `frontend/styles/`: CSS стили
|
||||
- **Конфигурационные файлы**:
|
||||
- `next.config.mjs`
|
||||
- `tsconfig.json`
|
||||
- `tailwind.config.ts`
|
||||
- `postcss.config.mjs`
|
||||
Функциональный/Декларативный: Предпочитай эти паттерны. Избегай классов, если нет явной необходимости (модели SQLAlchemy, React-компоненты).
|
||||
|
||||
- `frontend/lib/`: Служебные функции и утилиты, также работа с api бекенда
|
||||
api.ts - Основной файл для работы с API. Содержит базовую функцию fetchApi, которая выполняет HTTP-запросы и обрабатывает ответы сервера. Также определяет основные интерфейсы для работы с API.
|
||||
auth.ts - Модуль для аутентификации пользователей. Содержит функции для входа, регистрации, выхода из системы, сброса пароля.
|
||||
users.ts - Модуль для работы с данными пользователей. Содержит функции для получения профиля, обновления данных пользователя, управления адресами.
|
||||
cart.ts - Модуль для работы с корзиной. Позволяет получать состояние корзины, добавлять, обновлять и удалять товары из корзины.
|
||||
catalog.ts - Большой модуль для работы с каталогом товаров на стороне пользователя. Содержит функции для получения товаров, категорий, коллекций и работы с ними.
|
||||
catalog-admin.ts - Модуль для работы с каталогом товаров на стороне администратора. Содержит функции для управления категориями и коллекциями.
|
||||
orders.ts - Модуль для работы с заказами. Содержит функции для получения списка заказов, создания новых заказов, обновления статуса и т.д.
|
||||
analytics.ts - Модуль для работы с аналитикой. Содержит функции для логирования событий, получения отчетов и отслеживания пользовательской активности.
|
||||
utils.ts - Вспомогательные функции для форматирования данных, работы с датами, ценами и другими общими задачами.
|
||||
auth.tsx - React-компоненты для аутентификации (контекст, провайдеры и т.д.).
|
||||
DRY (Don't Repeat Yourself): Пиши модульный код, используй функции/компоненты для переиспользования логики. На фронтенде разделяй сложную логику и UI на небольшие, переиспользуемые компоненты.
|
||||
|
||||
Именование:
|
||||
|
||||
- **Бэкенд**:
|
||||
- **Язык**: Python
|
||||
- **Фреймворк**: FastAPI
|
||||
- **База данных**: PostgreSQL
|
||||
- **ORM**: SQLAlchemy 2.0
|
||||
- **Директории**:
|
||||
- `backend/app/`: Основной код
|
||||
- `models/`: Модели базы данных
|
||||
- `repositories/`: Репозитории для работы с данными
|
||||
- `schemas/`: Pydantic схемы
|
||||
- `services/`: Бизнес-логика
|
||||
- `routers/`: Endpoints API
|
||||
- `backend/uploads/`: Загруженные файлы
|
||||
- `backend/docs/`: документация проекта
|
||||
Переменные: Описательные имена с вспомогательными глаголами (is_active, has_permission, isLoading, itemCount, fetchUsers).
|
||||
|
||||
- **Конфигурационные файлы**:
|
||||
- `alembic.ini`: Конфигурация миграций
|
||||
- `.env`: Переменные окружения
|
||||
Файлы/Директории: Python: snake_case (routers/user_routes.py). TypeScript: kebab-case (components/auth-wizard/, lib/api-client.ts).
|
||||
|
||||
Комментирование: Кратко комментируй генерируемый код, объясняя что и почему делается, особенно для нетривиальной логики.
|
||||
|
||||
### Стиль кода и структура
|
||||
Важные Ограничения:
|
||||
|
||||
**Бэкенд (Python/FastAPI)**:
|
||||
НЕ ЗАПУСКАЙ СЕРВЕРЫ: Никогда не предлагай и не пытайся запускать frontend или backend серверы. Я делаю это сам.
|
||||
|
||||
- Используйте `def` для чистых функций и `async def` для асинхронных операций.
|
||||
- **Типизация**: Используйте аннотации типов для всех функций. Предпочитайте Pydantic-модели для валидации данных.
|
||||
- **Структура файлов**: Следуйте чёткому разделению с директориями для маршрутов, утилит, статического контента и моделей/схем.
|
||||
- **RORO паттерн**: Используйте паттерн «Получить объект, вернуть объект» для структурирования функций.
|
||||
- **Обработка ошибок**:
|
||||
- Обрабатывайте ошибки в начале функций с ранним возвратом.
|
||||
- Используйте защитные условия и избегайте глубоко вложенных условий.
|
||||
- Реализуйте правильное логирование и пользовательские типы ошибок.
|
||||
Контекст Проекта
|
||||
Используй эту информацию как основу для генерации и модификации кода.
|
||||
|
||||
**Фронтенд (TypeScript/React/Next.js)**:
|
||||
1. Структура Проекта:
|
||||
|
||||
- **TypeScript**: Используйте TypeScript для всего кода. Предпочитайте интерфейсы типам. Избегайте перечислений; используйте объекты вместо них.
|
||||
- **Компоненты**: Пишите все компоненты как функциональные с правильной типизацией TypeScript. Создавай компоненты где они нужны
|
||||
- **UI и стилизация**: Реализуйте отзывчивый дизайн с использованием Tailwind CSS и Shadcn UI, начиная с мобильной версии.
|
||||
- **Рендеринг**: Используйте серверные и клиентские компоненты Next.js правильно:
|
||||
- Предпочитайте серверные компоненты, где это возможно
|
||||
- Используйте директиву `"use client"` только для компонентов, требующих клиентских возможностей
|
||||
- Оборачивайте клиентские компоненты в `Suspense` для улучшения производительности
|
||||
.
|
||||
├── backend/
|
||||
│ ├── alembic/
|
||||
│ ├── app/
|
||||
│ │ ├── models/ # SQLAlchemy Модели
|
||||
│ │ ├── repositories/ # Логика доступа к данным
|
||||
│ │ ├── routers/ # FastAPI Роутеры (Эндпоинты)
|
||||
│ │ ├── schemas/ # Pydantic Схемы
|
||||
│ │ ├── services/ # Бизнес-логика
|
||||
│ │ ├── config.py
|
||||
│ │ ├── core.py
|
||||
│ │ └── main.py
|
||||
│ ├── docs/ # Документация (.md)
|
||||
│ ├── uploads/ # Загруженные файлы (статичные)
|
||||
│ ├── .env
|
||||
│ ├── alembic.ini
|
||||
│ └── requirements.txt
|
||||
├── frontend/
|
||||
│ ├── app/ # Next.js App Router (основной код, маршруты)
|
||||
│ │ ├── (main)/ # Группа маршрутов
|
||||
│ │ ├── account/
|
||||
│ │ ├── admin/
|
||||
│ │ ├── layout.tsx
|
||||
│ │ └── globals.css
|
||||
│ ├── components/ # React Компоненты (UI, layout, etc.)
|
||||
│ │ ├── admin/
|
||||
│ │ ├── cart/
|
||||
│ │ ├── ui/ # Shadcn UI компоненты
|
||||
│ │ └── ...
|
||||
│ ├── hooks/ # React Хуки (use...)
|
||||
│ ├── lib/ # Утилиты, API клиенты, константы
|
||||
│ │ ├── api.ts # Базовый API клиент (fetchApi)
|
||||
│ │ ├── auth.ts # Функции API для аутентификации
|
||||
│ │ ├── ... (и другие модули API: users, cart, catalog, etc.)
|
||||
│ │ ├── utils.ts # Общие утилиты
|
||||
│ │ └── auth.tsx # React контекст/провайдер для Auth
|
||||
│ ├── public/ # Статические ассеты
|
||||
│ ├── styles/ # Глобальные стили
|
||||
│ ├── types/ # Глобальные TypeScript типы/интерфейсы
|
||||
│ ├── next.config.mjs
|
||||
│ ├── tsconfig.json
|
||||
│ ├── tailwind.config.ts
|
||||
│ ├── postcss.config.mjs
|
||||
│ └── package.json
|
||||
├── docker-compose.yml
|
||||
└── package.json
|
||||
Use code with caution.
|
||||
2. Спецификация Backend API (OpenAPI 3.1.0):
|
||||
|
||||
### Оптимизация производительности
|
||||
Полная спецификация OpenAPI JSON должна быть доступна Cursor как контекст.
|
||||
|
||||
**Бэкенд**:
|
||||
Ключевые эндпоинты: Auth, Users, Addresses, Catalog (Categories, Collections, Products, Variants, Images, Sizes), Cart, Orders, Reviews, Content, Analytics.
|
||||
|
||||
- **Асинхронные операции**: Минимизируйте блокирующие операции ввода-вывода, используя асинхронные функции.
|
||||
- **Кэширование**: Внедряйте стратегии кэширования для часто используемых данных.
|
||||
- **Ленивая загрузка**: Используйте технику ленивой загрузки для больших наборов данных и ответов API.
|
||||
Аутентификация: OAuth2 Password Bearer.
|
||||
|
||||
**Фронтенд**:
|
||||
3. Документация Frontend API Client (frontend/lib/):
|
||||
|
||||
- **Компоненты React**: Предпочитайте серверный рендеринг и оптимизируйте клиентский рендеринг.
|
||||
- **Изображения**: Оптимизируйте загрузку изображений с помощью компонента Next Image.
|
||||
- **Метрики**: Оптимизируйте Core Web Vitals (LCP, CLS, FID).
|
||||
Документация по модулям auth.ts, users.ts и т.д. должна быть доступна Cursor как контекст.
|
||||
|
||||
### Проектные соглашения
|
||||
Содержит функции-обертки для вызова API бэкенда.
|
||||
|
||||
**Бэкенд**:
|
||||
Стиль Кода и Паттерны
|
||||
Бэкенд (Python / FastAPI / SQLAlchemy):
|
||||
|
||||
1. Следуйте **принципам проектирования RESTful API**.
|
||||
2. Используйте **систему внедрения зависимостей FastAPI** для управления состоянием и общими ресурсами.
|
||||
3. Используйте **SQLAlchemy 2.0** для функций ORM.
|
||||
4. Обеспечьте правильную настройку **CORS** для локальной разработки.
|
||||
5. Реализуйте надлежащую аутентификацию и авторизацию для защиты API.
|
||||
Функции: def / async def.
|
||||
|
||||
**Фронтенд**:
|
||||
Типизация: Обязательные аннотации типов. Pydantic (schemas/) для API.
|
||||
|
||||
1. Следуйте рекомендациям Next.js по использованию серверных и клиентских компонентов.
|
||||
2. Ограничивайте директиву `"use client"` небольшими, специфичными компонентами.
|
||||
3. Используйте хуки React эффективно, избегая ненужных рендеров.
|
||||
4. Реализуйте интернационализацию для поддержки русского языка.
|
||||
Структура: routers, services, repositories, models, schemas.
|
||||
|
||||
### Тестирование и развертывание
|
||||
RORO Паттерн: Принимай/возвращай объекты (схемы/модели).
|
||||
|
||||
Обработка Ошибок:
|
||||
|
||||
Ранний возврат / guard clauses. Избегай вложенности if.
|
||||
|
||||
Используй стандартные HTTPException FastAPI для ошибок API.
|
||||
|
||||
В сервисах/репозиториях используй try...except для отлова ожидаемых runtime-ошибок (e.g., NoResultFound от SQLAlchemy). Логируй ошибки.
|
||||
|
||||
Зависимости: FastAPI Dependency Injection (Depends()).
|
||||
|
||||
ORM: SQLAlchemy 2.0 (async Session).
|
||||
|
||||
Фронтенд (TypeScript / React / Next.js):
|
||||
|
||||
TypeScript: Строгая типизация. interface для объектов, type для остального. Избегай enum, используй as const или union types. unknown лучше any.
|
||||
|
||||
Компоненты React:
|
||||
|
||||
Функциональные компоненты с хуками. Строгая типизация props.
|
||||
|
||||
Компонентный подход: Активно разбивай UI и логику на меньшие, переиспользуемые компоненты, особенно при работе с Shadcn UI. Создавай компоненты рядом с местом использования или в components/, если они общие.
|
||||
|
||||
Next.js App Router:
|
||||
|
||||
Приоритет Серверным Компонентам (RSC).
|
||||
|
||||
"use client": Только для интерактива/хуков/browser API. Минимизируй их размер.
|
||||
|
||||
Suspense: Используй для асинхронной загрузки и улучшения UX.
|
||||
|
||||
Стилизация: Tailwind CSS first. Shadcn UI / Radix UI для примитивов. Mobile-first.
|
||||
|
||||
API Взаимодействие: Используй функции из frontend/lib/*.ts. Обрабатывай состояния isLoading / error.
|
||||
|
||||
Обработка Ошибок: В местах вызова API и в UI используй try...catch. Предоставляй обратную связь пользователю (например, через react-hot-toast или аналоги) и логируй ошибки в консоль или систему мониторинга.
|
||||
|
||||
Оптимизация Производительности
|
||||
Бэкенд: Асинхронность, оптимизация запросов SQLAlchemy, пагинация. Рассмотри кэширование, если потребуется.
|
||||
|
||||
Фронтенд: Динамические импорты (next/dynamic), next/image, минимизация клиентского JS, Core Web Vitals.
|
||||
|
||||
Проектные Соглашения
|
||||
API: RESTful.
|
||||
|
||||
CORS: Настроен для localhost.
|
||||
|
||||
Аутентификация/Авторизация: Реализована (OAuth2).
|
||||
|
||||
Тестирование и Развертывание
|
||||
Пиши код, который легко тестировать (чистые функции, DI), но не генерируй тесты по умолчанию.
|
||||
|
||||
Используй Docker и docker-compose.yml.
|
||||
|
||||
Обеспечивай валидацию и санитизацию данных.
|
||||
|
||||
- Реализуйте **юнит-тесты** как для фронтенда, так и для бэкенда.
|
||||
- Используйте **Docker** и **docker compose** для оркестрации в средах разработки и производства.
|
||||
- Обеспечьте надлежащую валидацию входных данных, санитизацию и обработку ошибок во всем приложении.
|
||||
|
||||
BIN
Logo DRESSED FOR SUCCESS/.DS_Store
vendored
42
Logo DRESSED FOR SUCCESS/logo_horizontal.svg
Normal file
@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Creator: CorelDRAW 2021.5 -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100.188mm" height="15.1954mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
|
||||
viewBox="0 0 36141.04 5481.48"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
.fil0 {fill:#2B2B2A;fill-rule:nonzero}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Слой_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<g id="_105553227186208">
|
||||
<g>
|
||||
<path class="fil0" d="M4500.43 2322.22c-1244.24,-395.04 -1823.79,-583.31 -1812.61,-1255.24 3.61,-158.76 81.13,-313.84 228.74,-454.13 -88.49,-33.22 -184.59,-62.77 -273.07,-81.2 -281.8,236.03 -362,503.22 -361.82,712.52 0.72,786.61 601.74,1114.99 1812.79,1476.77 155.04,48.01 295.3,92.31 428.15,136.61 7.36,-144 11.15,-343.35 -22.19,-535.33zm2067.5 1513.71l0 0c-103.28,-655.56 -520.43,-959.91 -1214.66,-1225.73 -33.15,-686.69 -321.05,-1310.61 -778.93,-1764.74 -169.83,-169.83 -361.82,-313.8 -572.19,-431.94 -128.42,-82.39 -332.42,-162.44 -350.78,-169.83 199.38,-47.98 417.19,-75.36 671.83,-81.24 1066.9,-24.6 1546.57,683.08 1602.45,1026.36l446.7 7.4c-36.94,-188.3 -395.15,-1196.19 -2111.87,-1196.19 -317.44,0 -627.49,55.41 -904.39,147.68 -221.56,-59.09 -443.09,-92.28 -679.44,-92.28 -671.87,0 -2676.64,3.64 -2676.64,3.64l0 114.46c232.71,0 380.36,55.37 476.28,136.61 173.58,140.29 173.58,365.49 173.58,494.71 0,966.37 0,2899.1 0,3865.47 0,151.36 0,450.41 -302.84,575.95 -84.92,36.9 -199.38,62.73 -347.03,62.73l0 118.14c0,0 1625.25,0 2626.17,0 409.11,0 733.51,-88.6 733.51,-88.6 358.5,132.39 1026.03,216.73 1812.79,51.69 890.32,-186.82 1507.79,-841.16 1395.46,-1554.29zm-815.91 1111.27l0 0c-383.96,287.94 -982.49,428.48 -1495.17,376.53 -1078.19,-109.19 -1658.97,-753.86 -1794.39,-1270l-465.09 0c85.21,374.98 388.47,826.8 978.34,1126.03 0,0 -255.58,84.92 -646.11,84.92 -295.37,0 -1181.47,0 -1181.47,0l0 -5043.15 1196.26 0c443.12,0 844.84,75.21 1358.7,417.19 625.51,416.25 1071.38,1037.47 1140.75,1794.29 110.35,1203.73 -554.74,2072.88 -1162.93,2440.36 155.04,66.45 313.66,114.42 417.15,140.29 642.47,-406.15 1094.39,-1128.41 1218.27,-1853.37 557.55,262.14 815.91,539.04 856.63,945.16 33.15,350.74 -140.25,635 -420.94,841.77z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="fil0" d="M12099.09 1010.7c0,205.29 -39.93,381.94 -119.58,530.17 -79.61,148.26 -190.39,262.36 -332.6,342.26 -142.2,80.19 -307.24,120.12 -495.18,120.12l-727.45 0 0 -1945.43 643.4 0c329.31,0 583.63,82.68 762.77,248 179.1,165.07 268.64,400.12 268.64,704.87zm-265.07 0l0 0c0,-241.11 -66.12,-425.16 -198.11,-551.67 -132.28,-126.47 -323.51,-189.85 -573.75,-189.85l-374.19 0 0 1523.01 433.46 0c144.4,0 270.04,-31.96 376.14,-95.34 106.34,-63.67 189.02,-153.74 248,-270.59 58.98,-116.84 88.45,-255.47 88.45,-415.56z"/>
|
||||
<path class="fil0" d="M13295.57 1195.61l0 807.64 -263.7 0 0 -1945.43 915.4 0c219.07,0 388.26,49.06 507.3,147.14 119.33,98.12 178.85,234.51 178.85,409.5 0,144.37 -42.17,265.9 -126.22,364.27 -84.3,98.66 -200.6,160.67 -348.83,186.57l552.46 837.95 -303.92 0 -505.39 -807.64 -605.96 0zm1072.75 -578.4l0 0c0,-113.27 -38.56,-199.52 -115.18,-259.04 -76.87,-59.23 -187.65,-88.99 -332.05,-88.99l-625.51 0 0 717.82 636.55 0c138.88,0 246.34,-32.21 322.42,-97.25 75.75,-64.75 113.78,-155.69 113.78,-272.53z"/>
|
||||
<polygon class="fil0" points="17171.86,1788.04 17171.86,2003.26 15639.47,2003.26 15639.47,57.83 17115.37,57.83 17115.37,273.33 15903.2,273.33 15903.2,897.47 17032.44,897.47 17032.44,1109.9 15903.2,1109.9 15903.2,1788.04 "/>
|
||||
<path class="fil0" d="M19615.25 1466.2c0,179.68 -70.02,318.56 -210.52,416.94 -140.29,98.66 -338.11,147.68 -593.01,147.68 -473.97,0 -748.7,-164.49 -824.2,-494.06l255.43 -51.26c29.51,117.13 92.06,202.8 187.65,257.64 95.92,54.83 225.96,82.14 390.75,82.14 170.3,0 301.75,-29.22 394.32,-87.62 92.6,-58.44 138.63,-144.4 138.63,-257.67 0,-63.38 -14.36,-114.89 -43.29,-154.57 -29.18,-39.68 -69.69,-72.22 -122.32,-98.12 -52.38,-25.61 -114.93,-47.36 -187.65,-64.75 -72.76,-17.64 -153.24,-36.36 -241.69,-56.74 -102.23,-22.87 -187.65,-45.49 -256.26,-67.53 -68.32,-22.04 -124.81,-45.45 -168.9,-69.69 -44.37,-24.53 -82.14,-50.43 -113.27,-77.99 -46.03,-42.46 -81.27,-91.77 -105.8,-147.72 -24.24,-56.2 -36.4,-120.67 -36.4,-193.42 0,-166.44 63.67,-294.86 191.26,-385.23 127.3,-90.11 310.01,-135.31 547.27,-135.31 220.98,0 389.92,33.91 506.72,101.4 116.84,67.78 198.98,182.96 245.8,346.12l-259.58 45.45c-28.35,-103.06 -82.93,-178.02 -162.83,-224.3 -80.19,-46.57 -190.97,-69.73 -332.88,-69.73 -155.4,0 -274.16,25.65 -356.01,77.16 -82.14,51.55 -122.9,128.42 -122.9,230.65 0,59.81 15.69,109.41 47.4,148.51 31.96,39.14 77.7,71.93 137.51,98.66 56.2,26.73 175.24,60.35 357.67,100.86 74.38,17.35 148.23,35.53 220.7,54.54 72.76,18.76 141.37,42.46 205.87,71.1 37.73,15.44 73.84,34.74 108.29,57.32 34.7,22.33 64.75,48.48 90.65,77.99 34.7,38.56 62.26,84.05 82.1,136.68 19.59,52.34 29.51,114.06 29.51,184.88z"/>
|
||||
<path class="fil0" d="M22066.61 1466.2c0,179.68 -69.98,318.56 -210.52,416.94 -140.25,98.66 -338.11,147.68 -593.01,147.68 -473.97,0 -748.7,-164.49 -824.2,-494.06l255.43 -51.26c29.51,117.13 92.06,202.8 187.65,257.64 95.92,54.83 225.96,82.14 390.75,82.14 170.3,0 301.75,-29.22 394.35,-87.62 92.56,-58.44 138.59,-144.4 138.59,-257.67 0,-63.38 -14.32,-114.89 -43.25,-154.57 -29.22,-39.68 -69.73,-72.22 -122.36,-98.12 -52.34,-25.61 -114.89,-47.36 -187.65,-64.75 -72.76,-17.64 -153.2,-36.36 -241.65,-56.74 -102.23,-22.87 -187.65,-45.49 -256.3,-67.53 -68.32,-22.04 -124.81,-45.45 -168.9,-69.69 -44.37,-24.53 -82.14,-50.43 -113.27,-77.99 -45.99,-42.46 -81.27,-91.77 -105.8,-147.72 -24.24,-56.2 -36.36,-120.67 -36.36,-193.42 0,-166.44 63.63,-294.86 191.22,-385.23 127.3,-90.11 310.01,-135.31 547.27,-135.31 220.98,0 389.92,33.91 506.76,101.4 116.81,67.78 198.94,182.96 245.77,346.12l-259.55 45.45c-28.39,-103.06 -82.97,-178.02 -162.87,-224.3 -80.19,-46.57 -190.97,-69.73 -332.88,-69.73 -155.4,0 -274.16,25.65 -356.01,77.16 -82.1,51.55 -122.9,128.42 -122.9,230.65 0,59.81 15.69,109.41 47.4,148.51 31.96,39.14 77.7,71.93 137.51,98.66 56.2,26.73 175.24,60.35 357.67,100.86 74.38,17.35 148.26,35.53 220.73,54.54 72.72,18.76 141.34,42.46 205.83,71.1 37.73,15.44 73.84,34.74 108.29,57.32 34.7,22.33 64.75,48.48 90.65,77.99 34.74,38.56 62.3,84.05 82.14,136.68 19.55,52.34 29.47,114.06 29.47,184.88z"/>
|
||||
<polygon class="fil0" points="24526.52,1788.04 24526.52,2003.26 22994.13,2003.26 22994.13,57.83 24470.03,57.83 24470.03,273.33 23257.86,273.33 23257.86,897.47 24387.1,897.47 24387.1,1109.9 23257.86,1109.9 23257.86,1788.04 "/>
|
||||
<path class="fil0" d="M27120.62 1010.7c0,205.29 -39.97,381.94 -119.58,530.17 -79.65,148.26 -190.43,262.36 -332.6,342.26 -142.2,80.19 -307.27,120.12 -495.18,120.12l-727.49 0 0 -1945.43 643.44 0c329.28,0 583.63,82.68 762.73,248 179.1,165.07 268.67,400.12 268.67,704.87zm-265.1 0l0 0c0,-241.11 -66.12,-425.16 -198.11,-551.67 -132.28,-126.47 -323.51,-189.85 -573.71,-189.85l-374.22 0 0 1523.01 433.46 0c144.4,0 270.04,-31.96 376.14,-95.34 106.38,-63.67 189.06,-153.74 248,-270.59 58.98,-116.84 88.45,-255.47 88.45,-415.56z"/>
|
||||
<polygon class="fil0" points="29668.16,1214.91 29668.16,2003.26 29404.46,2003.26 29404.46,57.83 30786.39,57.83 30786.39,273.33 29668.16,273.33 29668.16,996.92 30753.31,996.92 30753.31,1214.91 "/>
|
||||
<path class="fil0" d="M33529.3 1021.74c0,208.03 -39.43,387.43 -118.21,538.43 -78.53,151 -190.14,267.01 -334.83,348.58 -144.37,81.56 -315.5,122.07 -513.61,122.07 -198.69,0 -370.08,-40.22 -514.19,-121.24 -144.15,-81.02 -254.89,-197.03 -332.88,-348.04 -77.7,-151 -116.55,-330.94 -116.55,-539.8 0,-208.03 38.27,-386.06 115.18,-533.78 76.87,-147.68 187.36,-261.21 331.48,-340.32 144.11,-79.07 317.16,-118.75 519.71,-118.75 198.94,0 370.36,39.14 514.48,117.38 144.11,78.28 254.89,191.26 332.6,338.94 77.99,147.68 116.84,326.54 116.84,536.52zm-269.21 0l0 0c0,-242.23 -60.64,-432.37 -181.59,-570.43 -120.99,-138.05 -292.12,-206.92 -513.11,-206.92 -222.64,0 -394.86,68.03 -516.39,204.17 -121.24,136.39 -182.13,327.37 -182.13,573.17 0,162.01 27.52,302.55 82.93,421.59 55.12,119.33 134.73,211.35 238.91,276.39 103.89,64.75 228.7,97.29 373.94,97.29 224.56,0 397.06,-69.19 517.22,-207.78 120.12,-138.59 180.22,-334.54 180.22,-587.49z"/>
|
||||
<path class="fil0" d="M34725.78 1195.61l0 807.64 -263.73 0 0 -1945.43 915.4 0c219.07,0 388.29,49.06 507.33,147.14 119.29,98.12 178.82,234.51 178.82,409.5 0,144.37 -42.17,265.9 -126.18,364.27 -84.34,98.66 -200.6,160.67 -348.86,186.57l552.5 837.95 -303.95 0 -505.39 -807.64 -605.92 0zm1072.75 -578.4l0 0c0,-113.27 -38.6,-199.52 -115.18,-259.04 -76.91,-59.23 -187.65,-88.99 -332.05,-88.99l-625.51 0 0 717.82 636.51 0c138.88,0 246.34,-32.21 322.42,-97.25 75.75,-64.75 113.81,-155.69 113.81,-272.53z"/>
|
||||
<path class="fil0" d="M11948.38 4859.43c0,179.68 -69.98,318.56 -210.52,416.94 -140.25,98.62 -338.11,147.68 -593.01,147.68 -473.97,0 -748.7,-164.49 -824.2,-494.06l255.47 -51.26c29.47,117.09 92.02,202.8 187.65,257.64 95.88,54.83 225.93,82.14 390.71,82.14 170.3,0 301.75,-29.22 394.35,-87.66 92.56,-58.4 138.59,-144.37 138.59,-257.64 0,-63.38 -14.32,-114.89 -43.25,-154.57 -29.22,-39.68 -69.73,-72.22 -122.36,-98.12 -52.34,-25.61 -114.89,-47.4 -187.65,-64.75 -72.76,-17.64 -153.2,-36.36 -241.65,-56.78 -102.23,-22.87 -187.65,-45.45 -256.26,-67.49 -68.36,-22.04 -124.85,-45.45 -168.93,-69.73 -44.37,-24.53 -82.1,-50.43 -113.27,-77.95 -45.99,-42.46 -81.27,-91.77 -105.8,-147.72 -24.24,-56.2 -36.36,-120.7 -36.36,-193.42 0,-166.44 63.63,-294.86 191.22,-385.23 127.3,-90.11 310.01,-135.31 547.27,-135.31 220.98,0 389.92,33.87 506.76,101.4 116.84,67.78 198.94,182.96 245.8,346.09l-259.58 45.49c-28.39,-103.06 -82.97,-178.02 -162.87,-224.3 -80.19,-46.57 -190.97,-69.73 -332.88,-69.73 -155.4,0 -274.16,25.65 -356.01,77.16 -82.1,51.51 -122.9,128.42 -122.9,230.65 0,59.77 15.73,109.37 47.4,148.51 31.96,39.14 77.7,71.93 137.51,98.66 56.2,26.73 175.24,60.35 357.67,100.86 74.42,17.35 148.26,35.53 220.73,54.54 72.72,18.76 141.37,42.42 205.83,71.1 37.77,15.44 73.84,34.7 108.29,57.32 34.74,22.33 64.75,48.48 90.65,77.99 34.74,38.56 62.3,84.01 82.14,136.65 19.55,52.38 29.47,114.1 29.47,184.91z"/>
|
||||
<path class="fil0" d="M13653.27 5424.05c-159.3,0 -298.43,-29.22 -416.94,-88.16 -118.75,-58.98 -210.81,-143.32 -276.39,-252.69 -65.29,-109.66 -97.83,-240.86 -97.83,-393.49l0 -1238.65 263.73 0 0 1216.57c0,177.48 44.91,312.5 135.27,404.53 90.11,92.06 220.48,138.05 390.75,138.05 174.7,0 310.84,-47.65 407.84,-142.99 97.25,-95.09 145.77,-234.51 145.77,-417.48l0 -1198.68 262.32 0 0 1213.83c0,157.35 -33.33,292.66 -100.03,405.9 -66.95,113.27 -161.21,200.35 -283.28,261.5 -121.78,61.18 -265.64,91.77 -431.22,91.77z"/>
|
||||
<path class="fil0" d="M16355.92 5207.47c272.53,0 477.29,-135.02 614.51,-404.53l215.5 107.75c-80.19,167.52 -192.63,294.83 -337.57,382.2 -144.94,87.62 -313.3,131.16 -504.81,131.16 -197.03,0 -366.22,-40.76 -508.13,-122.61 -141.66,-82.14 -250.49,-198.69 -326.54,-350.24 -75.79,-151.29 -113.81,-330.14 -113.81,-536.23 0,-207.24 37.73,-384.69 113.23,-532.95 75.5,-148.26 184.08,-262.04 325.71,-341.15 141.91,-79.07 311.13,-118.75 508.13,-118.75 207.24,0 380.28,40.51 519.17,121.53 139.17,80.98 241.11,201.14 306.7,360.41l-249.92 82.68c-45.2,-112.98 -117.67,-199.52 -217.41,-259.29 -100.03,-60.1 -218.53,-89.86 -355.75,-89.86 -215.21,0 -382.77,69.19 -502.36,207.78 -119.87,138.59 -179.64,328.48 -179.64,569.6 0,158.15 28.35,297.03 84.84,416.07 56.78,119.33 136.68,211.93 240.32,277.76 103.6,65.58 226.22,98.66 367.84,98.66z"/>
|
||||
<path class="fil0" d="M18963.55 5207.47c272.53,0 477.25,-135.02 614.47,-404.53l215.5 107.75c-80.19,167.52 -192.63,294.83 -337.57,382.2 -144.94,87.62 -313.3,131.16 -504.81,131.16 -197.03,0 -366.22,-40.76 -508.13,-122.61 -141.62,-82.14 -250.49,-198.69 -326.54,-350.24 -75.79,-151.29 -113.81,-330.14 -113.81,-536.23 0,-207.24 37.77,-384.69 113.27,-532.95 75.5,-148.26 184.05,-262.04 325.71,-341.15 141.91,-79.07 311.1,-118.75 508.13,-118.75 207.2,0 380.25,40.51 519.13,121.53 139.17,80.98 241.11,201.14 306.7,360.41l-249.92 82.68c-45.2,-112.98 -117.67,-199.52 -217.41,-259.29 -100.03,-60.1 -218.53,-89.86 -355.75,-89.86 -215.21,0 -382.74,69.19 -502.32,207.78 -119.87,138.59 -179.68,328.48 -179.68,569.6 0,158.15 28.39,297.03 84.88,416.07 56.74,119.33 136.68,211.93 240.28,277.76 103.6,65.58 226.22,98.66 367.88,98.66z"/>
|
||||
<polygon class="fil0" points="22230.85,5181.28 22230.85,5396.49 20698.46,5396.49 20698.46,3451.06 22174.36,3451.06 22174.36,3666.56 20962.15,3666.56 20962.15,4290.7 22091.39,4290.7 22091.39,4503.14 20962.15,4503.14 20962.15,5181.28 "/>
|
||||
<path class="fil0" d="M24674.2 4859.43c0,179.68 -69.98,318.56 -210.52,416.94 -140.25,98.62 -338.11,147.68 -593.01,147.68 -473.93,0 -748.67,-164.49 -824.17,-494.06l255.43 -51.26c29.47,117.09 92.02,202.8 187.65,257.64 95.88,54.83 225.96,82.14 390.75,82.14 170.3,0 301.72,-29.22 394.32,-87.66 92.6,-58.4 138.59,-144.37 138.59,-257.64 0,-63.38 -14.32,-114.89 -43.25,-154.57 -29.22,-39.68 -69.73,-72.22 -122.36,-98.12 -52.34,-25.61 -114.89,-47.4 -187.65,-64.75 -72.72,-17.64 -153.2,-36.36 -241.65,-56.78 -102.23,-22.87 -187.65,-45.45 -256.26,-67.49 -68.36,-22.04 -124.81,-45.45 -168.93,-69.73 -44.37,-24.53 -82.1,-50.43 -113.23,-77.95 -46.03,-42.46 -81.31,-91.77 -105.84,-147.72 -24.24,-56.2 -36.36,-120.7 -36.36,-193.42 0,-166.44 63.67,-294.86 191.22,-385.23 127.34,-90.11 310.01,-135.31 547.27,-135.31 221.02,0 389.92,33.87 506.76,101.4 116.84,67.78 198.94,182.96 245.8,346.09l-259.58 45.49c-28.39,-103.06 -82.93,-178.02 -162.83,-224.3 -80.19,-46.57 -190.97,-69.73 -332.88,-69.73 -155.4,0 -274.19,25.65 -356.04,77.16 -82.1,51.51 -122.9,128.42 -122.9,230.65 0,59.77 15.73,109.37 47.4,148.51 31.96,39.14 77.74,71.93 137.51,98.66 56.2,26.73 175.24,60.35 357.67,100.86 74.42,17.35 148.26,35.53 220.73,54.54 72.76,18.76 141.37,42.42 205.83,71.1 37.77,15.44 73.88,34.7 108.29,57.32 34.74,22.33 64.79,48.48 90.69,77.99 34.7,38.56 62.26,84.01 82.1,136.65 19.55,52.38 29.47,114.1 29.47,184.91z"/>
|
||||
<path class="fil0" d="M27125.85 4859.43c0,179.68 -69.98,318.56 -210.52,416.94 -140.25,98.62 -338.11,147.68 -593.01,147.68 -473.97,0 -748.67,-164.49 -824.2,-494.06l255.47 -51.26c29.47,117.09 92.02,202.8 187.65,257.64 95.88,54.83 225.96,82.14 390.75,82.14 170.27,0 301.72,-29.22 394.32,-87.66 92.56,-58.4 138.59,-144.37 138.59,-257.64 0,-63.38 -14.32,-114.89 -43.25,-154.57 -29.22,-39.68 -69.73,-72.22 -122.36,-98.12 -52.34,-25.61 -114.89,-47.4 -187.65,-64.75 -72.76,-17.64 -153.2,-36.36 -241.65,-56.78 -102.23,-22.87 -187.65,-45.45 -256.26,-67.49 -68.36,-22.04 -124.85,-45.45 -168.93,-69.73 -44.37,-24.53 -82.1,-50.43 -113.23,-77.95 -46.03,-42.46 -81.31,-91.77 -105.84,-147.72 -24.24,-56.2 -36.36,-120.7 -36.36,-193.42 0,-166.44 63.63,-294.86 191.22,-385.23 127.3,-90.11 310.01,-135.31 547.27,-135.31 220.98,0 389.92,33.87 506.76,101.4 116.84,67.78 198.94,182.96 245.8,346.09l-259.58 45.49c-28.39,-103.06 -82.93,-178.02 -162.87,-224.3 -80.19,-46.57 -190.94,-69.73 -332.85,-69.73 -155.44,0 -274.19,25.65 -356.04,77.16 -82.1,51.51 -122.9,128.42 -122.9,230.65 0,59.77 15.73,109.37 47.4,148.51 31.96,39.14 77.7,71.93 137.51,98.66 56.2,26.73 175.24,60.35 357.67,100.86 74.42,17.35 148.26,35.53 220.73,54.54 72.76,18.76 141.37,42.42 205.83,71.1 37.77,15.44 73.84,34.7 108.29,57.32 34.74,22.33 64.75,48.48 90.65,77.99 34.74,38.56 62.3,84.01 82.14,136.65 19.55,52.38 29.47,114.1 29.47,184.91z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
BIN
Logo DRESSED FOR SUCCESS/Знак/.DS_Store
vendored
BIN
Logo DRESSED FOR SUCCESS/Название/.DS_Store
vendored
BIN
Logo DRESSED FOR SUCCESS/Основная версия/.DS_Store
vendored
BIN
backend/.DS_Store
vendored
BIN
backend/alembic/.DS_Store
vendored
BIN
backend/app/.DS_Store
vendored
4
backend/app/.env
Normal file
@ -0,0 +1,4 @@
|
||||
CDEK_LOGIN=q8AQtmLL7kPg6TuDo1eB2uqelJS4tHn2
|
||||
CDEK_PASSWORD=RmAmgvSgSl1yirlz9QupbzOJVqhCxcP5
|
||||
# CDEK_BASE_URL=https://api.cdek.ru/v2
|
||||
CDEK_BASE_URL=https://api.edu.cdek.ru/v2
|
||||
@ -16,7 +16,7 @@ ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 дней
|
||||
|
||||
# Настройки базы данных
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./app.db")
|
||||
# DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./app.db")
|
||||
|
||||
# Настройки почты
|
||||
MAIL_USERNAME = os.getenv("MAIL_USERNAME", "test@example.com")
|
||||
@ -79,6 +79,11 @@ class Settings(BaseSettings):
|
||||
PAYMENT_GATEWAY_API_KEY: str = os.getenv("PAYMENT_GATEWAY_API_KEY", "")
|
||||
PAYMENT_GATEWAY_SECRET: str = os.getenv("PAYMENT_GATEWAY_SECRET", "")
|
||||
|
||||
# Настройки для CDEK API
|
||||
CDEK_LOGIN: str = os.getenv("CDEK_LOGIN", "cdek-login")
|
||||
CDEK_PASSWORD: str = os.getenv("CDEK_PASSWORD", "cdek-pass")
|
||||
CDEK_BASE_URL: str = os.getenv("CDEK_BASE_URL", "https://api.cdek.ru/v2")
|
||||
|
||||
# Настройки для отправки email (пример)
|
||||
SMTP_SERVER: str = MAIL_SERVER
|
||||
SMTP_PORT: int = MAIL_PORT
|
||||
|
||||
@ -5,7 +5,7 @@ from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.order_models import CartItem, Order, OrderItem, OrderStatus, PaymentMethod
|
||||
from app.models.catalog_models import Product, ProductImage, ProductVariant
|
||||
from app.models.catalog_models import Product, ProductImage, ProductVariant, Size
|
||||
from app.models.user_models import User, UserAddress
|
||||
from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate
|
||||
|
||||
@ -172,9 +172,6 @@ def get_all_orders(
|
||||
|
||||
def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
# Проверяем, что адрес доставки существует и принадлежит пользователю, если указан
|
||||
print(f"Начало создания заказа для пользователя {user_id}")
|
||||
print(f"Данные заказа: {order}")
|
||||
|
||||
if order.shipping_address_id:
|
||||
address = db.query(UserAddress).filter(
|
||||
UserAddress.id == order.shipping_address_id,
|
||||
@ -182,12 +179,10 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
).first()
|
||||
|
||||
if not address:
|
||||
print(f"Адрес доставки {order.shipping_address_id} не найден для пользователя {user_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Указанный адрес доставки не найден"
|
||||
)
|
||||
print(f"Адрес доставки найден: {address}")
|
||||
|
||||
# Создаем новый заказ
|
||||
new_order = Order(
|
||||
@ -202,21 +197,17 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
db.add(new_order)
|
||||
db.flush() # Получаем ID заказа
|
||||
|
||||
print(f"Создан заказ с ID: {new_order.id}")
|
||||
|
||||
# Получаем элементы корзины пользователя
|
||||
cart_items = []
|
||||
|
||||
# Если указаны конкретные элементы корзины
|
||||
if order.cart_items:
|
||||
print(f"Используем указанные элементы корзины: {order.cart_items}")
|
||||
cart_items = db.query(CartItem).filter(
|
||||
CartItem.id.in_(order.cart_items),
|
||||
CartItem.user_id == user_id
|
||||
).all()
|
||||
# Если указаны прямые элементы заказа
|
||||
elif order.items:
|
||||
print(f"Используем прямые элементы заказа: {order.items}")
|
||||
# Создаем элементы заказа напрямую
|
||||
for item_data in order.items:
|
||||
# Проверяем, что вариант существует
|
||||
@ -228,117 +219,172 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
detail=f"Вариант товара с ID {item_data.variant_id} не найден"
|
||||
)
|
||||
|
||||
# Получаем продукт для варианта
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
if not product:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт для варианта с ID {item_data.variant_id} не найден"
|
||||
)
|
||||
|
||||
# Определяем цену для товара (используем discount_price если есть, иначе price)
|
||||
price = product.discount_price if product.discount_price and product.discount_price > 0 else product.price
|
||||
|
||||
# Создаем элемент заказа
|
||||
order_item = OrderItem(
|
||||
order_id=new_order.id,
|
||||
product_id=variant.product_id,
|
||||
variant_id=variant.id,
|
||||
quantity=item_data.quantity,
|
||||
price=item_data.price
|
||||
price=price
|
||||
)
|
||||
db.add(order_item)
|
||||
new_order.total_amount += order_item.price * order_item.quantity
|
||||
new_order.total_amount += price * item_data.quantity
|
||||
# Иначе используем все элементы корзины пользователя
|
||||
else:
|
||||
print(f"Используем все элементы корзины пользователя")
|
||||
cart_items = db.query(CartItem).filter(CartItem.user_id == user_id).all()
|
||||
|
||||
# Если используем элементы корзины
|
||||
if cart_items:
|
||||
print(f"Найдено {len(cart_items)} элементов корзины")
|
||||
# Создаем элементы заказа из элементов корзины
|
||||
for cart_item in cart_items:
|
||||
# Получаем вариант товара и его цену
|
||||
# Получаем вариант продукта
|
||||
variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first()
|
||||
if not variant:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Вариант товара с ID {cart_item.variant_id} не найден"
|
||||
)
|
||||
continue
|
||||
|
||||
# Определяем цену (используем скидочную цену, если она есть)
|
||||
price = variant.discount_price if variant.discount_price else variant.price
|
||||
# Получаем продукт
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
if not product:
|
||||
continue
|
||||
|
||||
# Определяем цену для товара (используем discount_price если есть, иначе price)
|
||||
price = product.discount_price if product.discount_price and product.discount_price > 0 else product.price
|
||||
|
||||
# Создаем элемент заказа
|
||||
order_item = OrderItem(
|
||||
order_id=new_order.id,
|
||||
variant_id=variant.id,
|
||||
variant_id=cart_item.variant_id,
|
||||
quantity=cart_item.quantity,
|
||||
price=price
|
||||
)
|
||||
db.add(order_item)
|
||||
|
||||
# Обновляем общую сумму заказа
|
||||
new_order.total_amount += price * cart_item.quantity
|
||||
|
||||
# Удаляем элемент из корзины
|
||||
db.delete(cart_item)
|
||||
|
||||
# Очищаем корзину пользователя после создания заказа
|
||||
if cart_items and not order.cart_items:
|
||||
# Если используем все элементы корзины, очищаем всю корзину
|
||||
db.query(CartItem).filter(CartItem.user_id == user_id).delete()
|
||||
elif order.cart_items:
|
||||
# Если используем только выбранные элементы корзины, удаляем только их
|
||||
db.query(CartItem).filter(
|
||||
CartItem.id.in_([item.id for item in cart_items]),
|
||||
CartItem.user_id == user_id
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
# Проверяем, были ли добавлены элементы заказа
|
||||
if new_order.total_amount == 0:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Не удалось создать заказ: корзина пуста или товары недоступны"
|
||||
)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(new_order)
|
||||
print(f"Заказ успешно создан: {new_order.id}, общая сумма: {new_order.total_amount}")
|
||||
return new_order
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"Ошибка при создании заказа: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Ошибка при создании заказа: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def update_order(db: Session, order_id: int, order: OrderUpdate, is_admin: bool = False) -> Order:
|
||||
db_order = get_order(db, order_id)
|
||||
if not db_order:
|
||||
def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin: bool = False) -> Order:
|
||||
"""
|
||||
Обновляет информацию о заказе.
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
order_id: ID заказа
|
||||
order_update: Данные для обновления
|
||||
is_admin: Флаг, указывающий, является ли пользователь администратором
|
||||
|
||||
Returns:
|
||||
Обновленный заказ
|
||||
|
||||
Raises:
|
||||
HTTPException: Если заказ не найден или нет прав на обновление
|
||||
"""
|
||||
# Получаем заказ
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Заказ не найден"
|
||||
)
|
||||
|
||||
# Обычные пользователи могут только отменить заказ
|
||||
if not is_admin and order.status and order.status != OrderStatus.CANCELLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Недостаточно прав для изменения статуса заказа"
|
||||
)
|
||||
# Проверяем возможность обновления статуса
|
||||
if order_update.status:
|
||||
# Если заказ уже отменен или доставлен, нельзя менять его статус
|
||||
if order.status in [OrderStatus.CANCELLED, OrderStatus.DELIVERED, OrderStatus.REFUNDED]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Невозможно изменить статус заказа из {order.status}"
|
||||
)
|
||||
|
||||
# Если пользователь не админ, он может только отменить заказ
|
||||
if not is_admin and order_update.status != OrderStatus.CANCELLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Пользователи могут только отменить заказ"
|
||||
)
|
||||
|
||||
# Если пользователь хочет отменить заказ, проверяем возможность отмены
|
||||
if order_update.status == OrderStatus.CANCELLED:
|
||||
if order.status not in [OrderStatus.PENDING, OrderStatus.PROCESSING]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Нельзя отменить заказ, который уже отправлен или доставлен"
|
||||
)
|
||||
|
||||
# Нельзя изменить статус заказа с CANCELLED или REFUNDED
|
||||
if db_order.status in [OrderStatus.CANCELLED, OrderStatus.REFUNDED] and order.status:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Нельзя изменить статус заказа, который уже {db_order.status}"
|
||||
)
|
||||
# Проверяем другие поля для обновления
|
||||
if not is_admin:
|
||||
# Обычные пользователи могут обновлять только статус (отмена)
|
||||
for field in ["shipping_address_id", "payment_method", "payment_details", "tracking_number"]:
|
||||
if getattr(order_update, field) is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Пользователи не могут изменять поле {field}"
|
||||
)
|
||||
|
||||
# Обновляем только предоставленные поля
|
||||
update_data = order.dict(exclude_unset=True)
|
||||
# Обновляем поля заказа
|
||||
update_data = order_update.dict(exclude_unset=True)
|
||||
|
||||
# Проверяем, что адрес доставки существует и принадлежит пользователю, если указан
|
||||
if "shipping_address_id" in update_data and update_data["shipping_address_id"]:
|
||||
address = db.query(UserAddress).filter(
|
||||
UserAddress.id == update_data["shipping_address_id"],
|
||||
UserAddress.user_id == db_order.user_id
|
||||
).first()
|
||||
# Если указан адрес доставки, проверяем его существование
|
||||
if "shipping_address_id" in update_data:
|
||||
address = db.query(UserAddress).filter(UserAddress.id == update_data["shipping_address_id"]).first()
|
||||
if not address:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Адрес доставки не найден или не принадлежит пользователю"
|
||||
detail="Указанный адрес доставки не найден"
|
||||
)
|
||||
|
||||
# Применяем обновления
|
||||
for key, value in update_data.items():
|
||||
setattr(db_order, key, value)
|
||||
setattr(order, key, value)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_order)
|
||||
return db_order
|
||||
except IntegrityError:
|
||||
db.refresh(order)
|
||||
return order
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ошибка при обновлении заказа"
|
||||
detail=f"Ошибка при обновлении заказа: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@ -371,128 +417,173 @@ def delete_order(db: Session, order_id: int, is_admin: bool = False) -> bool:
|
||||
|
||||
# Функции для получения детальной информации
|
||||
def get_cart_with_product_details(db: Session, user_id: int) -> List[Dict[str, Any]]:
|
||||
cart_items = get_user_cart(db, user_id)
|
||||
"""
|
||||
Получает корзину пользователя с детальной информацией о товарах.
|
||||
"""
|
||||
# Получаем элементы корзины пользователя
|
||||
cart_items = db.query(CartItem).filter(CartItem.user_id == user_id).all()
|
||||
result = []
|
||||
|
||||
for item in cart_items:
|
||||
# Получаем информацию о варианте и продукте
|
||||
variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first()
|
||||
for cart_item in cart_items:
|
||||
# Получаем вариант продукта
|
||||
variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first()
|
||||
if not variant:
|
||||
continue
|
||||
|
||||
# Получаем продукт
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
if not product:
|
||||
continue
|
||||
|
||||
# Получаем размер варианта
|
||||
size = db.query(Size).filter(Size.id == variant.size_id).first() if variant.size_id else None
|
||||
size_name = size.name if size else ''
|
||||
|
||||
# Получаем основное изображение продукта
|
||||
image = db.query(ProductImage).filter(
|
||||
product_image = None
|
||||
primary_image = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product.id,
|
||||
ProductImage.is_primary == True
|
||||
).first()
|
||||
|
||||
# Если нет основного изображения, берем первое доступное
|
||||
if not image:
|
||||
image = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product.id
|
||||
).first()
|
||||
if primary_image:
|
||||
product_image = primary_image.image_url
|
||||
else:
|
||||
# Если нет основного изображения, берем любое
|
||||
any_image = db.query(ProductImage).filter(ProductImage.product_id == product.id).first()
|
||||
if any_image:
|
||||
product_image = any_image.image_url
|
||||
|
||||
# Рассчитываем цену
|
||||
price = variant.discount_price if variant.discount_price else variant.price
|
||||
# Определяем цену товара (используем discount_price если есть, иначе price)
|
||||
price = product.discount_price if product.discount_price and product.discount_price > 0 else product.price
|
||||
|
||||
# Формируем результат
|
||||
result.append({
|
||||
"id": item.id,
|
||||
"user_id": item.user_id,
|
||||
"variant_id": item.variant_id,
|
||||
"slug": product.slug,
|
||||
"quantity": item.quantity,
|
||||
"created_at": item.created_at,
|
||||
"updated_at": item.updated_at,
|
||||
# Формируем элемент корзины с деталями
|
||||
cart_item_details = {
|
||||
"id": cart_item.id,
|
||||
"user_id": cart_item.user_id,
|
||||
"variant_id": cart_item.variant_id,
|
||||
"quantity": cart_item.quantity,
|
||||
"created_at": cart_item.created_at,
|
||||
"updated_at": cart_item.updated_at,
|
||||
"product_id": product.id,
|
||||
"product_name": product.name,
|
||||
"product_price": price,
|
||||
"product_image": image.image_url if image else None,
|
||||
"variant_name": variant.name,
|
||||
"total_price": price * item.quantity
|
||||
})
|
||||
"product_image": product_image,
|
||||
"slug": product.slug,
|
||||
"variant_name": size_name,
|
||||
"total_price": price * cart_item.quantity
|
||||
}
|
||||
|
||||
result.append(cart_item_details)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]:
|
||||
"""
|
||||
Получает заказ по ID с детальной информацией о товарах и адресе доставки.
|
||||
"""
|
||||
# Получаем заказ
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
return None
|
||||
|
||||
# Получаем все товары в заказе с их вариантами
|
||||
order_items = (
|
||||
db.query(OrderItem)
|
||||
.filter(OrderItem.order_id == order_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
items = []
|
||||
for item in order_items:
|
||||
# Получаем информацию о товаре и его варианте
|
||||
variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first()
|
||||
if not variant:
|
||||
continue
|
||||
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
if not product:
|
||||
continue
|
||||
|
||||
# Получаем основное изображение продукта
|
||||
image = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product.id,
|
||||
ProductImage.is_primary == True
|
||||
).first()
|
||||
|
||||
# Если нет основного изображения, берем первое доступное
|
||||
if not image:
|
||||
image = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product.id
|
||||
).first()
|
||||
|
||||
items.append({
|
||||
"id": item.id,
|
||||
"product": {
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"image": image.image_url if image else None,
|
||||
"slug": product.slug
|
||||
},
|
||||
"variant_name": variant.size.name if variant.size else None,
|
||||
"quantity": item.quantity,
|
||||
"price": item.price
|
||||
})
|
||||
|
||||
|
||||
# Получаем информацию о пользователе
|
||||
user = db.query(User).filter(User.id == order.user_id).first()
|
||||
user_email = user.email if user else None
|
||||
user_name = f"{user.first_name} {user.last_name}" if user and user.first_name and user.last_name else None
|
||||
|
||||
# Получаем адрес доставки
|
||||
shipping_address = None
|
||||
if order.shipping_address_id:
|
||||
address = db.query(UserAddress).filter(UserAddress.id == order.shipping_address_id).first()
|
||||
if address:
|
||||
shipping_address = {
|
||||
"id": address.id,
|
||||
"address_line1": address.address_line1,
|
||||
"address_line2": address.address_line2,
|
||||
"city": address.city,
|
||||
"state": address.state,
|
||||
"postal_code": address.postal_code,
|
||||
"country": address.country
|
||||
"country": address.country,
|
||||
"is_default": address.is_default
|
||||
}
|
||||
|
||||
|
||||
# Получаем элементы заказа с информацией о продуктах
|
||||
items = []
|
||||
order_items = db.query(OrderItem).filter(OrderItem.order_id == order.id).all()
|
||||
|
||||
for item in order_items:
|
||||
# Получаем вариант продукта
|
||||
variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first()
|
||||
variant_name = None
|
||||
size_name = None
|
||||
|
||||
if variant:
|
||||
# Получаем размер варианта
|
||||
size = db.query(Size).filter(Size.id == variant.size_id).first() if variant.size_id else None
|
||||
if size:
|
||||
size_name = size.name
|
||||
variant_name = f"{size.name}"
|
||||
|
||||
# Получаем информацию о продукте
|
||||
product = None
|
||||
product_name = "Удаленный продукт"
|
||||
product_image = None
|
||||
|
||||
if variant:
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
|
||||
if product:
|
||||
product_name = product.name
|
||||
|
||||
# Получаем основное изображение продукта
|
||||
primary_image = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product.id,
|
||||
ProductImage.is_primary == True
|
||||
).first()
|
||||
|
||||
if primary_image:
|
||||
product_image = primary_image.image_url
|
||||
else:
|
||||
# Если нет основного изображения, берем любое
|
||||
any_image = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product.id
|
||||
).first()
|
||||
|
||||
if any_image:
|
||||
product_image = any_image.image_url
|
||||
|
||||
# Добавляем информацию об элементе заказа
|
||||
items.append({
|
||||
"id": item.id,
|
||||
"order_id": item.order_id,
|
||||
"variant_id": item.variant_id,
|
||||
"quantity": item.quantity,
|
||||
"price": item.price,
|
||||
"product_id": variant.product_id if variant else None,
|
||||
"product_name": product_name,
|
||||
"product_image": product_image,
|
||||
"variant_name": variant_name,
|
||||
"size": size_name,
|
||||
"total_price": item.price * item.quantity
|
||||
})
|
||||
|
||||
# Формируем результат
|
||||
return {
|
||||
"id": order.id,
|
||||
"user_id": order.user_id,
|
||||
"user_name": f"{order.user.first_name} {order.user.last_name}" if order.user else None,
|
||||
"user_email": order.user.email if order.user else None,
|
||||
"user_email": user_email,
|
||||
"user_name": user_name,
|
||||
"status": order.status,
|
||||
"total": order.total_amount,
|
||||
"created_at": order.created_at,
|
||||
"updated_at": order.updated_at,
|
||||
"items_count": len(items),
|
||||
"items": items,
|
||||
"total_amount": order.total_amount,
|
||||
"shipping_address_id": order.shipping_address_id,
|
||||
"shipping_address": shipping_address,
|
||||
"payment_method": order.payment_method,
|
||||
"payment_details": order.payment_details,
|
||||
"tracking_number": order.tracking_number,
|
||||
"notes": order.notes
|
||||
"notes": order.notes,
|
||||
"created_at": order.created_at,
|
||||
"updated_at": order.updated_at,
|
||||
"items": items
|
||||
}
|
||||
@ -8,6 +8,7 @@ from app.routers.order_router import order_router
|
||||
from app.routers.review_router import review_router
|
||||
from app.routers.content_router import content_router
|
||||
from app.routers.analytics_router import analytics_router
|
||||
from app.routers.delivery_router import delivery_router
|
||||
|
||||
# Создаем основной роутер
|
||||
router = APIRouter()
|
||||
@ -20,4 +21,5 @@ router.include_router(cart_router)
|
||||
router.include_router(order_router)
|
||||
router.include_router(review_router)
|
||||
router.include_router(content_router)
|
||||
router.include_router(analytics_router)
|
||||
router.include_router(analytics_router)
|
||||
router.include_router(delivery_router)
|
||||
BIN
backend/app/routers/__pycache__/delivery_router.cpython-310.pyc
Normal file
@ -16,6 +16,12 @@ async def add_to_cart_endpoint(
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Добавляет товар в корзину пользователя.
|
||||
|
||||
- **variant_id**: ID варианта продукта
|
||||
- **quantity**: Количество товара
|
||||
"""
|
||||
return services.add_to_cart(db, current_user.id, cart_item)
|
||||
|
||||
|
||||
@ -26,19 +32,47 @@ async def update_cart_item_endpoint(
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Обновляет количество товара в корзине.
|
||||
|
||||
- **cart_item_id**: ID элемента корзины
|
||||
- **quantity**: Новое количество товара
|
||||
"""
|
||||
return services.update_cart_item(db, current_user.id, cart_item_id, cart_item)
|
||||
|
||||
|
||||
@cart_router.delete("/items/{cart_item_id}", response_model=Dict[str, Any])
|
||||
async def remove_from_cart_endpoint(cart_item_id: int, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)):
|
||||
async def remove_from_cart_endpoint(
|
||||
cart_item_id: int,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Удаляет товар из корзины.
|
||||
|
||||
- **cart_item_id**: ID элемента корзины
|
||||
"""
|
||||
return services.remove_from_cart(db, current_user.id, cart_item_id)
|
||||
|
||||
|
||||
@cart_router.delete("/clear", response_model=Dict[str, Any])
|
||||
async def clear_cart_endpoint(current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)):
|
||||
async def clear_cart_endpoint(
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Очищает всю корзину пользователя."""
|
||||
return services.clear_cart(db, current_user.id)
|
||||
|
||||
|
||||
@cart_router.get("/", response_model=Dict[str, Any])
|
||||
async def get_cart_endpoint(current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)):
|
||||
async def get_cart_endpoint(
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Получает содержимое корзины пользователя.
|
||||
|
||||
Возвращает список товаров в корзине с информацией о продуктах,
|
||||
общую сумму и количество товаров.
|
||||
"""
|
||||
return services.get_cart(db, current_user.id)
|
||||
46
backend/app/routers/delivery_router.py
Normal file
@ -0,0 +1,46 @@
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
import logging
|
||||
from app.services import get_cdek_service
|
||||
from app.services.delivery_service import CDEKService
|
||||
|
||||
# Настройка логирования
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Создаем роутер для доставки
|
||||
delivery_router = APIRouter(
|
||||
prefix="/delivery",
|
||||
tags=["delivery"],
|
||||
responses={404: {"description": "Not found"}},
|
||||
)
|
||||
|
||||
@delivery_router.post("/cdek")
|
||||
async def cdek_service_endpoint(request: Request, cdek_service: CDEKService = Depends(get_cdek_service)):
|
||||
"""
|
||||
Эндпоинт для обработки запросов виджета CDEK.
|
||||
Объединяет query-параметры и тело запроса, затем передает их в сервис CDEKService.
|
||||
|
||||
- **action**: Тип действия (offices, calculate)
|
||||
- Для action=offices: Параметры для получения списка офисов
|
||||
- Для action=calculate: Параметры для расчета стоимости доставки
|
||||
"""
|
||||
# Получаем query-параметры из URL
|
||||
query_params = dict(request.query_params)
|
||||
logger.info("CDEK эндпоинт: Получены query-параметры: %s", query_params)
|
||||
|
||||
try:
|
||||
# Пытаемся получить тело запроса как JSON
|
||||
body = await request.json()
|
||||
logger.info("CDEK эндпоинт: Получено тело запроса: %s", body)
|
||||
except Exception as e:
|
||||
logger.warning("CDEK эндпоинт: Не удалось получить JSON тело запроса: %s", str(e))
|
||||
body = {}
|
||||
|
||||
# Объединяем данные (приоритет у тела запроса)
|
||||
request_data = {**query_params, **body}
|
||||
logger.info("CDEK эндпоинт: Объединенные данные запроса: %s", request_data)
|
||||
|
||||
# Асинхронно обрабатываем запрос
|
||||
logger.debug("CDEK эндпоинт: Передача запроса в сервис CDEKService")
|
||||
response = await cdek_service.process(request_data)
|
||||
logger.info("CDEK эндпоинт: Получен ответ от сервиса, статус: %s", response.status_code)
|
||||
return response
|
||||
@ -6,40 +6,98 @@ from app.core import get_db, get_current_active_user
|
||||
from app import services
|
||||
from app.schemas.order_schemas import OrderCreate, OrderUpdate, Order
|
||||
from app.models.user_models import User as UserModel
|
||||
from app.repositories.order_repo import get_all_orders, get_user_orders
|
||||
from app.models.order_models import OrderStatus
|
||||
|
||||
# Роутер для заказов
|
||||
order_router = APIRouter(prefix="/orders", tags=["Заказы"])
|
||||
|
||||
@order_router.post("/", response_model=Dict[str, Any])
|
||||
async def create_order_endpoint(order: OrderCreate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)):
|
||||
async def create_order_endpoint(
|
||||
order: OrderCreate,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Создает новый заказ.
|
||||
|
||||
- **shipping_address_id**: ID адреса доставки
|
||||
- **payment_method**: Способ оплаты
|
||||
- **notes**: Примечания к заказу (опционально)
|
||||
- **cart_items**: Список ID элементов корзины (опционально)
|
||||
- **items**: Прямые элементы заказа (опционально)
|
||||
"""
|
||||
return services.create_order(db, current_user.id, order)
|
||||
|
||||
|
||||
@order_router.get("/{order_id}", response_model=Dict[str, Any])
|
||||
async def get_order_endpoint(order_id: int, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)):
|
||||
async def get_order_endpoint(
|
||||
order_id: int,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Получает информацию о заказе по ID.
|
||||
|
||||
- **order_id**: ID заказа
|
||||
"""
|
||||
return services.get_order(db, current_user.id, order_id, current_user.is_admin)
|
||||
|
||||
|
||||
@order_router.put("/{order_id}", response_model=Dict[str, Any])
|
||||
async def update_order_endpoint(order_id: int, order: OrderUpdate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)):
|
||||
async def update_order_endpoint(
|
||||
order_id: int,
|
||||
order: OrderUpdate,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Обновляет информацию о заказе.
|
||||
|
||||
- **order_id**: ID заказа
|
||||
- **status**: Новый статус заказа (опционально, только для админов)
|
||||
- **shipping_address_id**: ID нового адреса доставки (опционально, только для админов)
|
||||
- **payment_method**: Новый способ оплаты (опционально, только для админов)
|
||||
- **payment_details**: Детали оплаты (опционально, только для админов)
|
||||
- **tracking_number**: Номер отслеживания (опционально, только для админов)
|
||||
- **notes**: Примечания к заказу (опционально)
|
||||
"""
|
||||
return services.update_order(db, current_user.id, order_id, order, current_user.is_admin)
|
||||
|
||||
|
||||
@order_router.post("/{order_id}/cancel", response_model=Dict[str, Any])
|
||||
async def cancel_order_endpoint(order_id: int, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)):
|
||||
async def cancel_order_endpoint(
|
||||
order_id: int,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Отменяет заказ.
|
||||
|
||||
- **order_id**: ID заказа
|
||||
"""
|
||||
return services.cancel_order(db, current_user.id, order_id)
|
||||
|
||||
|
||||
@order_router.get("/", response_model=List[Order])
|
||||
async def get_orders(
|
||||
@order_router.get("/", response_model=List[Dict[str, Any]])
|
||||
async def get_orders_endpoint(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[str] = None,
|
||||
status: Optional[OrderStatus] = None,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
):
|
||||
"""
|
||||
Получает список заказов пользователя.
|
||||
|
||||
- **skip**: Количество пропускаемых записей
|
||||
- **limit**: Максимальное количество записей
|
||||
- **status**: Фильтр по статусу заказа (опционально)
|
||||
"""
|
||||
# Преобразуем заказы в словари с детальной информацией
|
||||
if current_user.is_admin:
|
||||
return get_all_orders(db, skip, limit, status)
|
||||
orders = services.order_repo.get_all_orders(db, skip, limit, status)
|
||||
else:
|
||||
return get_user_orders(db, current_user.id, skip, limit)
|
||||
orders = services.order_repo.get_user_orders(db, current_user.id, skip, limit)
|
||||
|
||||
# Получаем полную информацию о каждом заказе
|
||||
return [services.order_repo.get_order_with_details(db, order.id) for order in orders]
|
||||
@ -16,7 +16,7 @@ from app.services.catalog_service import (
|
||||
|
||||
from app.services.order_service import (
|
||||
add_to_cart, update_cart_item, remove_from_cart, clear_cart, get_cart,
|
||||
create_order, get_order, update_order, cancel_order
|
||||
create_order, get_order, update_order, cancel_order,
|
||||
)
|
||||
|
||||
from app.services.review_service import (
|
||||
@ -26,4 +26,9 @@ from app.services.review_service import (
|
||||
from app.services.content_service import (
|
||||
create_page, update_page, delete_page, get_page_by_slug,
|
||||
log_event, get_analytics_report
|
||||
)
|
||||
)
|
||||
|
||||
from app.services.delivery_service import get_cdek_service
|
||||
|
||||
# Импорт репозиториев для прямого доступа
|
||||
from app.repositories import order_repo
|
||||
295
backend/app/services/delivery_service.py
Normal file
@ -0,0 +1,295 @@
|
||||
import time
|
||||
import logging
|
||||
import json
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import Response, JSONResponse
|
||||
from app.config import settings
|
||||
|
||||
# Настройка детального логирования
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class CDEKService:
|
||||
def __init__(self):
|
||||
"""
|
||||
Инициализация сервиса с учетными данными и базовым URL для API CDEK.
|
||||
"""
|
||||
self.login = settings.CDEK_LOGIN
|
||||
self.secret = settings.CDEK_PASSWORD
|
||||
self.base_url = settings.CDEK_BASE_URL
|
||||
self.auth_token = None
|
||||
self.request_data = {}
|
||||
self.metrics = [] # Список для хранения метрик времени выполнения запросов
|
||||
logger.debug("CDEKService инициализирован с логином: %s, базовым URL: %s", self.login, self.base_url)
|
||||
|
||||
def start_metrics(self) -> float:
|
||||
"""
|
||||
Фиксирует время начала измерения метрики.
|
||||
:return: Текущее монотонное время.
|
||||
"""
|
||||
return time.monotonic()
|
||||
|
||||
def end_metrics(self, metric_name: str, metric_description: str, start: float) -> None:
|
||||
"""
|
||||
Вычисляет время выполнения операции и сохраняет метрику.
|
||||
:param metric_name: Имя метрики.
|
||||
:param metric_description: Описание метрики.
|
||||
:param start: Время начала операции.
|
||||
"""
|
||||
duration = round((time.monotonic() - start) * 1000, 2) # время в мс
|
||||
self.metrics.append({
|
||||
"name": metric_name,
|
||||
"description": metric_description,
|
||||
"time": duration
|
||||
})
|
||||
logger.debug("Добавлена метрика: %s, длительность: %s мс", metric_name, duration)
|
||||
|
||||
async def get_auth_token(self) -> None:
|
||||
"""
|
||||
Асинхронно запрашивает токен авторизации у CDEK API, используя клиентские учетные данные.
|
||||
При удачном ответе сохраняет полученный access_token для дальнейших запросов.
|
||||
"""
|
||||
start = self.start_metrics()
|
||||
url = f"{self.base_url}/oauth/token"
|
||||
|
||||
# Формируем данные для запроса токена - точно как в PHP версии
|
||||
payload = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.login,
|
||||
"client_secret": self.secret
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-App-Name": "widget_pvz",
|
||||
"X-App-Version": "3.11.1",
|
||||
"User-Agent": "widget/3.11.1"
|
||||
}
|
||||
|
||||
logger.info("Запрос токена авторизации к CDEK API: URL=%s, данные=%s", url, payload)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Отправляем данные как form-data, как в PHP оригинале
|
||||
response = await client.post(url, data=payload, headers=headers)
|
||||
|
||||
logger.debug("Получен ответ авторизации: status=%s, headers=%s",
|
||||
response.status_code, response.headers)
|
||||
logger.debug("Тело ответа авторизации: %s", response.text)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error("Неуспешный статус код авторизации: %s, ответ: %s",
|
||||
response.status_code, response.text)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка авторизации CDEK API: {response.status_code}"
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
logger.debug("Разобранный JSON ответа авторизации: %s", result)
|
||||
|
||||
if "access_token" not in result:
|
||||
logger.error("Токен авторизации не получен. Ответ: %s", result)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Сервер не авторизован для доступа к CDEK API: токен отсутствует"
|
||||
)
|
||||
|
||||
self.auth_token = result["access_token"]
|
||||
logger.info("Токен авторизации успешно получен: %s...", self.auth_token[:10])
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Ошибка декодирования JSON: %s, тело ответа: %s", str(e), response.text)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка декодирования ответа авторизации: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Непредвиденная ошибка при авторизации: %s", str(e), exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка обработки ответа авторизации: {str(e)}"
|
||||
)
|
||||
finally:
|
||||
self.end_metrics("auth", "Server Auth Time", start)
|
||||
|
||||
async def http_request(
|
||||
self, method: str, data: dict, use_form_data: bool = False, use_json: bool = False
|
||||
) -> dict:
|
||||
"""
|
||||
Выполняет асинхронный HTTP-запрос к API CDEK.
|
||||
:param method: Метод API (например, 'deliverypoints' или 'calculator/tarifflist').
|
||||
:param data: Параметры запроса.
|
||||
:param use_form_data: Если True, данные отправляются в виде form-data.
|
||||
:param use_json: Если True, данные отправляются в виде JSON.
|
||||
:return: Словарь с результатом запроса и дополнительными заголовками.
|
||||
"""
|
||||
url = f"{self.base_url}/{method}"
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"X-App-Name": "widget_pvz",
|
||||
"X-App-Version": "3.11.1",
|
||||
"User-Agent": "widget/3.11.1"
|
||||
}
|
||||
|
||||
if self.auth_token:
|
||||
headers["Authorization"] = f"Bearer {self.auth_token}"
|
||||
|
||||
logger.debug("HTTP запрос: URL=%s, метод=%s, данные=%s, заголовки=%s",
|
||||
url, "POST" if (use_json or use_form_data) else "GET", data, headers)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
if use_json:
|
||||
headers["Content-Type"] = "application/json"
|
||||
logger.info("Отправка POST запроса с JSON данными к %s: %s", url, data)
|
||||
response = await client.post(url, json=data, headers=headers)
|
||||
elif use_form_data:
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
logger.info("Отправка POST запроса с form-data к %s: %s", url, data)
|
||||
response = await client.post(url, data=data, headers=headers)
|
||||
else:
|
||||
logger.info("Отправка GET запроса к %s с параметрами: %s", url, data)
|
||||
response = await client.get(url, params=data, headers=headers)
|
||||
|
||||
logger.debug("Получен ответ: status=%s, headers=%s",
|
||||
response.status_code, response.headers)
|
||||
logger.debug("Тело ответа: %s", response.text[:500] + "..." if len(response.text) > 500 else response.text)
|
||||
|
||||
# Фильтрация заголовков, начинающихся на "X-"
|
||||
added_headers = []
|
||||
for key, value in response.headers.items():
|
||||
if key.startswith("X-"):
|
||||
header_line = f"{key}: {value}"
|
||||
added_headers.append(header_line)
|
||||
logger.debug("Добавлен заголовок: %s", header_line)
|
||||
|
||||
if response.status_code < 200 or response.status_code >= 300:
|
||||
logger.error("Ошибка запроса к CDEK API. Код: %s, Ответ: %s",
|
||||
response.status_code, response.text)
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=f"Ошибка запроса к CDEK API: {response.text}"
|
||||
)
|
||||
|
||||
return {"result": response.text, "addedHeaders": added_headers}
|
||||
except Exception as e:
|
||||
logger.error("Ошибка при выполнении HTTP запроса: %s", str(e), exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка HTTP запроса: {str(e)}")
|
||||
|
||||
async def get_offices(self) -> dict:
|
||||
"""
|
||||
Асинхронно запрашивает список пунктов выдачи (офисов) доставки.
|
||||
:return: Результат запроса в виде словаря.
|
||||
"""
|
||||
start = self.start_metrics()
|
||||
logger.info("Запрос списка офисов с данными: %s", self.request_data)
|
||||
result = await self.http_request("deliverypoints", self.request_data)
|
||||
self.end_metrics("office", "Offices Request", start)
|
||||
return result
|
||||
|
||||
async def calculate(self) -> dict:
|
||||
"""
|
||||
Асинхронно запрашивает расчет стоимости доставки.
|
||||
:return: Результат расчета в виде словаря.
|
||||
"""
|
||||
start = self.start_metrics()
|
||||
logger.info("Запрос расчета доставки с данными: %s", self.request_data)
|
||||
result = await self.http_request("calculator/tarifflist", self.request_data, use_form_data=False, use_json=True)
|
||||
self.end_metrics("calc", "Calculate Request", start)
|
||||
return result
|
||||
|
||||
async def process(self, request_data: dict) -> Response:
|
||||
"""
|
||||
Обрабатывает входящие данные, определяет action и выполняет соответствующий запрос.
|
||||
:param request_data: Данные запроса (объединение query-параметров и тела запроса).
|
||||
:return: Финальный HTTP-ответ с результатом.
|
||||
"""
|
||||
start = self.start_metrics()
|
||||
logger.info("Обработка входящего запроса с данными: %s", request_data)
|
||||
self.request_data = request_data
|
||||
|
||||
# Проверяем, что передан обязательный параметр "action"
|
||||
if "action" not in self.request_data:
|
||||
logger.warning("Отсутствует обязательный параметр 'action'")
|
||||
return self.send_validation_error("Action is required")
|
||||
|
||||
# Получаем токен авторизации
|
||||
try:
|
||||
await self.get_auth_token()
|
||||
except Exception as e:
|
||||
logger.error("Ошибка при получении токена авторизации: %s", str(e))
|
||||
return self.send_validation_error(f"Ошибка авторизации: {str(e)}")
|
||||
|
||||
action = self.request_data.get("action")
|
||||
logger.info("Выполнение действия: %s", action)
|
||||
|
||||
try:
|
||||
if action == "offices":
|
||||
result = await self.get_offices()
|
||||
elif action == "calculate":
|
||||
result = await self.calculate()
|
||||
else:
|
||||
logger.warning("Неизвестное действие: %s", action)
|
||||
return self.send_validation_error("Unknown action")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка при выполнении действия %s: %s", action, str(e))
|
||||
return self.send_validation_error(f"Ошибка выполнения действия: {str(e)}")
|
||||
|
||||
logger.info("Действие %s успешно выполнено", action)
|
||||
return self.send_response(result, start)
|
||||
|
||||
def send_validation_error(self, message: str) -> JSONResponse:
|
||||
"""
|
||||
Формирует ответ с ошибкой валидации.
|
||||
:param message: Текст ошибки.
|
||||
:return: JSONResponse с кодом 400 и сообщением об ошибке.
|
||||
"""
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Service-Version": "3.11.1"
|
||||
}
|
||||
logger.warning("Ошибка валидации: %s", message)
|
||||
return JSONResponse(status_code=400, content={"message": message}, headers=headers)
|
||||
|
||||
def send_response(self, data: dict, start: float) -> Response:
|
||||
"""
|
||||
Формирует окончательный HTTP-ответ, включая заголовки с метриками.
|
||||
:param data: Данные, полученные от API CDEK.
|
||||
:param start: Время начала обработки запроса.
|
||||
:return: HTTP-ответ с нужными заголовками и данными.
|
||||
"""
|
||||
self.end_metrics("total", "Total Time", start)
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Service-Version": "3.11.1"
|
||||
}
|
||||
# Добавляем дополнительные заголовки из ответа CDEK API
|
||||
for header_line in data.get("addedHeaders", []):
|
||||
parts = header_line.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
headers[parts[0].strip()] = parts[1].strip()
|
||||
logger.debug("Добавлен заголовок ответа: %s = %s", parts[0].strip(), parts[1].strip())
|
||||
|
||||
# Формирование заголовка Server-Timing с данными о метриках
|
||||
server_timing = ""
|
||||
for metric in self.metrics:
|
||||
server_timing += f'{metric["name"]};desc="{metric["description"]}";dur={metric["time"]},'
|
||||
|
||||
if server_timing:
|
||||
headers["Server-Timing"] = server_timing.rstrip(",")
|
||||
logger.debug("Добавлен заголовок Server-Timing: %s", headers["Server-Timing"])
|
||||
|
||||
logger.info("Формирование окончательного ответа с %d заголовками", len(headers))
|
||||
return Response(content=data["result"], status_code=200, headers=headers, media_type="application/json")
|
||||
|
||||
|
||||
# Функция для создания экземпляра сервиса
|
||||
def get_cdek_service() -> CDEKService:
|
||||
"""
|
||||
Создает и возвращает экземпляр CDEKService.
|
||||
:return: Экземпляр CDEKService.
|
||||
"""
|
||||
return CDEKService()
|
||||
@ -1,61 +1,95 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import HTTPException, status
|
||||
from typing import Dict, Any
|
||||
from typing import Dict, Any, List
|
||||
|
||||
from app.repositories import order_repo, content_repo
|
||||
from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate
|
||||
from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate, Order
|
||||
from app.schemas.content_schemas import AnalyticsLogCreate
|
||||
from app.models.order_models import OrderStatus
|
||||
|
||||
|
||||
# Сервисы корзины и заказов
|
||||
# Сервисы корзины
|
||||
def add_to_cart(db: Session, user_id: int, cart_item: CartItemCreate) -> Dict[str, Any]:
|
||||
from app.schemas.order_schemas import CartItem as CartItemSchema
|
||||
|
||||
"""
|
||||
Добавляет товар в корзину пользователя.
|
||||
"""
|
||||
new_cart_item = order_repo.create_cart_item(db, cart_item, user_id)
|
||||
|
||||
# Логируем событие добавления в корзину
|
||||
log_data = AnalyticsLogCreate(
|
||||
user_id=user_id,
|
||||
event_type="add_to_cart",
|
||||
product_id=new_cart_item.variant.product_id,
|
||||
additional_data={"quantity": cart_item.quantity}
|
||||
additional_data={
|
||||
"variant_id": cart_item.variant_id,
|
||||
"quantity": cart_item.quantity
|
||||
}
|
||||
)
|
||||
content_repo.log_analytics_event(db, log_data)
|
||||
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
cart_item_schema = CartItemSchema.model_validate(new_cart_item)
|
||||
return {"cart_item": cart_item_schema}
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Товар успешно добавлен в корзину",
|
||||
"cart_item": {
|
||||
"id": new_cart_item.id,
|
||||
"user_id": new_cart_item.user_id,
|
||||
"variant_id": new_cart_item.variant_id,
|
||||
"quantity": new_cart_item.quantity
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def update_cart_item(db: Session, user_id: int, cart_item_id: int, cart_item: CartItemUpdate) -> Dict[str, Any]:
|
||||
from app.schemas.order_schemas import CartItem as CartItemSchema
|
||||
|
||||
"""
|
||||
Обновляет количество товара в корзине пользователя.
|
||||
"""
|
||||
updated_cart_item = order_repo.update_cart_item(db, cart_item_id, cart_item, user_id)
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
cart_item_schema = CartItemSchema.model_validate(updated_cart_item)
|
||||
return {"cart_item": cart_item_schema}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Товар в корзине успешно обновлен",
|
||||
"cart_item": {
|
||||
"id": updated_cart_item.id,
|
||||
"user_id": updated_cart_item.user_id,
|
||||
"variant_id": updated_cart_item.variant_id,
|
||||
"quantity": updated_cart_item.quantity
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def remove_from_cart(db: Session, user_id: int, cart_item_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Удаляет товар из корзины пользователя.
|
||||
"""
|
||||
success = order_repo.delete_cart_item(db, cart_item_id, user_id)
|
||||
return {"success": success}
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"message": "Товар успешно удален из корзины" if success else "Не удалось удалить товар из корзины"
|
||||
}
|
||||
|
||||
|
||||
def clear_cart(db: Session, user_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Очищает корзину пользователя.
|
||||
"""
|
||||
success = order_repo.clear_cart(db, user_id)
|
||||
return {"success": success}
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"message": "Корзина успешно очищена" if success else "Не удалось очистить корзину"
|
||||
}
|
||||
|
||||
|
||||
def get_cart(db: Session, user_id: int) -> Dict[str, Any]:
|
||||
from app.schemas.order_schemas import CartItem as CartItemSchema
|
||||
|
||||
"""
|
||||
Получает корзину пользователя с детальной информацией о товарах.
|
||||
"""
|
||||
# Получаем элементы корзины с деталями продуктов
|
||||
cart_items = order_repo.get_cart_with_product_details(db, user_id)
|
||||
|
||||
# Рассчитываем общую сумму корзины
|
||||
total_amount = sum(item["total_price"] for item in cart_items)
|
||||
|
||||
# Примечание: cart_items уже содержит сериализованные данные из репозитория
|
||||
return {
|
||||
"items": cart_items,
|
||||
"total_amount": total_amount,
|
||||
@ -63,37 +97,53 @@ def get_cart(db: Session, user_id: int) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# Сервисы заказов
|
||||
def create_order(db: Session, user_id: int, order: OrderCreate) -> Dict[str, Any]:
|
||||
from app.schemas.order_schemas import Order as OrderSchema
|
||||
|
||||
print(f"Создание заказа для пользователя {user_id}: {order}")
|
||||
|
||||
"""
|
||||
Создает новый заказ на основе корзины или переданных элементов.
|
||||
"""
|
||||
try:
|
||||
new_order = order_repo.create_order(db, order, user_id)
|
||||
|
||||
print(f"Заказ успешно создан: {new_order.id}")
|
||||
|
||||
# Логируем событие создания заказа
|
||||
log_data = AnalyticsLogCreate(
|
||||
user_id=user_id,
|
||||
event_type="order_created",
|
||||
additional_data={"order_id": new_order.id, "total_amount": new_order.total_amount}
|
||||
additional_data={
|
||||
"order_id": new_order.id,
|
||||
"total_amount": new_order.total_amount
|
||||
}
|
||||
)
|
||||
content_repo.log_analytics_event(db, log_data)
|
||||
|
||||
# Получаем заказ с деталями
|
||||
order_details = order_repo.get_order_with_details(db, new_order.id)
|
||||
|
||||
return {"order": order_details}
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Заказ успешно создан",
|
||||
"order": order_details
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Ошибка при создании заказа: {str(e)}")
|
||||
raise
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Ошибка при создании заказа: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def get_order(db: Session, user_id: int, order_id: int, is_admin: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Получает информацию о заказе по ID.
|
||||
"""
|
||||
# Получаем заказ с деталями
|
||||
order_details = order_repo.get_order_with_details(db, order_id)
|
||||
|
||||
if not order_details:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Заказ не найден"
|
||||
)
|
||||
|
||||
# Проверяем права доступа
|
||||
if not is_admin and order_details["user_id"] != user_id:
|
||||
raise HTTPException(
|
||||
@ -101,40 +151,89 @@ def get_order(db: Session, user_id: int, order_id: int, is_admin: bool = False)
|
||||
detail="Недостаточно прав для просмотра этого заказа"
|
||||
)
|
||||
|
||||
return {"order": order_details}
|
||||
return {
|
||||
"success": True,
|
||||
"order": order_details
|
||||
}
|
||||
|
||||
|
||||
def update_order(db: Session, user_id: int, order_id: int, order: OrderUpdate, is_admin: bool = False) -> Dict[str, Any]:
|
||||
from app.schemas.order_schemas import Order as OrderSchema
|
||||
|
||||
updated_order = order_repo.update_order(db, order_id, order, is_admin)
|
||||
def update_order(db: Session, user_id: int, order_id: int, order_update: OrderUpdate, is_admin: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Обновляет информацию о заказе.
|
||||
"""
|
||||
# Проверяем существование заказа
|
||||
order = order_repo.get_order(db, order_id)
|
||||
if not order:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Заказ не найден"
|
||||
)
|
||||
|
||||
# Проверяем права доступа
|
||||
if not is_admin and updated_order.user_id != user_id:
|
||||
if not is_admin and order.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Недостаточно прав для обновления этого заказа"
|
||||
)
|
||||
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
order_schema = OrderSchema.model_validate(updated_order)
|
||||
return {"order": order_schema}
|
||||
# Обычные пользователи могут только отменить заказ
|
||||
if not is_admin and (order_update.status and order_update.status != OrderStatus.CANCELLED):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Пользователи могут только отменить заказ"
|
||||
)
|
||||
|
||||
# Обновляем заказ
|
||||
updated_order = order_repo.update_order(db, order_id, order_update, is_admin)
|
||||
|
||||
# Получаем обновленный заказ с деталями
|
||||
order_details = order_repo.get_order_with_details(db, order_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Заказ успешно обновлен",
|
||||
"order": order_details
|
||||
}
|
||||
|
||||
|
||||
def cancel_order(db: Session, user_id: int, order_id: int) -> Dict[str, Any]:
|
||||
from app.schemas.order_schemas import Order as OrderSchema
|
||||
|
||||
# Отменяем заказ (обычный пользователь может только отменить заказ)
|
||||
order_update = OrderUpdate(status="cancelled")
|
||||
updated_order = order_repo.update_order(db, order_id, order_update, is_admin=False)
|
||||
"""
|
||||
Отменяет заказ.
|
||||
"""
|
||||
# Проверяем существование заказа
|
||||
order = order_repo.get_order(db, order_id)
|
||||
if not order:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Заказ не найден"
|
||||
)
|
||||
|
||||
# Проверяем права доступа
|
||||
if updated_order.user_id != user_id:
|
||||
if order.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Недостаточно прав для отмены этого заказа"
|
||||
)
|
||||
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
order_schema = OrderSchema.model_validate(updated_order)
|
||||
return {"order": order_schema}
|
||||
# Отменяем заказ
|
||||
order_update = OrderUpdate(status=OrderStatus.CANCELLED)
|
||||
order_repo.update_order(db, order_id, order_update, False)
|
||||
|
||||
# Получаем обновленный заказ с деталями
|
||||
order_details = order_repo.get_order_with_details(db, order_id)
|
||||
|
||||
# Логируем событие отмены заказа
|
||||
log_data = AnalyticsLogCreate(
|
||||
user_id=user_id,
|
||||
event_type="order_cancelled",
|
||||
additional_data={
|
||||
"order_id": order_id
|
||||
}
|
||||
)
|
||||
content_repo.log_analytics_event(db, log_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Заказ успешно отменен",
|
||||
"order": order_details
|
||||
}
|
||||
115
backend/app/test_cdek.py
Normal file
@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
import logging
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Добавляем путь к корневой директории проекта
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.services.delivery_service import CDEKService
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def test_auth():
|
||||
"""Тестирование авторизации в CDEK API"""
|
||||
logger.info("Начало теста авторизации")
|
||||
service = CDEKService()
|
||||
try:
|
||||
await service.get_auth_token()
|
||||
logger.info("Токен получен успешно: %s...", service.auth_token[:10])
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Ошибка при авторизации: %s", str(e), exc_info=True)
|
||||
return False
|
||||
|
||||
async def test_offices():
|
||||
"""Тестирование получения списка офисов"""
|
||||
logger.info("Начало теста получения офисов")
|
||||
service = CDEKService()
|
||||
await service.get_auth_token()
|
||||
|
||||
# Тестовые данные для получения офисов в Москве
|
||||
service.request_data = {
|
||||
"action": "offices",
|
||||
"city_code": "44", # Код Москвы
|
||||
"type": "PVZ"
|
||||
}
|
||||
|
||||
try:
|
||||
result = await service.get_offices()
|
||||
logger.info("Офисы получены: %s", result["result"][:100])
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Ошибка при получении офисов: %s", str(e), exc_info=True)
|
||||
return False
|
||||
|
||||
async def test_calculate():
|
||||
"""Тестирование расчета стоимости доставки"""
|
||||
logger.info("Начало теста расчета доставки")
|
||||
service = CDEKService()
|
||||
await service.get_auth_token()
|
||||
|
||||
# Тестовые данные для расчета доставки Москва -> Санкт-Петербург
|
||||
service.request_data = {
|
||||
"action": "calculate",
|
||||
"from_location": {
|
||||
"code": "44", # Код Москвы
|
||||
"city": "Москва"
|
||||
},
|
||||
"to_location": {
|
||||
"code": "270", # Код Санкт-Петербурга
|
||||
"city": "Санкт-Петербург"
|
||||
},
|
||||
"packages": [
|
||||
{
|
||||
"weight": 500,
|
||||
"length": 20,
|
||||
"width": 20,
|
||||
"height": 20
|
||||
}
|
||||
],
|
||||
"tariff_code": 1
|
||||
}
|
||||
|
||||
try:
|
||||
result = await service.calculate()
|
||||
logger.info("Расчет выполнен: %s", result["result"][:100])
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Ошибка при расчете доставки: %s", str(e), exc_info=True)
|
||||
return False
|
||||
|
||||
async def main():
|
||||
"""Запуск всех тестов"""
|
||||
logger.info("Запуск тестов CDEK API")
|
||||
|
||||
# Тест авторизации
|
||||
auth_result = await test_auth()
|
||||
if not auth_result:
|
||||
logger.error("Тест авторизации не пройден. Дальнейшие тесты отменены.")
|
||||
return
|
||||
|
||||
# Тест получения офисов
|
||||
offices_result = await test_offices()
|
||||
if not offices_result:
|
||||
logger.error("Тест получения офисов не пройден.")
|
||||
|
||||
# Тест расчета доставки
|
||||
calculate_result = await test_calculate()
|
||||
if not calculate_result:
|
||||
logger.error("Тест расчета доставки не пройден.")
|
||||
|
||||
if auth_result and offices_result and calculate_result:
|
||||
logger.info("ВСЕ ТЕСТЫ ПРОЙДЕНЫ УСПЕШНО")
|
||||
else:
|
||||
logger.warning("НЕКОТОРЫЕ ТЕСТЫ НЕ ПРОЙДЕНЫ")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
130
backend/docs/cdek_integration.md
Normal file
@ -0,0 +1,130 @@
|
||||
# Документация по интеграции с CDEK API
|
||||
|
||||
## Обзор
|
||||
|
||||
Интеграция с API CDEK предоставляет функциональность для работы с сервисом доставки CDEK в нашем интернет-магазине. В частности, эта интеграция позволяет:
|
||||
|
||||
1. Получать список пунктов выдачи CDEK
|
||||
2. Рассчитывать стоимость доставки
|
||||
|
||||
Интеграция разработана специально для обеспечения работы виджета CDEK на фронтенде.
|
||||
|
||||
## Архитектура
|
||||
|
||||
Интеграция состоит из следующих компонентов:
|
||||
|
||||
1. **CDEKService** (сервисный класс) - содержит всю логику взаимодействия с API CDEK:
|
||||
- Авторизация через OAuth2
|
||||
- Отправка запросов к API CDEK
|
||||
- Сбор метрик времени выполнения
|
||||
- Форматирование ответов
|
||||
|
||||
2. **Эндпоинт /api/delivery/cdek** - принимает запросы от фронтенда и передает их в сервис
|
||||
|
||||
## Настройка
|
||||
|
||||
Для корректной работы с API CDEK необходимо добавить следующие переменные окружения:
|
||||
|
||||
```
|
||||
CDEK_LOGIN=ваш_логин
|
||||
CDEK_PASSWORD=ваш_пароль
|
||||
CDEK_BASE_URL=https://api.cdek.ru/v2
|
||||
```
|
||||
|
||||
По умолчанию используется продакшн окружение. Для тестов можно использовать тестовое окружение CDEK.
|
||||
|
||||
## Использование API
|
||||
|
||||
### Получение списка пунктов выдачи (офисов)
|
||||
|
||||
**Запрос:**
|
||||
```
|
||||
POST /api/delivery/cdek
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"action": "offices",
|
||||
"city_code": "44",
|
||||
"type": "PVZ"
|
||||
}
|
||||
```
|
||||
|
||||
### Расчет стоимости доставки
|
||||
|
||||
**Запрос:**
|
||||
```
|
||||
POST /api/delivery/cdek
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"action": "calculate",
|
||||
"from_location": {
|
||||
"code": "44",
|
||||
"city": "Москва"
|
||||
},
|
||||
"to_location": {
|
||||
"code": "270",
|
||||
"city": "Санкт-Петербург"
|
||||
},
|
||||
"packages": [
|
||||
{
|
||||
"weight": 500,
|
||||
"length": 20,
|
||||
"width": 20,
|
||||
"height": 20
|
||||
}
|
||||
],
|
||||
"tariff_code": 1
|
||||
}
|
||||
```
|
||||
|
||||
## Метрики и мониторинг
|
||||
|
||||
Сервис собирает метрики выполнения запросов и добавляет их в заголовок `Server-Timing`:
|
||||
|
||||
- `auth` - время авторизации
|
||||
- `office` - время запроса списка офисов
|
||||
- `calc` - время расчета доставки
|
||||
- `total` - общее время обработки запроса
|
||||
|
||||
Эта информация может быть полезна для отладки и мониторинга производительности.
|
||||
|
||||
## Интеграция с виджетом CDEK
|
||||
|
||||
Для использования виджета CDEK на фронтенде необходимо:
|
||||
|
||||
1. Подключить скрипт виджета в HTML:
|
||||
```html
|
||||
<script type="text/javascript" src="https://widget.cdek.ru/widget/widjet.js"></script>
|
||||
```
|
||||
|
||||
2. Настроить виджет с указанием URL нашего прокси-эндпоинта:
|
||||
```javascript
|
||||
const cdekWidget = new window.CDEKWidget({
|
||||
apiUrl: '/api/delivery/cdek',
|
||||
from: 'Москва',
|
||||
defaultLocation: 'Санкт-Петербург'
|
||||
});
|
||||
```
|
||||
|
||||
3. Настроить обработчик событий для сохранения выбранной точки доставки:
|
||||
```javascript
|
||||
cdekWidget.on('select', (data) => {
|
||||
console.log('Выбран пункт выдачи:', data);
|
||||
// Сохраняем выбранную точку доставки в состоянии заказа
|
||||
});
|
||||
```
|
||||
|
||||
## Обработка ошибок
|
||||
|
||||
В случае возникновения ошибок при взаимодействии с API CDEK, сервис возвращает HTTP-ответ с соответствующим кодом состояния и описанием ошибки:
|
||||
|
||||
- `400 Bad Request` - неверный формат запроса или отсутствие обязательных параметров
|
||||
- `500 Internal Server Error` - ошибки авторизации или взаимодействия с API CDEK
|
||||
|
||||
Все ошибки логируются для дальнейшего анализа.
|
||||
|
||||
## Ссылки
|
||||
|
||||
- [Официальная документация API CDEK](https://api-docs.cdek.ru/29923741.html)
|
||||
- [Документация виджета CDEK](https://widget.cdek.ru/docs.html)
|
||||
@ -1,12 +1,13 @@
|
||||
fastapi==0.95.1
|
||||
uvicorn==0.22.0
|
||||
sqlalchemy==2.0.12
|
||||
pydantic==1.10.7
|
||||
fastapi==0.105.0
|
||||
uvicorn==0.24.0
|
||||
sqlalchemy==2.0.23
|
||||
pydantic==2.5.2
|
||||
python-jose==3.3.0
|
||||
passlib==1.7.4
|
||||
python-multipart==0.0.6
|
||||
email-validator==2.0.0
|
||||
email-validator==2.1.0
|
||||
psycopg2-binary==2.9.6
|
||||
alembic==1.10.4
|
||||
alembic==1.12.1
|
||||
python-dotenv==1.0.0
|
||||
bcrypt==4.0.1
|
||||
bcrypt==4.0.1
|
||||
httpx==0.25.2
|
||||
BIN
backend/uploads/0d6a250d-23f0-4dcb-a839-b1029128376e.jpg
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
backend/uploads/190f8310-d7fc-4a7e-8b76-c639e13a493d.jpeg
Normal file
|
After Width: | Height: | Size: 337 KiB |
BIN
backend/uploads/22862a48-8c4c-4132-b5c8-05b9cfd03e78.jpeg
Normal file
|
After Width: | Height: | Size: 483 KiB |
BIN
backend/uploads/229a07d2-33d5-4761-97c2-81286f0a879a.jpeg
Normal file
|
After Width: | Height: | Size: 327 KiB |
BIN
backend/uploads/3e7ce154-97d8-416f-af1e-d871b62415a5.jpg
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
backend/uploads/559f4717-0b77-4928-bd1a-3f6e6d431f30.jpeg
Normal file
|
After Width: | Height: | Size: 337 KiB |
BIN
backend/uploads/5748eff6-706b-495c-b857-389a70a816c0.jpg
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
backend/uploads/68d1092c-0c17-4d6f-964e-3245613aff1b.jpeg
Normal file
|
After Width: | Height: | Size: 423 KiB |
BIN
backend/uploads/706987f8-90eb-4bde-9bee-51575371794b.jpeg
Normal file
|
After Width: | Height: | Size: 536 KiB |
BIN
backend/uploads/99c64231-1d6c-4dd4-9631-04d5cf0a6e14.jpeg
Normal file
|
After Width: | Height: | Size: 318 KiB |
BIN
backend/uploads/9b08346b-251a-4af5-be0b-2e5a58189350.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
backend/uploads/a324a97a-4530-4a3a-8641-a51001be7a4d.jpeg
Normal file
|
After Width: | Height: | Size: 297 KiB |
BIN
backend/uploads/a7bc3345-500b-466a-b23f-b10ad21437bf.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
backend/uploads/aae1f808-0060-4ea3-a0a2-3222ecd7d29f.jpeg
Normal file
|
After Width: | Height: | Size: 354 KiB |
BIN
backend/uploads/cf9b7f18-08b5-4d98-8b54-fce2f93bf3e6.jpeg
Normal file
|
After Width: | Height: | Size: 310 KiB |
BIN
backend/uploads/d99a1fbc-e9ae-4002-bb38-0cf271368acb.jpeg
Normal file
|
After Width: | Height: | Size: 355 KiB |
BIN
backend/uploads/db6b753a-ec93-4073-89b9-edc50f372c80.jpg
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
backend/uploads/e1cdca42-d717-4ce7-a988-451f1ed80e08.jpeg
Normal file
|
After Width: | Height: | Size: 314 KiB |
BIN
backend/uploads/e7427911-7740-4705-a6ae-b2021e7046e2.jpeg
Normal file
|
After Width: | Height: | Size: 322 KiB |
BIN
backend/uploads/f67d2659-62cd-4197-931d-a93b3a6247d5.jpg
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
frontend/.DS_Store
vendored
BIN
frontend/app/(main)/.DS_Store
vendored
Normal file
@ -3,102 +3,66 @@
|
||||
import { useState } from "react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { Trash2, Plus, Minus, ArrowRight, ShoppingBag, Heart, ChevronLeft, Clock, ShieldCheck, Truck } from "lucide-react"
|
||||
import { Trash2, Plus, Minus, ArrowRight, ShoppingBag, Heart, ChevronLeft, Clock, ShieldCheck, Truck, Package } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
import { useCart } from "@/hooks/useCart"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { formatPrice } from "@/lib/utils"
|
||||
import { CartItem as CartItemType } from "@/types/cart"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { ProductCard } from "@/components/product/product-card"
|
||||
|
||||
interface CartItem {
|
||||
interface RecommendedProduct {
|
||||
id: number
|
||||
name: string
|
||||
price: number
|
||||
quantity: number
|
||||
size: string
|
||||
color: string
|
||||
image: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export default function CartPage() {
|
||||
// Mock cart items
|
||||
const [cartItems, setCartItems] = useState<CartItem[]>([
|
||||
{
|
||||
id: 1,
|
||||
name: "ПЛАТЬЕ С ЦВЕТОЧНЫМ ПРИНТОМ",
|
||||
price: 5990,
|
||||
quantity: 1,
|
||||
size: "M",
|
||||
color: "Белый",
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "БЛУЗА ИЗ НАТУРАЛЬНОГО ШЕЛКА",
|
||||
price: 4990,
|
||||
quantity: 2,
|
||||
size: "S",
|
||||
color: "Синий",
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
},
|
||||
])
|
||||
const [promoCode, setPromoCode] = useState("")
|
||||
const [promoApplied, setPromoApplied] = useState(false)
|
||||
const { cart, loading, updateCartItem, removeFromCart, clearCart } = useCart()
|
||||
const [ref1, inView1] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [ref2, inView2] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [ref3, inView3] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const router = useRouter()
|
||||
const [processing, setProcessing] = useState<{ [key: number]: boolean }>({})
|
||||
|
||||
const updateQuantity = (id: number, newQuantity: number) => {
|
||||
if (newQuantity < 1) return
|
||||
|
||||
setCartItems(cartItems.map((item) => (item.id === id ? { ...item, quantity: newQuantity } : item)))
|
||||
}
|
||||
|
||||
const removeItem = (id: number) => {
|
||||
setCartItems(cartItems.filter((item) => item.id !== id))
|
||||
}
|
||||
|
||||
const moveToWishlist = (id: number) => {
|
||||
// В реальном приложении здесь был бы код для добавления в избранное
|
||||
console.log(`Товар ${id} добавлен в избранное`)
|
||||
removeItem(id)
|
||||
}
|
||||
|
||||
const applyPromoCode = () => {
|
||||
if (promoCode.trim() === "SALE20") {
|
||||
setPromoApplied(true)
|
||||
} else {
|
||||
alert("Неверный промокод")
|
||||
const handleQuantityChange = async (itemId: number, newQuantity: number) => {
|
||||
if (processing[itemId]) return
|
||||
|
||||
setProcessing(prev => ({ ...prev, [itemId]: true }))
|
||||
try {
|
||||
await updateCartItem(itemId, newQuantity)
|
||||
} finally {
|
||||
setProcessing(prev => ({ ...prev, [itemId]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0)
|
||||
const shipping = subtotal >= 5000 ? 0 : 500 // Free shipping for orders over 5000
|
||||
const discount = promoApplied ? Math.round(subtotal * 0.2) : 0 // 20% discount if promo applied
|
||||
const total = subtotal + shipping - discount
|
||||
const handleRemoveItem = async (itemId: number) => {
|
||||
if (processing[itemId]) return
|
||||
|
||||
setProcessing(prev => ({ ...prev, [itemId]: true }))
|
||||
try {
|
||||
await removeFromCart(itemId)
|
||||
} finally {
|
||||
setProcessing(prev => ({ ...prev, [itemId]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckout = () => {
|
||||
router.push('/checkout')
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const subtotal = cart.total_price
|
||||
const shipping = subtotal >= 5000 ? 0 : 500 // Free shipping for orders over 5000
|
||||
const total = subtotal + shipping
|
||||
|
||||
// Recommended products
|
||||
const recommendedProducts = [
|
||||
{
|
||||
id: 3,
|
||||
name: "ЮБКА МИДИ ПЛИССЕ",
|
||||
price: 5290,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "ЖАКЕТ ИЗ ИТАЛЬЯНСКОЙ ШЕРСТИ",
|
||||
price: 12900,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "ПЛАТЬЕ-РУБАШКА ИЗ ЛЬНА",
|
||||
price: 7990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
},
|
||||
]
|
||||
|
||||
// Анимационные варианты
|
||||
const containerVariants = {
|
||||
@ -116,22 +80,38 @@ export default function CartPage() {
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.5 } }
|
||||
}
|
||||
|
||||
if (!loading && (!cart.items || cart.items.length === 0)) {
|
||||
return (
|
||||
<div className="container mx-auto py-12 px-4">
|
||||
<h1 className="text-2xl font-bold mb-8">Корзина</h1>
|
||||
<div className="bg-white p-8 rounded-lg shadow-sm text-center">
|
||||
<p className="text-lg mb-6">Ваша корзина пуста</p>
|
||||
<Link href="/catalog">
|
||||
<Button className="bg-black hover:bg-neutral-800">
|
||||
Продолжить покупки
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white min-h-screen">
|
||||
<div className="container mx-auto px-4 py-8 md:py-16">
|
||||
<div className="flex items-center mb-8 md:mb-12">
|
||||
<div className="flex items-center justify-between mb-8 md:mb-12">
|
||||
<Link href="/catalog" className="text-primary hover:text-primary/80 flex items-center text-sm md:text-base transition-colors duration-200">
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Вернуться к покупкам
|
||||
</Link>
|
||||
<h1 className="text-2xl md:text-3xl lg:text-4xl font-light text-primary tracking-tight">Корзина</h1>
|
||||
<div className="w-[120px] md:hidden"></div> {/* Элемент для центрирования заголовка на мобильных устройствах */}
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl md:text-3xl lg:text-4xl font-light text-primary mb-8 md:mb-12 tracking-tight">Корзина</h1>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{cartItems.length === 0 ? (
|
||||
{loading ? (
|
||||
<motion.div
|
||||
key="empty-cart"
|
||||
key="loading"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
@ -141,23 +121,10 @@ export default function CartPage() {
|
||||
<div className="inline-flex items-center justify-center w-24 h-24 md:w-32 md:h-32 bg-tertiary/20 rounded-full mb-8">
|
||||
<ShoppingBag className="h-12 w-12 md:h-16 md:w-16 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl md:text-2xl font-medium text-primary mb-4">Ваша корзина пуста</h2>
|
||||
<p className="text-gray-600 mb-10 max-w-md mx-auto text-base md:text-lg">
|
||||
Добавьте товары в корзину, чтобы оформить заказ. Вы можете найти множество интересных товаров в нашем
|
||||
каталоге.
|
||||
</p>
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary/90 text-white rounded-none min-w-[220px] py-6"
|
||||
asChild
|
||||
>
|
||||
<Link href="/catalog">
|
||||
Перейти в каталог
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<h2 className="text-xl md:text-2xl font-medium text-primary mb-4">Загрузка корзины...</h2>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-3 gap-8 md:gap-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-12">
|
||||
{/* Cart Items */}
|
||||
<motion.div
|
||||
ref={ref1}
|
||||
@ -166,124 +133,129 @@ export default function CartPage() {
|
||||
animate={inView1 ? "visible" : "hidden"}
|
||||
className="md:col-span-2 space-y-6 md:space-y-8"
|
||||
>
|
||||
{cartItems.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
variants={itemVariants}
|
||||
className="flex flex-col sm:flex-row gap-6 p-6 border border-gray-100 hover:border-primary/20 transition-all duration-300 bg-white shadow-sm hover:shadow-md"
|
||||
>
|
||||
<div className="relative w-full sm:w-32 md:w-40 h-48 sm:h-40 flex-shrink-0 overflow-hidden">
|
||||
<Link href={`/product/${item.id}`}>
|
||||
<Image
|
||||
src={item.image || "/placeholder.svg"}
|
||||
alt={item.name}
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 hover:scale-105"
|
||||
/>
|
||||
</Link>
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm mb-4">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex items-center">
|
||||
<ShoppingBag className="h-5 w-5 mr-2 text-primary" />
|
||||
<h2 className="text-lg font-semibold">Товары в корзине ({cart.items.length})</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<Link
|
||||
href={`/product/${item.id}`}
|
||||
className="text-base md:text-lg font-medium text-primary hover:text-secondary transition-colors duration-200"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => removeItem(item.id)}
|
||||
className="text-gray-400 hover:text-primary transition-colors duration-200"
|
||||
aria-label="Удалить товар"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-sm text-gray-500 space-y-1">
|
||||
<div><span className="text-gray-600">Размер:</span> {item.size}</div>
|
||||
<div><span className="text-gray-600">Цвет:</span> {item.color}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center border border-gray-200 bg-white">
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, item.quantity - 1)}
|
||||
className="w-10 h-10 flex items-center justify-center text-gray-500 hover:text-primary transition-colors"
|
||||
aria-label="Уменьшить количество"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="w-12 text-center font-medium">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, item.quantity + 1)}
|
||||
className="w-10 h-10 flex items-center justify-center text-gray-500 hover:text-primary transition-colors"
|
||||
aria-label="Увеличить количество"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<button
|
||||
onClick={() => moveToWishlist(item.id)}
|
||||
className="text-gray-500 hover:text-primary transition-colors duration-200 flex items-center text-sm"
|
||||
>
|
||||
<Heart className="h-4 w-4 mr-2" />
|
||||
<span className="hidden sm:inline">В избранное</span>
|
||||
</button>
|
||||
<div className="text-lg font-medium text-primary">
|
||||
{(item.price * item.quantity).toLocaleString()} ₽
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* Promo Code */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="mt-8 p-6 border border-gray-100 bg-tertiary/5 shadow-sm"
|
||||
>
|
||||
<h3 className="text-lg font-medium text-primary mb-4">Промокод</h3>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Введите промокод"
|
||||
value={promoCode}
|
||||
onChange={(e) => setPromoCode(e.target.value)}
|
||||
className="border-gray-200 focus:border-primary focus:ring-primary rounded-none"
|
||||
disabled={promoApplied}
|
||||
/>
|
||||
<Button
|
||||
onClick={applyPromoCode}
|
||||
className={`rounded-none ${
|
||||
promoApplied
|
||||
? "bg-green-600 hover:bg-green-700"
|
||||
: "bg-primary hover:bg-primary/90"
|
||||
} text-white transition-colors duration-200`}
|
||||
disabled={promoApplied || !promoCode.trim()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm hover:bg-red-50 hover:text-red-600 hover:border-red-200"
|
||||
onClick={clearCart}
|
||||
disabled={loading || cart.items.length === 0}
|
||||
>
|
||||
{promoApplied ? "Применен" : "Применить"}
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
Очистить
|
||||
</Button>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{promoApplied && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-3 text-sm text-green-600 flex items-center"
|
||||
|
||||
<div className="space-y-6">
|
||||
{cart.items.map((item) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
variants={itemVariants}
|
||||
className="flex flex-col sm:flex-row gap-4 sm:items-center border-b pb-6 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<svg className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Промокод успешно применен! Скидка 20%
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
{/* Изображение товара */}
|
||||
<div className="relative w-24 h-32 sm:w-32 sm:h-40 flex-shrink-0 rounded-md overflow-hidden border border-gray-200">
|
||||
{item.image || item.product_image ? (
|
||||
<Link href={`/product/${item.productId || item.product_id}`}>
|
||||
<Image
|
||||
src={item.image || item.product_image || ""}
|
||||
alt={item.name || item.product_name || "Товар"}
|
||||
fill
|
||||
className="object-cover bg-secondary/10"
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-100">
|
||||
<Package className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Информация о товаре */}
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
<Link
|
||||
href={`/product/${item.productId || item.product_id}`}
|
||||
className="text-primary hover:text-primary/80 transition-colors duration-200"
|
||||
>
|
||||
<h3 className="font-medium text-base sm:text-lg line-clamp-2">
|
||||
{item.name || item.product_name || "Товар"}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
{/* Вариант товара - размер и цвет */}
|
||||
<div className="text-sm text-gray-600">
|
||||
{(item.size || item.variant_name) && (
|
||||
<span>Размер: {item.size || item.variant_name}</span>
|
||||
)}
|
||||
{(item.size || item.variant_name) && item.color && <span className="mx-2">•</span>}
|
||||
{item.color && <span>Цвет: {item.color}</span>}
|
||||
</div>
|
||||
|
||||
{/* Количество и цена */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 pt-2">
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full relative"
|
||||
onClick={() => handleQuantityChange(item.id, Math.max(1, item.quantity - 1))}
|
||||
disabled={processing[item.id] || item.quantity <= 1}
|
||||
>
|
||||
{processing[item.id] ? (
|
||||
<span className="h-3 w-3 block rounded-full border-2 border-primary border-t-transparent animate-spin"></span>
|
||||
) : (
|
||||
<Minus className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<span className="text-center w-8 select-none">{item.quantity}</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full relative"
|
||||
onClick={() => handleQuantityChange(item.id, item.quantity + 1)}
|
||||
disabled={processing[item.id]}
|
||||
>
|
||||
{processing[item.id] ? (
|
||||
<span className="h-3 w-3 block rounded-full border-2 border-primary border-t-transparent animate-spin"></span>
|
||||
) : (
|
||||
<Plus className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Цена:</div>
|
||||
<div className="font-medium text-primary">{formatPrice(item.price * item.quantity)}</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-gray-400 hover:text-red-500 relative"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
disabled={processing[item.id]}
|
||||
>
|
||||
{processing[item.id] ? (
|
||||
<span className="h-4 w-4 block rounded-full border-2 border-red-400 border-t-transparent animate-spin"></span>
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Order Summary */}
|
||||
@ -292,118 +264,152 @@ export default function CartPage() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView2 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
className="md:sticky md:top-24 h-fit"
|
||||
className="md:sticky md:top-24 h-fit hidden md:block"
|
||||
>
|
||||
<div className="border border-gray-100 p-6 bg-white shadow-sm">
|
||||
<h2 className="text-xl font-medium text-primary mb-6">Ваш заказ</h2>
|
||||
<div className="border border-gray-100 p-6 rounded-lg bg-white shadow-sm">
|
||||
<h2 className="text-xl font-medium text-primary mb-6 flex items-center">
|
||||
<ShoppingBag className="h-5 w-5 mr-2 text-primary" />
|
||||
Ваш заказ
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4 text-base">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Товары ({cartItems.length}):</span>
|
||||
<span>{subtotal.toLocaleString()} ₽</span>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Товары ({cart.total_items}):</span>
|
||||
<span className="font-medium">{formatPrice(cart.total_price)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Доставка:</span>
|
||||
<span>{shipping === 0 ? "Бесплатно" : `${shipping.toLocaleString()} ₽`}</span>
|
||||
<span className={shipping === 0 ? "text-green-600 font-medium" : "font-medium"}>
|
||||
{shipping === 0 ? "Бесплатно" : `${formatPrice(shipping)}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{promoApplied && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="flex justify-between text-green-600"
|
||||
>
|
||||
<span>Скидка (20%):</span>
|
||||
<span>-{discount.toLocaleString()} ₽</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="flex justify-between font-medium text-lg">
|
||||
<div className="flex justify-between font-medium text-lg items-center">
|
||||
<span>Итого:</span>
|
||||
<span className="text-primary">{total.toLocaleString()} ₽</span>
|
||||
<span className="text-xl text-primary">{formatPrice(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full mt-8 bg-primary hover:bg-primary/90 text-white rounded-none py-6 transition-colors duration-200"
|
||||
{/* Кнопка оформления заказа */}
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary/90 py-6 text-base font-medium mt-6"
|
||||
disabled={cart.items.length === 0}
|
||||
asChild
|
||||
>
|
||||
<Link href="/checkout">
|
||||
<Link href="/checkout/contact" className="flex items-center justify-center">
|
||||
Оформить заказ
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="mt-4 text-xs text-gray-500 text-center">
|
||||
Нажимая кнопку "Оформить заказ", вы соглашаетесь с условиями <Link href="/terms" className="underline hover:text-primary transition-colors duration-200">пользовательского соглашения</Link> и <Link href="/privacy" className="underline hover:text-primary transition-colors duration-200">политикой конфиденциальности</Link>.
|
||||
</div>
|
||||
<p className="text-xs text-center text-gray-500 mt-3">
|
||||
Нажимая кнопку, вы принимаете условия публичной оферты
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-6 border border-gray-100 bg-tertiary/5 shadow-sm">
|
||||
<div className="mt-6 p-6 border border-gray-100 bg-tertiary/5 rounded-lg shadow-sm">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Truck className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
<span className="text-sm">Бесплатная доставка от 5000 ₽</span>
|
||||
<div className="flex items-start gap-3">
|
||||
<Truck className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-sm font-medium block mb-0.5">Бесплатная доставка</span>
|
||||
<span className="text-xs text-gray-500">при заказе от 5000 ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
<span className="text-sm">Возврат в течение 14 дней</span>
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-sm font-medium block mb-0.5">Возврат в течение 14 дней</span>
|
||||
<span className="text-xs text-gray-500">без объяснения причин</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
<span className="text-sm">Безопасная оплата</span>
|
||||
<div className="flex items-start gap-3">
|
||||
<ShieldCheck className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-sm font-medium block mb-0.5">Безопасная оплата</span>
|
||||
<span className="text-xs text-gray-500">все платежи защищены</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Order Summary for mobile - visible only on small screens */}
|
||||
<div className="block md:hidden">
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary/90 py-6 text-base font-medium mb-8 flex items-center justify-center"
|
||||
disabled={cart.items.length === 0}
|
||||
onClick={() => {
|
||||
const orderSummary = document.getElementById('mobile-order-summary');
|
||||
if (orderSummary) {
|
||||
orderSummary.classList.toggle('hidden');
|
||||
|
||||
// Меняем иконку и текст при раскрытии/скрытии
|
||||
const icon = document.getElementById('toggle-icon');
|
||||
if (icon) {
|
||||
icon.classList.toggle('rotate-270');
|
||||
icon.classList.toggle('rotate-90');
|
||||
}
|
||||
|
||||
const buttonText = document.getElementById('toggle-text');
|
||||
if (buttonText) {
|
||||
buttonText.innerText = orderSummary.classList.contains('hidden') ?
|
||||
'Посмотреть итог заказа' : 'Скрыть итог заказа';
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span id="toggle-text">Посмотреть итог заказа</span>
|
||||
<span className="ml-2 font-medium">{formatPrice(total)}</span>
|
||||
<ChevronLeft id="toggle-icon" className="ml-auto h-5 w-5 transform rotate-90" />
|
||||
</Button>
|
||||
|
||||
<div id="mobile-order-summary" className="hidden bg-white p-6 rounded-lg shadow-sm mb-8 border border-gray-100">
|
||||
<h2 className="text-xl font-medium text-primary mb-6 flex items-center">
|
||||
<ShoppingBag className="h-5 w-5 mr-2 text-primary" />
|
||||
Сводка заказа
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4 text-base">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Товары ({cart.total_items}):</span>
|
||||
<span className="font-medium">{formatPrice(cart.total_price)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Доставка:</span>
|
||||
<span className={shipping === 0 ? "text-green-600 font-medium" : "font-medium"}>
|
||||
{shipping === 0 ? "Бесплатно" : `${formatPrice(shipping)}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="flex justify-between font-medium text-lg items-center">
|
||||
<span>Итого:</span>
|
||||
<span className="text-xl text-primary">{formatPrice(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary/90 py-6 text-base font-medium mt-6"
|
||||
disabled={cart.items.length === 0}
|
||||
asChild
|
||||
>
|
||||
<Link href="/checkout/contact" className="flex items-center justify-center">
|
||||
Оформить заказ
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Recommended Products */}
|
||||
{cartItems.length > 0 && (
|
||||
<motion.div
|
||||
ref={ref3}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={inView3 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="mt-16 md:mt-24"
|
||||
>
|
||||
<h2 className="text-xl md:text-2xl font-light text-primary mb-8 tracking-tight">Вам также может понравиться</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-6">
|
||||
{recommendedProducts.map((product, index) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView3 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="border border-gray-100 hover:border-primary/20 transition-all duration-300 group bg-white shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Link href={`/product/${product.id}`} className="block">
|
||||
<div className="relative aspect-[3/4] overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 md:p-6">
|
||||
<h3 className="text-sm md:text-base font-medium text-primary line-clamp-2 mb-2 group-hover:text-secondary transition-colors duration-200">{product.name}</h3>
|
||||
<p className="text-base md:text-lg font-medium text-primary">{product.price.toLocaleString()} ₽</p>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,156 +1,361 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { notFound, useRouter } from "next/navigation"
|
||||
import { Product } from "@/lib/catalog"
|
||||
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 catalogService, { ProductDetails } from "@/lib/catalog"
|
||||
import { formatPrice } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { fetchProduct, ApiResponse } from "@/lib/api"
|
||||
import { ImageSlider } from "@/components/product/ImageSlider"
|
||||
import { ProductDetails } from "@/components/product/ProductDetails"
|
||||
import { ProductDetails as ProductDetailsComponent } from "@/components/product/ProductDetails"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { motion } from "framer-motion"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useCart } from "@/hooks/useCart"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function ProductPage({ params }: { params: { slug: string } }) {
|
||||
interface ProductPageProps {
|
||||
params: {
|
||||
slug: string
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProductPage({ params }: ProductPageProps) {
|
||||
const [product, setProduct] = useState<ProductDetails | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [product, setProduct] = useState<Product | null>(null)
|
||||
const router = useRouter()
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [mainRef, mainInView] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [imageRef, imageInView] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [detailsRef, detailsInView] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const { addToCart, loading: cartLoading } = useCart()
|
||||
|
||||
useEffect(() => {
|
||||
const loadProduct = async () => {
|
||||
setLoading(true)
|
||||
// Загрузка товара при монтировании компонента
|
||||
useState(() => {
|
||||
const fetchProduct = async () => {
|
||||
try {
|
||||
console.log(`Загрузка товара по slug: ${params.slug}`);
|
||||
setLoading(true)
|
||||
const productData = await catalogService.getProductBySlug(params.slug)
|
||||
|
||||
// fetchProduct возвращает ApiResponse<Product>
|
||||
const response = await fetchProduct(params.slug);
|
||||
console.log("Ответ API:", response);
|
||||
|
||||
// Проверяем структуру ответа
|
||||
if (!response.success || !response.data) {
|
||||
const errorMsg = response.error || "Продукт не найден";
|
||||
console.error("Ошибка при загрузке продукта:", errorMsg);
|
||||
setError(errorMsg);
|
||||
return;
|
||||
if (!productData) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const productData = response.data;
|
||||
console.log("Данные продукта:", productData);
|
||||
|
||||
// Проверяем изображения продукта
|
||||
if (productData.images && Array.isArray(productData.images)) {
|
||||
console.log("Изображения продукта:", productData.images);
|
||||
} else {
|
||||
console.warn("Продукт не содержит изображений или images не является массивом");
|
||||
// Устанавливаем пустой массив, чтобы избежать ошибок
|
||||
productData.images = [];
|
||||
}
|
||||
|
||||
// Устанавливаем данные продукта
|
||||
setProduct(productData as Product);
|
||||
setProduct(productData)
|
||||
} catch (err) {
|
||||
console.error("Ошибка при загрузке товара:", err)
|
||||
setError(err instanceof Error ? err.message : "Ошибка при загрузке продукта")
|
||||
setError(err instanceof Error ? err : new Error("Неизвестная ошибка"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchProduct()
|
||||
})
|
||||
|
||||
loadProduct()
|
||||
}, [params.slug])
|
||||
|
||||
if (error && !loading) {
|
||||
return (
|
||||
<div className="container py-12 text-center">
|
||||
<h1 className="text-2xl font-semibold mb-4">Ошибка</h1>
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={() => router.push("/catalog")}
|
||||
className="mt-4 bg-primary text-white px-4 py-2 rounded hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Вернуться в каталог
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Если все еще загружается, показываем скелетон
|
||||
if (loading) {
|
||||
return <ProductSkeleton />
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return notFound()
|
||||
// Если произошла ошибка, показываем сообщение
|
||||
if (error || !product) {
|
||||
return (
|
||||
<div className="container mx-auto py-20 px-4 text-center">
|
||||
<div className="max-w-md mx-auto bg-white p-8 rounded-2xl shadow-lg">
|
||||
<h1 className="text-2xl font-medium mb-4">Товар не найден</h1>
|
||||
<p className="text-gray-600 mb-6">К сожалению, запрашиваемый товар не найден или произошла ошибка при загрузке данных.</p>
|
||||
<Link href="/catalog">
|
||||
<Button className="rounded-full bg-primary hover:bg-primary/90 text-white px-8 py-6">Вернуться в каталог</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container py-6 md:py-10">
|
||||
<nav className="flex flex-wrap items-center text-sm text-gray-500 mb-4 sm:mb-6 overflow-x-auto whitespace-nowrap pb-2">
|
||||
<a href="/" className="hover:text-primary">
|
||||
Главная
|
||||
</a>
|
||||
<span className="mx-2">›</span>
|
||||
<a href="/catalog" className="hover:text-primary">
|
||||
Каталог
|
||||
</a>
|
||||
{product.category && (
|
||||
<>
|
||||
<span className="mx-2">›</span>
|
||||
<a
|
||||
href={`/catalog?category_id=${product.category.id}`}
|
||||
className="hover:text-primary"
|
||||
<>
|
||||
<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"
|
||||
>
|
||||
{product.category.name}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<span className="mx-2">›</span>
|
||||
<span className="text-primary font-medium truncate max-w-[150px] sm:max-w-xs">
|
||||
{product.name}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12">
|
||||
<div>
|
||||
<ImageSlider
|
||||
images={product.images || []}
|
||||
productName={product.name}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* Блок информации о товаре */}
|
||||
<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.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="mb-6"
|
||||
>
|
||||
{product.category_name && (
|
||||
<div className="text-sm text-primary/60 mb-2 uppercase tracking-wider font-medium">
|
||||
{product.category_name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="text-3xl md:text-4xl font-light mb-4 text-primary tracking-tight">{product.name}</h1>
|
||||
|
||||
{/* Цена */}
|
||||
<div className="flex items-center gap-3">
|
||||
{product.discount_price ? (
|
||||
<>
|
||||
<span className="text-2xl font-medium text-primary">
|
||||
{formatPrice(product.discount_price)}
|
||||
</span>
|
||||
<span className="text-lg text-primary/50 line-through">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-2xl font-medium text-primary">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<Separator className="my-6 bg-primary/10" />
|
||||
|
||||
{/* Компонент выбора размера и количества */}
|
||||
<ProductDetailsComponent product={product} />
|
||||
|
||||
<Separator className="my-6 bg-primary/10" />
|
||||
|
||||
{/* Описание товара */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
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">
|
||||
<TabsTrigger
|
||||
value="description"
|
||||
className="rounded-md data-[state=active]:bg-white data-[state=active]:shadow-sm text-sm py-2.5"
|
||||
>
|
||||
Описание
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="care"
|
||||
className="rounded-md data-[state=active]:bg-white data-[state=active]:shadow-sm text-sm py-2.5"
|
||||
>
|
||||
Уход
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<motion.div
|
||||
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"
|
||||
>
|
||||
<TabsContent value="description" className="text-primary/80 text-sm leading-relaxed mt-0">
|
||||
{product.description ? (
|
||||
typeof product.description === 'string' ? (
|
||||
<div className="prose prose-neutral prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: product.description }} />
|
||||
) : (
|
||||
<p>Описание недоступно</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-primary/50 italic">Описание отсутствует</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="care" className="text-primary/80 text-sm leading-relaxed mt-0">
|
||||
{product.care_instructions ? (
|
||||
typeof product.care_instructions === 'string' ? (
|
||||
<div className="prose prose-neutral prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: product.care_instructions }} />
|
||||
) : (
|
||||
<p>Инструкции по уходу недоступны</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-primary/50 italic">Инструкции по уходу отсутствуют</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
</motion.div>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
|
||||
{/* Информация о доставке и возврате */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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="flex items-center mb-2">
|
||||
<div className="bg-primary p-2 rounded-full mr-3 text-white">
|
||||
<Truck className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="font-medium text-primary">Доставка</h3>
|
||||
</div>
|
||||
<p className="text-sm text-primary/70">
|
||||
Доставка по всей России 1-3 рабочих дня
|
||||
</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="flex items-center mb-2">
|
||||
<div className="bg-primary p-2 rounded-full mr-3 text-white">
|
||||
<RotateCcw className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="font-medium text-primary">Возврат</h3>
|
||||
</div>
|
||||
<p className="text-sm text-primary/70">
|
||||
Бесплатный возврат в течение 14 дней
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ProductDetails product={product} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductSkeleton() {
|
||||
return (
|
||||
<div className="container py-6 md:py-10">
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<Skeleton className="h-6 w-2/3" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12">
|
||||
<div>
|
||||
<Skeleton className="aspect-square w-full rounded-lg" />
|
||||
<div className="mt-3 flex gap-2 justify-center">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="w-16 h-16 rounded" />
|
||||
))}
|
||||
</div>
|
||||
<div className="min-h-screen bg-white">
|
||||
<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" />
|
||||
<Skeleton className="h-5 w-64 sm:w-96" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-4/5" />
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<div className="space-y-1 mt-6">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
|
||||
<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>
|
||||
<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" />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
178
frontend/app/(main)/checkout/contact/page.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { ShoppingBag, ChevronLeft, Phone } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { motion } from "framer-motion"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
import { useCart } from "@/hooks/useCart"
|
||||
import { formatPrice } from "@/lib/utils"
|
||||
|
||||
export default function ContactPage() {
|
||||
const { cart, loading } = useCart()
|
||||
const [ref, inView] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [whatsappText, setWhatsappText] = useState("")
|
||||
|
||||
// Формируем текст для WhatsApp при изменении корзины
|
||||
useEffect(() => {
|
||||
if (!loading && cart.items && cart.items.length > 0) {
|
||||
const itemsList = cart.items.map(item =>
|
||||
`- ${item.product_name || item.name} (${item.size || 'No size'}) x ${item.quantity} - ${formatPrice(item.price * item.quantity)}`
|
||||
).join('\n');
|
||||
|
||||
const message = `Здравствуйте! Хочу оформить заказ на следующие товары:\n\n${itemsList}\n\nИтого: ${formatPrice(cart.total_price)}`;
|
||||
|
||||
setWhatsappText(encodeURIComponent(message));
|
||||
}
|
||||
}, [cart, loading]);
|
||||
|
||||
// Номер телефона для связи (можно изменить)
|
||||
const phoneNumber = "+79236212432";
|
||||
// WhatsApp ссылка
|
||||
const whatsappLink = `https://wa.me/${phoneNumber.replace(/\+/g, '')}?text=${whatsappText}`;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto py-12 px-4 min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<ShoppingBag className="h-12 w-12 mx-auto mb-4 text-primary animate-pulse" />
|
||||
<h2 className="text-xl font-medium">Загрузка информации о заказе...</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!cart.items || cart.items.length === 0) {
|
||||
return (
|
||||
<div className="container mx-auto py-12 px-4 min-h-screen">
|
||||
<div className="max-w-md mx-auto text-center bg-white p-8 rounded-lg shadow-sm">
|
||||
<ShoppingBag className="h-12 w-12 mx-auto mb-4 text-primary" />
|
||||
<h2 className="text-xl font-medium mb-4">Ваша корзина пуста</h2>
|
||||
<p className="mb-6 text-gray-600">Добавьте товары в корзину, чтобы оформить заказ</p>
|
||||
<Button asChild>
|
||||
<Link href="/catalog">Перейти к покупкам</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-tertiary/5 min-h-screen">
|
||||
<div className="container mx-auto px-4 py-8 md:py-16">
|
||||
<div className="flex items-center justify-between mb-8 md:mb-12">
|
||||
<Link href="/cart" className="text-primary hover:text-primary/80 flex items-center text-sm md:text-base transition-colors duration-200">
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Вернуться в корзину
|
||||
</Link>
|
||||
<h1 className="text-2xl md:text-3xl lg:text-4xl font-light text-primary tracking-tight">Оформление заказа</h1>
|
||||
<div className="w-[120px] md:hidden"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="bg-white rounded-lg shadow-sm p-6 md:p-8 mb-8"
|
||||
>
|
||||
<h2 className="text-xl font-medium mb-6 flex items-center">
|
||||
<ShoppingBag className="h-5 w-5 mr-2 text-primary" />
|
||||
Ваш заказ
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{cart.items.map((item) => (
|
||||
<div key={item.id} className="flex items-center border-b pb-4">
|
||||
<div className="relative w-16 h-20 flex-shrink-0 rounded-md overflow-hidden bg-gray-100 mr-4">
|
||||
{item.image || item.product_image ? (
|
||||
<Image
|
||||
src={item.image || item.product_image || ""}
|
||||
alt={item.name || item.product_name || "Товар"}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-100">
|
||||
<ShoppingBag className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium">{item.product_name || item.name}</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Размер: {item.size || 'Не указан'} | Количество: {item.quantity}
|
||||
</p>
|
||||
<p className="text-sm font-medium mt-1">{formatPrice(item.price * item.quantity)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-gray-600">Товары ({cart.total_items}):</span>
|
||||
<span className="font-medium">{formatPrice(cart.total_price)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-medium text-lg items-center">
|
||||
<span>Итого:</span>
|
||||
<span className="text-xl text-primary">{formatPrice(cart.total_price)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="bg-white rounded-lg shadow-sm p-6 md:p-8"
|
||||
>
|
||||
<h2 className="text-xl font-medium mb-6">Свяжитесь с нами для оформления заказа</h2>
|
||||
|
||||
<div className="space-y-6 md:space-y-8">
|
||||
<div className="bg-tertiary/10 rounded-lg p-5 border border-tertiary/20">
|
||||
<h3 className="text-lg font-medium mb-4">
|
||||
Для оформления заказа вы можете:
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="w-full bg-green-600 hover:bg-green-700 py-6 text-white gap-2"
|
||||
>
|
||||
<Link href={whatsappLink} target="_blank" rel="noopener noreferrer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2"><path d="M3 21l1.65-3.8a9 9 0 1 1 3.4 2.9L3 21"/><path d="M9 10a.5.5 0 0 0 1 0V9a.5.5 0 0 0-1 0v1Zm0 0a5 5 0 0 0 5 5"/><path d="M9.5 11a.5.5 0 0 0 1 0v-1a.5.5 0 0 0-1 0v1Zm0 0a3 3 0 0 0 3 3"/></svg>
|
||||
Связаться в WhatsApp
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="w-full border-primary text-primary hover:bg-primary/5 py-6 gap-2"
|
||||
>
|
||||
<Link href={`tel:${phoneNumber}`}>
|
||||
<Phone className="mr-2 h-5 w-5" />
|
||||
Позвонить по телефону
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<p>Наш специалист свяжется с вами для подтверждения заказа, уточнения деталей и согласования способа оплаты и доставки.</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,514 +1,232 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { useCart } from "@/hooks/useCart"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { formatPrice } from "@/lib/utils"
|
||||
import UserInfoForm from "@/components/checkout/user-info-form"
|
||||
import DeliveryMethodSelector from "@/components/checkout/delivery-method-selector"
|
||||
import AddressForm from "@/components/checkout/address-form"
|
||||
import PaymentMethodSelector from "@/components/checkout/payment-method-selector"
|
||||
import OrderSummary from "@/components/checkout/order-summary"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
import { ChevronLeft, CreditCard, Truck, Check, ShieldCheck, Clock } from "lucide-react"
|
||||
import OrderComment from "@/components/checkout/order-comment"
|
||||
import { orderService } from "@/lib/order-service"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
// Типы для выбора способа доставки и оплаты
|
||||
export type DeliveryMethod = "cdek" | "courier"
|
||||
export type PaymentMethod = "sbp" | "card"
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const [step, setStep] = useState(1)
|
||||
const [formData, setFormData] = useState({
|
||||
const { cart, loading, clearCart } = useCart()
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
|
||||
// Состояния для обработки заказа
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// Состояния для форм и выбранных опций
|
||||
const [userInfo, setUserInfo] = useState({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
address: "",
|
||||
city: "",
|
||||
postalCode: "",
|
||||
deliveryMethod: "courier",
|
||||
paymentMethod: "card",
|
||||
comment: ""
|
||||
phone: ""
|
||||
})
|
||||
const [orderComplete, setOrderComplete] = useState(false)
|
||||
const [ref1, inView1] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [ref2, inView2] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { id, value } = e.target
|
||||
setFormData(prev => ({ ...prev, [id]: value }))
|
||||
|
||||
const [deliveryMethod, setDeliveryMethod] = useState<DeliveryMethod>("cdek")
|
||||
|
||||
const [address, setAddress] = useState({
|
||||
city: "Новокузнецк", // По умолчанию
|
||||
street: "",
|
||||
house: "",
|
||||
apartment: "",
|
||||
postalCode: ""
|
||||
})
|
||||
|
||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("sbp")
|
||||
|
||||
const [orderComment, setOrderComment] = useState("")
|
||||
|
||||
// Проверка валидности заполнения формы
|
||||
const isFormValid = () => {
|
||||
const isUserInfoValid =
|
||||
userInfo.firstName &&
|
||||
userInfo.email &&
|
||||
userInfo.phone
|
||||
|
||||
const isAddressValid =
|
||||
address.city &&
|
||||
address.street &&
|
||||
address.house
|
||||
|
||||
return isUserInfoValid && isAddressValid
|
||||
}
|
||||
|
||||
const handleRadioChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// В реальном приложении здесь был бы код для отправки заказа
|
||||
console.log("Заказ оформлен:", formData)
|
||||
setOrderComplete(true)
|
||||
}
|
||||
|
||||
// Mock cart items
|
||||
const cartItems = [
|
||||
{
|
||||
id: 1,
|
||||
name: "ПЛАТЬЕ С ЦВЕТОЧНЫМ ПРИНТОМ",
|
||||
price: 5990,
|
||||
quantity: 1,
|
||||
size: "M",
|
||||
color: "Белый",
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "БЛУЗА ИЗ НАТУРАЛЬНОГО ШЕЛКА",
|
||||
price: 4990,
|
||||
quantity: 2,
|
||||
size: "S",
|
||||
color: "Синий",
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
},
|
||||
]
|
||||
|
||||
// Calculate totals
|
||||
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0)
|
||||
const shipping = formData.deliveryMethod === "courier" ? 500 : formData.deliveryMethod === "pickup" ? 300 : 400
|
||||
const total = subtotal + shipping
|
||||
|
||||
// Анимационные варианты
|
||||
const fadeIn = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.5 } }
|
||||
}
|
||||
|
||||
const staggerContainer = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1
|
||||
|
||||
// Обработчик оформления заказа
|
||||
const handleSubmitOrder = async () => {
|
||||
if (!isFormValid()) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Форма не заполнена",
|
||||
description: "Пожалуйста, заполните все обязательные поля"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!cart.items || cart.items.length === 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Корзина пуста",
|
||||
description: "Невозможно оформить заказ с пустой корзиной"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
|
||||
// Подготавливаем данные для заказа
|
||||
const orderData = {
|
||||
userInfo,
|
||||
items: cart.items,
|
||||
address,
|
||||
deliveryMethod,
|
||||
paymentMethod,
|
||||
comment: orderComment
|
||||
}
|
||||
|
||||
// Сохраняем состояние корзины для показа на странице успешного оформления
|
||||
try {
|
||||
// Создаем копию корзины
|
||||
const orderCartCopy = JSON.stringify(cart);
|
||||
localStorage.setItem('last_order_cart', orderCartCopy);
|
||||
} catch (error) {
|
||||
console.error("Ошибка при сохранении состояния корзины:", error);
|
||||
}
|
||||
|
||||
// Создаем заказ
|
||||
const order = await orderService.createOrder(orderData)
|
||||
|
||||
// Очищаем корзину
|
||||
await clearCart()
|
||||
|
||||
// Перенаправляем на страницу успешного оформления
|
||||
router.push(`/checkout/success?order_id=${order.orderId}&total=${order.total}&email=${encodeURIComponent(userInfo.email)}`)
|
||||
} catch (error) {
|
||||
console.error("Ошибка при оформлении заказа:", error)
|
||||
// Удаляем временные данные для предотвращения утечки
|
||||
try {
|
||||
localStorage.removeItem('last_order_cart');
|
||||
} catch (error) {
|
||||
console.error("Ошибка при очистке данных заказа:", error);
|
||||
}
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Ошибка",
|
||||
description: "Произошла ошибка при оформлении заказа. Пожалуйста, попробуйте снова."
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (orderComplete) {
|
||||
|
||||
// Проверка наличия товаров в корзине
|
||||
if (!loading && (!cart.items || cart.items.length === 0)) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="container mx-auto px-4 py-16 md:py-24 text-center max-w-2xl"
|
||||
>
|
||||
<div className="inline-flex items-center justify-center w-24 h-24 md:w-32 md:h-32 bg-primary/10 rounded-full mb-8">
|
||||
<Check className="h-12 w-12 md:h-16 md:w-16 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl lg:text-4xl font-light text-primary mb-6 tracking-tight">Заказ успешно оформлен</h1>
|
||||
<p className="text-gray-600 mb-8 text-base md:text-lg">
|
||||
Спасибо за ваш заказ! Мы отправили подтверждение на вашу электронную почту.
|
||||
Номер вашего заказа: <span className="font-medium text-primary">ORD-{Math.floor(100000 + Math.random() * 900000)}</span>
|
||||
</p>
|
||||
<div className="flex flex-col md:flex-row gap-4 justify-center">
|
||||
<div className="container mx-auto py-12 px-4">
|
||||
<h1 className="text-2xl font-bold mb-8">Оформление заказа</h1>
|
||||
<div className="bg-white p-8 rounded-lg shadow-sm text-center">
|
||||
<p className="text-lg mb-6">Ваша корзина пуста</p>
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary/90 text-white rounded-none py-6"
|
||||
asChild
|
||||
onClick={() => router.push("/catalog")}
|
||||
className="bg-black hover:bg-neutral-800"
|
||||
>
|
||||
<Link href="/catalog">
|
||||
Продолжить покупки
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:bg-primary/5 rounded-none py-6"
|
||||
asChild
|
||||
>
|
||||
<Link href="/">
|
||||
Вернуться на главную
|
||||
</Link>
|
||||
Перейти в каталог
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white min-h-screen">
|
||||
<div className="container mx-auto px-4 py-8 md:py-16">
|
||||
<div className="flex items-center mb-8 md:mb-12">
|
||||
<Link href="/cart" className="text-primary hover:text-primary/80 flex items-center text-sm md:text-base transition-colors duration-200">
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Вернуться в корзину
|
||||
</Link>
|
||||
<div className="container mx-auto py-12 px-4">
|
||||
<h1 className="text-2xl font-bold mb-8">Оформление заказа</h1>
|
||||
|
||||
<div className="grid md:grid-cols-12 gap-8">
|
||||
{/* Левая колонка - формы и выбор опций */}
|
||||
<div className="md:col-span-8 space-y-6">
|
||||
{/* Информация о пользователе */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Информация о получателе</h2>
|
||||
<UserInfoForm userInfo={userInfo} setUserInfo={setUserInfo} />
|
||||
</div>
|
||||
|
||||
{/* Способ доставки */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Способ доставки</h2>
|
||||
<DeliveryMethodSelector
|
||||
selected={deliveryMethod}
|
||||
onSelect={setDeliveryMethod}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Адрес доставки */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Адрес доставки</h2>
|
||||
<AddressForm
|
||||
address={address}
|
||||
setAddress={setAddress}
|
||||
deliveryMethod={deliveryMethod}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Комментарий к заказу */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Комментарий к заказу</h2>
|
||||
<OrderComment comment={orderComment} setComment={setOrderComment} />
|
||||
</div>
|
||||
|
||||
{/* Способ оплаты */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Способ оплаты</h2>
|
||||
<PaymentMethodSelector
|
||||
selected={paymentMethod}
|
||||
onSelect={setPaymentMethod}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl md:text-3xl lg:text-4xl font-light text-primary mb-8 md:mb-12 tracking-tight">Оформление заказа</h1>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8 md:gap-12">
|
||||
{/* Checkout Form */}
|
||||
<motion.div
|
||||
ref={ref1}
|
||||
initial="hidden"
|
||||
animate={inView1 ? "visible" : "hidden"}
|
||||
variants={staggerContainer}
|
||||
className="md:col-span-2 space-y-8"
|
||||
>
|
||||
{/* Step 1: Contact Information */}
|
||||
<motion.div
|
||||
variants={fadeIn}
|
||||
className={`transition-opacity duration-300 ${step !== 1 && "opacity-60"}`}
|
||||
{/* Правая колонка - сводка заказа */}
|
||||
<div className="md:col-span-4">
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm sticky top-4">
|
||||
<h2 className="text-lg font-semibold mb-4">Ваш заказ</h2>
|
||||
<OrderSummary cart={cart} />
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<Button
|
||||
className="w-full bg-black hover:bg-neutral-800 mt-4"
|
||||
onClick={handleSubmitOrder}
|
||||
disabled={!isFormValid() || loading || isSubmitting}
|
||||
>
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="bg-primary text-white w-10 h-10 rounded-full flex items-center justify-center mr-4 font-medium">
|
||||
1
|
||||
</div>
|
||||
<h2 className="text-xl font-medium text-primary">Контактная информация</h2>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{step === 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-6 p-6 border border-gray-200 shadow-sm bg-white"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName" className="text-gray-700">
|
||||
Имя
|
||||
</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
placeholder="Введите имя"
|
||||
className="border-gray-200 focus:border-primary focus:ring-primary rounded-none"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName" className="text-gray-700">
|
||||
Фамилия
|
||||
</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
placeholder="Введите фамилию"
|
||||
className="border-gray-200 focus:border-primary focus:ring-primary rounded-none"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-gray-700">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="example@mail.com"
|
||||
className="border-gray-200 focus:border-primary focus:ring-primary rounded-none"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone" className="text-gray-700">
|
||||
Телефон
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
placeholder="+7 (___) ___-__-__"
|
||||
className="border-gray-200 focus:border-primary focus:ring-primary rounded-none"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full md:w-auto bg-primary hover:bg-primary/90 text-white rounded-none py-6 px-8 transition-colors duration-200"
|
||||
onClick={() => setStep(2)}
|
||||
disabled={!formData.firstName || !formData.email || !formData.phone}
|
||||
>
|
||||
Продолжить
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Step 2: Shipping */}
|
||||
<motion.div
|
||||
variants={fadeIn}
|
||||
className={`transition-opacity duration-300 ${step !== 2 && "opacity-60"}`}
|
||||
>
|
||||
<div className="flex items-center mb-6">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center mr-4 font-medium ${step >= 2 ? "bg-primary text-white" : "bg-gray-200 text-gray-500"}`}>
|
||||
2
|
||||
</div>
|
||||
<h2 className="text-xl font-medium text-primary">Доставка</h2>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{step === 2 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-6 p-6 border border-gray-200 shadow-sm bg-white"
|
||||
>
|
||||
<RadioGroup
|
||||
defaultValue={formData.deliveryMethod}
|
||||
onValueChange={(value) => handleRadioChange("deliveryMethod", value)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex items-start space-x-3 p-4 border border-gray-200 hover:border-primary/20 transition-colors">
|
||||
<RadioGroupItem value="courier" id="courier" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="courier" className="text-base font-medium text-primary">
|
||||
Курьерская доставка
|
||||
</Label>
|
||||
<p className="text-sm text-gray-600">Доставка курьером по адресу</p>
|
||||
<p className="text-sm font-medium text-primary mt-1">500 ₽</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 p-4 border border-gray-200 hover:border-primary/20 transition-colors">
|
||||
<RadioGroupItem value="pickup" id="pickup" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="pickup" className="text-base font-medium text-primary">
|
||||
Самовывоз из пункта выдачи
|
||||
</Label>
|
||||
<p className="text-sm text-gray-600">Выберите удобный пункт выдачи</p>
|
||||
<p className="text-sm font-medium text-primary mt-1">300 ₽</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 p-4 border border-gray-200 hover:border-primary/20 transition-colors">
|
||||
<RadioGroupItem value="post" id="post" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="post" className="text-base font-medium text-primary">
|
||||
Почта России
|
||||
</Label>
|
||||
<p className="text-sm text-gray-600">Доставка в отделение Почты России</p>
|
||||
<p className="text-sm font-medium text-primary mt-1">400 ₽</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address" className="text-gray-700">
|
||||
Адрес доставки
|
||||
</Label>
|
||||
<Input
|
||||
id="address"
|
||||
placeholder="Улица, дом, квартира"
|
||||
className="border-gray-200 focus:border-primary focus:ring-primary rounded-none"
|
||||
value={formData.address}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city" className="text-gray-700">
|
||||
Город
|
||||
</Label>
|
||||
<Input
|
||||
id="city"
|
||||
placeholder="Введите город"
|
||||
className="border-gray-200 focus:border-primary focus:ring-primary rounded-none"
|
||||
value={formData.city}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postalCode" className="text-gray-700">
|
||||
Почтовый индекс
|
||||
</Label>
|
||||
<Input
|
||||
id="postalCode"
|
||||
placeholder="Введите индекс"
|
||||
className="border-gray-200 focus:border-primary focus:ring-primary rounded-none max-w-xs"
|
||||
value={formData.postalCode}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-gray-300 text-gray-700 hover:bg-gray-50 rounded-none py-6 px-8 transition-colors duration-200"
|
||||
onClick={() => setStep(1)}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary/90 text-white rounded-none py-6 px-8 transition-colors duration-200"
|
||||
onClick={() => setStep(3)}
|
||||
disabled={!formData.address || !formData.city}
|
||||
>
|
||||
Продолжить
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Step 3: Payment */}
|
||||
<motion.div
|
||||
variants={fadeIn}
|
||||
className={`transition-opacity duration-300 ${step !== 3 && "opacity-60"}`}
|
||||
>
|
||||
<div className="flex items-center mb-6">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center mr-4 font-medium ${step >= 3 ? "bg-primary text-white" : "bg-gray-200 text-gray-500"}`}>
|
||||
3
|
||||
</div>
|
||||
<h2 className="text-xl font-medium text-primary">Оплата</h2>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{step === 3 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-6 p-6 border border-gray-200 shadow-sm bg-white"
|
||||
>
|
||||
<RadioGroup
|
||||
defaultValue={formData.paymentMethod}
|
||||
onValueChange={(value) => handleRadioChange("paymentMethod", value)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex items-start space-x-3 p-4 border border-gray-200 hover:border-primary/20 transition-colors">
|
||||
<RadioGroupItem value="card" id="card" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="card" className="text-base font-medium text-primary flex items-center">
|
||||
<CreditCard className="h-5 w-5 mr-2" />
|
||||
Банковская карта
|
||||
</Label>
|
||||
<p className="text-sm text-gray-600">Visa, MasterCard, МИР</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 p-4 border border-gray-200 hover:border-primary/20 transition-colors">
|
||||
<RadioGroupItem value="cash" id="cash" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="cash" className="text-base font-medium text-primary">
|
||||
Наличными при получении
|
||||
</Label>
|
||||
<p className="text-sm text-gray-600">Оплата курьеру или в пункте выдачи</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<div className="space-y-2 pt-4">
|
||||
<Label htmlFor="comment" className="text-gray-700">
|
||||
Комментарий к заказу (необязательно)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="comment"
|
||||
placeholder="Введите комментарий к заказу"
|
||||
className="border-gray-200 focus:border-primary focus:ring-primary rounded-none min-h-[100px]"
|
||||
value={formData.comment}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-gray-300 text-gray-700 hover:bg-gray-50 rounded-none py-6 px-8 transition-colors duration-200"
|
||||
onClick={() => setStep(2)}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary/90 text-white rounded-none py-6 px-8 transition-colors duration-200"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Оформить заказ
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Order Summary */}
|
||||
<motion.div
|
||||
ref={ref2}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView2 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
className="md:sticky md:top-24 h-fit"
|
||||
>
|
||||
<div className="border border-gray-100 p-6 bg-white shadow-sm">
|
||||
<h2 className="text-xl font-medium text-primary mb-6">Ваш заказ</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{cartItems.map((item) => (
|
||||
<div key={item.id} className="flex gap-4">
|
||||
<div className="relative w-16 h-20 flex-shrink-0">
|
||||
<Image src={item.image} alt={item.name} fill className="object-cover" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-primary line-clamp-2">{item.name}</p>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
<span>Размер: {item.size}</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>Цвет: {item.color}</span>
|
||||
</div>
|
||||
<div className="flex justify-between mt-2">
|
||||
<span className="text-xs text-gray-500">{item.quantity} × {item.price.toLocaleString()} ₽</span>
|
||||
<span className="text-sm font-medium">{(item.price * item.quantity).toLocaleString()} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Товары ({cartItems.length}):</span>
|
||||
<span>{subtotal.toLocaleString()} ₽</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Доставка:</span>
|
||||
<span>{shipping.toLocaleString()} ₽</span>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="flex justify-between font-medium text-lg">
|
||||
<span>Итого:</span>
|
||||
<span className="text-primary">{total.toLocaleString()} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-6 border border-gray-100 bg-tertiary/5 shadow-sm">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Truck className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
<span className="text-sm">Доставка по всей России</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
<span className="text-sm">Срок доставки 1-3 рабочих дня</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
<span className="text-sm">Безопасная оплата</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Оформление...
|
||||
</>
|
||||
) : (
|
||||
"Оформить заказ"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="text-sm text-gray-500 mt-4 text-center">
|
||||
Нажимая на кнопку, вы соглашаетесь с условиями обработки персональных данных и правилами магазина
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
167
frontend/app/(main)/checkout/success/page.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, ChevronLeft, Package } from "lucide-react";
|
||||
import { formatPrice } from "@/lib/utils";
|
||||
import { OrderItem } from "@/types/order";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export default function CheckoutSuccessPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [orderInfo, setOrderInfo] = useState({
|
||||
orderId: "",
|
||||
total: 0,
|
||||
email: "",
|
||||
items: [] as OrderItem[]
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Получаем информацию о заказе из URL параметров
|
||||
const orderId = searchParams.get("order_id") || `ORD-${Math.floor(100000 + Math.random() * 900000)}`;
|
||||
const total = Number(searchParams.get("total")) || 0;
|
||||
const email = searchParams.get("email") || "";
|
||||
|
||||
// Пытаемся получить данные о товарах из localStorage
|
||||
let orderItems: OrderItem[] = [];
|
||||
try {
|
||||
// Используем сохраненную копию корзины заказа вместо текущей
|
||||
const cartData = localStorage.getItem('last_order_cart');
|
||||
if (cartData) {
|
||||
const cart = JSON.parse(cartData);
|
||||
orderItems = cart.items.map((item: any) => ({
|
||||
id: item.id,
|
||||
productId: item.productId || item.product_id,
|
||||
price: item.price,
|
||||
quantity: item.quantity,
|
||||
name: item.name || item.product_name,
|
||||
image: item.image || item.product_image,
|
||||
color: item.color,
|
||||
size: item.size || item.variant_name
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка при получении данных о товарах:", error);
|
||||
}
|
||||
|
||||
setOrderInfo({
|
||||
orderId,
|
||||
total,
|
||||
email,
|
||||
items: orderItems
|
||||
});
|
||||
|
||||
// Очищаем данные о последнем заказе, так как они уже загружены
|
||||
try {
|
||||
localStorage.removeItem('last_order_cart');
|
||||
} catch (error) {
|
||||
console.error("Ошибка при очистке данных заказа:", error);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-16 md:py-24 max-w-3xl">
|
||||
<Link href="/catalog" className="inline-flex items-center text-sm text-gray-600 hover:text-primary mb-8">
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Вернуться в каталог
|
||||
</Link>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-green-100 rounded-full mb-6">
|
||||
<Check className="h-10 w-10 text-green-600" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl md:text-3xl font-bold mb-4">Заказ успешно оформлен!</h1>
|
||||
|
||||
<p className="text-gray-700 mb-6">
|
||||
Спасибо за ваш заказ! Информация о заказе отправлена на вашу электронную почту
|
||||
{orderInfo.email && <span className="font-medium"> {orderInfo.email}</span>}.
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 p-6 rounded-lg mb-8 text-left">
|
||||
<h2 className="font-medium text-lg mb-4">Информация о заказе</h2>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="mb-2">
|
||||
<span className="text-gray-600">Номер заказа:</span>
|
||||
<span className="font-medium ml-2">{orderInfo.orderId}</span>
|
||||
</p>
|
||||
|
||||
{orderInfo.total > 0 && (
|
||||
<p className="mb-2">
|
||||
<span className="text-gray-600">Сумма заказа:</span>
|
||||
<span className="font-medium ml-2">{formatPrice(orderInfo.total)}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{orderInfo.items && orderInfo.items.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="font-medium mb-3">Товары в заказе:</h3>
|
||||
<div className="space-y-4 max-h-[300px] overflow-y-auto pr-2">
|
||||
{orderInfo.items.map((item) => (
|
||||
<div key={item.id} className="flex gap-3">
|
||||
{/* Изображение товара */}
|
||||
<div className="relative h-16 w-16 flex-shrink-0 rounded overflow-hidden border border-gray-200">
|
||||
{item.image ? (
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.name || "Товар"}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-gray-100 flex items-center justify-center">
|
||||
<Package className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Информация о товаре */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium line-clamp-2">{item.name || "Товар"}</h4>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{item.size && <span>Размер: {item.size}</span>}
|
||||
{item.size && item.color && <span className="mx-1">•</span>}
|
||||
{item.color && <span>Цвет: {item.color}</span>}
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<span className="text-xs text-gray-500">{item.quantity} × {formatPrice(item.price || 0)}</span>
|
||||
<span className="text-sm font-medium">{formatPrice((item.price || 0) * item.quantity)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-500 mt-4">
|
||||
Статус вашего заказа будет обновляться, следите за email-уведомлениями.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary/90"
|
||||
onClick={() => router.push("/catalog")}
|
||||
>
|
||||
Продолжить покупки
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:bg-primary/5"
|
||||
onClick={() => router.push("/account/orders")}
|
||||
>
|
||||
Мои заказы
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
frontend/app/.DS_Store
vendored
@ -125,7 +125,7 @@ const CategoryItem = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && hasSubcategories && (
|
||||
{isExpanded && category.subcategories && category.subcategories.length > 0 && (
|
||||
<div>
|
||||
{category.subcategories.map((subcategory) => (
|
||||
<CategoryItem
|
||||
@ -363,12 +363,8 @@ export default function CategoriesPage() {
|
||||
const loadCategories = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await categoryService.getCategoryTree();
|
||||
if (response.success && response.data) {
|
||||
setCategories(response.data);
|
||||
} else {
|
||||
toast.error('Ошибка при загрузке категорий');
|
||||
}
|
||||
const data = await categoryService.getCategories();
|
||||
setCategories(data);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке категорий:', error);
|
||||
toast.error('Ошибка при загрузке категорий');
|
||||
@ -405,55 +401,53 @@ export default function CategoriesPage() {
|
||||
|
||||
// Обработчик удаления категории
|
||||
const handleDelete = async (categoryId: number) => {
|
||||
if (confirm('Вы действительно хотите удалить эту категорию?')) {
|
||||
try {
|
||||
const response = await categoryService.deleteCategory(categoryId);
|
||||
if (response.success) {
|
||||
toast.success('Категория успешно удалена');
|
||||
// Обновляем список категорий
|
||||
const updatedResponse = await categoryService.getCategoryTree();
|
||||
if (updatedResponse.success && updatedResponse.data) {
|
||||
setCategories(updatedResponse.data);
|
||||
}
|
||||
} else {
|
||||
toast.error(response.error || 'Ошибка при удалении категории');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при удалении категории:', error);
|
||||
toast.error('Ошибка при удалении категории');
|
||||
if (!confirm('Вы действительно хотите удалить эту категорию?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await categoryService.deleteCategory(categoryId);
|
||||
if (result) {
|
||||
// Обновляем список категорий после удаления
|
||||
setCategories(categories.filter(cat => cat.id !== categoryId));
|
||||
toast.success('Категория успешно удалена');
|
||||
} else {
|
||||
toast.error('Не удалось удалить категорию');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при удалении категории:', error);
|
||||
toast.error('Ошибка при удалении категории');
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик сохранения категории
|
||||
const handleSave = async (data: CategoryFormValues) => {
|
||||
const handleSave = async (values: CategoryFormValues) => {
|
||||
try {
|
||||
let response;
|
||||
|
||||
if (dialogMode === 'edit' && categoryToEdit) {
|
||||
// Обновление существующей категории
|
||||
response = await categoryService.updateCategory(categoryToEdit.id, data as CategoryUpdate);
|
||||
if (response.success) {
|
||||
const result = await categoryService.updateCategory(categoryToEdit.id, values);
|
||||
if (result) {
|
||||
// Обновляем категорию в списке
|
||||
setCategories(categories.map(cat =>
|
||||
cat.id === categoryToEdit.id ? { ...cat, ...values } : cat
|
||||
));
|
||||
toast.success('Категория успешно обновлена');
|
||||
} else {
|
||||
toast.error('Не удалось обновить категорию');
|
||||
}
|
||||
} else {
|
||||
// Создание новой категории
|
||||
response = await categoryService.createCategory(data as CategoryCreate);
|
||||
if (response.success) {
|
||||
const result = await categoryService.createCategory({
|
||||
...values,
|
||||
parent_id: parentIdForCreate || undefined
|
||||
});
|
||||
if (result) {
|
||||
// Добавляем новую категорию в список
|
||||
setCategories([...categories, result]);
|
||||
toast.success('Категория успешно создана');
|
||||
} else {
|
||||
toast.error('Не удалось создать категорию');
|
||||
}
|
||||
}
|
||||
|
||||
if (response && response.success) {
|
||||
setIsDialogOpen(false);
|
||||
// Обновляем список категорий
|
||||
const updatedResponse = await categoryService.getCategoryTree();
|
||||
if (updatedResponse.success && updatedResponse.data) {
|
||||
setCategories(updatedResponse.data);
|
||||
}
|
||||
} else {
|
||||
toast.error(response?.error || 'Ошибка при сохранении категории');
|
||||
}
|
||||
setIsDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при сохранении категории:', error);
|
||||
toast.error('Ошибка при сохранении категории');
|
||||
|
||||
@ -1,235 +1,410 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Plus, Edit, Trash, Search } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, Search, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
// Типы для коллекций
|
||||
interface Collection {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
// UI Компоненты
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
// Временные данные до реализации API
|
||||
const mockCollections: Collection[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Весна-Лето 2023',
|
||||
slug: 'spring-summer-2023',
|
||||
description: 'Коллекция весна-лето 2023 года. Яркие цвета и легкие ткани.',
|
||||
is_active: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Осень-Зима 2022/23',
|
||||
slug: 'autumn-winter-2022-23',
|
||||
description: 'Теплые и стильные вещи для холодного сезона.',
|
||||
is_active: true
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Базовая коллекция',
|
||||
slug: 'basic-collection',
|
||||
description: 'Базовые модели, которые всегда в тренде.',
|
||||
is_active: true
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Спортивная линия',
|
||||
slug: 'sport-line',
|
||||
description: 'Спортивная одежда для активного образа жизни.',
|
||||
is_active: false
|
||||
}
|
||||
];
|
||||
// Админ-компоненты
|
||||
import { AdminPageContainer } from '@/components/admin/AdminPageContainer';
|
||||
import { AdminPageHeader } from '@/components/admin/AdminPageHeader';
|
||||
|
||||
// API и типы
|
||||
import { Collection, collectionService } from '@/lib/catalog';
|
||||
|
||||
// Схема валидации для формы коллекции
|
||||
const collectionFormSchema = z.object({
|
||||
name: z.string().min(2, { message: 'Название должно содержать минимум 2 символа' }),
|
||||
slug: z.string().min(2, { message: 'Slug должен содержать минимум 2 символа' }),
|
||||
description: z.string().optional(),
|
||||
is_active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
type CollectionFormValues = z.infer<typeof collectionFormSchema>;
|
||||
|
||||
export default function CollectionsPage() {
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filteredCollections, setFilteredCollections] = useState<Collection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [editingCollection, setEditingCollection] = useState<Collection | null>(null);
|
||||
|
||||
// Загрузка коллекций при монтировании
|
||||
useEffect(() => {
|
||||
// Здесь должен быть запрос к API
|
||||
// В будущем заменить на реальный запрос
|
||||
// Инициализация формы
|
||||
const form = useForm<CollectionFormValues>({
|
||||
resolver: zodResolver(collectionFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Загрузка списка коллекций
|
||||
const loadCollections = async () => {
|
||||
setLoading(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setCollections(mockCollections);
|
||||
setFilteredCollections(mockCollections);
|
||||
try {
|
||||
const response = await collectionService.getCollections();
|
||||
if (response) {
|
||||
setCollections(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке коллекций:', error);
|
||||
toast.error('Не удалось загрузить коллекции');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Загрузка при монтировании компонента
|
||||
useEffect(() => {
|
||||
loadCollections();
|
||||
}, []);
|
||||
|
||||
// Обработчик поиска
|
||||
useEffect(() => {
|
||||
if (searchTerm.trim() === '') {
|
||||
setFilteredCollections(collections);
|
||||
} else {
|
||||
const filtered = collections.filter(collection =>
|
||||
collection.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
collection.slug.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(collection.description && collection.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
setFilteredCollections(filtered);
|
||||
}
|
||||
}, [searchTerm, collections]);
|
||||
// Открытие диалога для создания новой коллекции
|
||||
const handleAddCollection = () => {
|
||||
form.reset({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
is_active: true,
|
||||
});
|
||||
setEditingCollection(null);
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
// Обработчик редактирования коллекции
|
||||
const handleEdit = (id: number) => {
|
||||
// В будущем реализовать с переходом на страницу редактирования
|
||||
console.log('Редактирование коллекции:', id);
|
||||
alert(`Редактирование коллекции с ID: ${id}`);
|
||||
// Открытие диалога для редактирования коллекции
|
||||
const handleEditCollection = (collection: Collection) => {
|
||||
form.reset({
|
||||
name: collection.name,
|
||||
slug: collection.slug,
|
||||
description: collection.description || '',
|
||||
is_active: collection.is_active,
|
||||
});
|
||||
setEditingCollection(collection);
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
// Обработчик удаления коллекции
|
||||
const handleDelete = (id: number) => {
|
||||
if (window.confirm('Вы уверены, что хотите удалить эту коллекцию?')) {
|
||||
console.log('Удаление коллекции:', id);
|
||||
// В будущем реализовать запрос к API
|
||||
// Временная реализация для демонстрации
|
||||
const updatedCollections = collections.filter(collection => collection.id !== id);
|
||||
setCollections(updatedCollections);
|
||||
setFilteredCollections(updatedCollections.filter(collection =>
|
||||
collection.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
collection.slug.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(collection.description && collection.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
));
|
||||
const handleDeleteCollection = async (id: number) => {
|
||||
if (!confirm('Вы уверены, что хотите удалить эту коллекцию?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await collectionService.deleteCollection(id);
|
||||
if (success) {
|
||||
setCollections(collections.filter(collection => collection.id !== id));
|
||||
toast.success('Коллекция успешно удалена');
|
||||
} else {
|
||||
throw new Error('Не удалось удалить коллекцию');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при удалении коллекции:', error);
|
||||
toast.error('Ошибка при удалении коллекции');
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик изменения статуса коллекции
|
||||
const handleToggleStatus = (id: number) => {
|
||||
const updatedCollections = collections.map(collection =>
|
||||
collection.id === id
|
||||
? { ...collection, is_active: !collection.is_active }
|
||||
: collection
|
||||
);
|
||||
setCollections(updatedCollections);
|
||||
setFilteredCollections(updatedCollections.filter(collection =>
|
||||
collection.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
collection.slug.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(collection.description && collection.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
));
|
||||
// Обработчик переключения активности коллекции
|
||||
const handleToggleActive = async (collection: Collection) => {
|
||||
try {
|
||||
const updatedCollection = {
|
||||
...collection,
|
||||
is_active: !collection.is_active,
|
||||
};
|
||||
|
||||
const result = await collectionService.updateCollection(updatedCollection);
|
||||
if (result) {
|
||||
setCollections(collections.map(c => c.id === collection.id ? result : c));
|
||||
toast.success(`Коллекция ${result.is_active ? 'активирована' : 'деактивирована'}`);
|
||||
} else {
|
||||
throw new Error('Не удалось обновить коллекцию');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при обновлении статуса коллекции:', error);
|
||||
toast.error('Ошибка при изменении статуса коллекции');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Обработчик сохранения коллекции
|
||||
const onSubmit = async (values: CollectionFormValues) => {
|
||||
try {
|
||||
let result: Collection | null;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6">
|
||||
<strong className="font-bold">Ошибка!</strong>
|
||||
<span className="block sm:inline"> {error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (editingCollection) {
|
||||
// Обновление существующей коллекции
|
||||
result = await collectionService.updateCollection({
|
||||
...values,
|
||||
id: editingCollection.id
|
||||
});
|
||||
|
||||
if (result) {
|
||||
setCollections(collections.map(collection =>
|
||||
collection.id === editingCollection.id ? result! : collection
|
||||
));
|
||||
toast.success('Коллекция успешно обновлена');
|
||||
} else {
|
||||
throw new Error('Не удалось обновить коллекцию');
|
||||
}
|
||||
} else {
|
||||
// Создание новой коллекции
|
||||
result = await collectionService.createCollection(values);
|
||||
|
||||
if (result) {
|
||||
setCollections([...collections, result]);
|
||||
toast.success('Коллекция успешно создана');
|
||||
} else {
|
||||
throw new Error('Не удалось создать коллекцию');
|
||||
}
|
||||
}
|
||||
|
||||
setShowDialog(false);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при сохранении коллекции:', error);
|
||||
toast.error('Ошибка при сохранении коллекции');
|
||||
}
|
||||
};
|
||||
|
||||
// Фильтрация коллекций по поисковому запросу
|
||||
const filteredCollections = collections.filter(collection =>
|
||||
collection.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
collection.slug.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(collection.description?.toLowerCase() || '').includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Действия для заголовка
|
||||
const headerActions = (
|
||||
<Button onClick={handleAddCollection} size="sm" className="flex items-center gap-1">
|
||||
<Plus className="h-4 w-4" />
|
||||
Добавить коллекцию
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Управление коллекциями</h2>
|
||||
<Link
|
||||
href="/admin/collections/create"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Добавить коллекцию
|
||||
</Link>
|
||||
</div>
|
||||
<AdminPageContainer>
|
||||
<AdminPageHeader
|
||||
title="Управление коллекциями"
|
||||
description="Создавайте и редактируйте коллекции товаров"
|
||||
actions={headerActions}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Коллекции</CardTitle>
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Поиск коллекций..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск коллекций..."
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : filteredCollections.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{searchTerm ? 'Коллекции не найдены' : 'Нет доступных коллекций'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Название</TableHead>
|
||||
<TableHead>Slug</TableHead>
|
||||
<TableHead>Описание</TableHead>
|
||||
<TableHead className="w-[100px] text-center">Статус</TableHead>
|
||||
<TableHead className="w-[100px] text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCollections.map((collection) => (
|
||||
<TableRow key={collection.id}>
|
||||
<TableCell className="font-medium">{collection.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{collection.slug}</TableCell>
|
||||
<TableCell className="max-w-md truncate">
|
||||
{collection.description || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={collection.is_active ? 'text-green-500' : 'text-gray-400'}
|
||||
onClick={() => handleToggleActive(collection)}
|
||||
>
|
||||
{collection.is_active ? 'Активна' : 'Неактивна'}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEditCollection(collection)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive/90"
|
||||
onClick={() => handleDeleteCollection(collection.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Название</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Slug</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Описание</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Статус</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredCollections.map((collection) => (
|
||||
<tr key={collection.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{collection.name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{collection.slug}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{collection.description ?
|
||||
(collection.description.length > 60 ?
|
||||
`${collection.description.substring(0, 60)}...` :
|
||||
collection.description) :
|
||||
'—'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
collection.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{collection.is_active ? 'Активна' : 'Неактивна'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => handleToggleStatus(collection.id)}
|
||||
className={`text-sm ${
|
||||
collection.is_active ? 'text-red-600 hover:text-red-900' : 'text-green-600 hover:text-green-900'
|
||||
}`}
|
||||
>
|
||||
{collection.is_active ? 'Деактивировать' : 'Активировать'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(collection.id)}
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
>
|
||||
<Edit size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(collection.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredCollections.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-4 text-center text-sm text-gray-500">
|
||||
Коллекции не найдены
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/* Диалог для создания/редактирования коллекции */}
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingCollection ? 'Редактировать коллекцию' : 'Добавить новую коллекцию'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingCollection
|
||||
? 'Измените информацию о коллекции и нажмите "Сохранить"'
|
||||
: 'Заполните форму для создания новой коллекции'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Название</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Введите название коллекции" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Введите slug коллекции" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Уникальный идентификатор для URL (только латинские буквы, цифры и дефисы)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Описание</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Введите описание коллекции"
|
||||
{...field}
|
||||
rows={4}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_active"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Активна</FormLabel>
|
||||
<FormDescription>
|
||||
Коллекция будет отображаться на сайте
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" type="button" onClick={() => setShowDialog(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="submit">Сохранить</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AdminPageContainer>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { BarChart3, Package, Tag, Users, ShoppingBag, FileText, Settings, Home, Layers, FolderTree, LogOut } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { BarChart3, Package, Tag, Users, ShoppingBag, FileText, Settings, Home, Layers, FolderTree, LogOut } from 'lucide-react';
|
||||
import { authApi } from '@/lib/auth-api';
|
||||
|
||||
// Интерфейс для компонента MenuItem
|
||||
interface MenuItemProps {
|
||||
@ -12,6 +13,16 @@ interface MenuItemProps {
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
// Интерфейс пользователя
|
||||
interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
is_admin: boolean;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
// Компонент элемента меню
|
||||
const MenuItem = ({ href, icon, label, active = false }: MenuItemProps) => {
|
||||
return (
|
||||
@ -33,27 +44,144 @@ const links = [
|
||||
{ name: 'Дашборд', href: '/admin', icon: <Home size={20} /> },
|
||||
{ name: 'Заказы', href: '/admin/orders', icon: <Package size={20} /> },
|
||||
{ name: 'Товары', href: '/admin/products', icon: <ShoppingBag size={20} /> },
|
||||
{ name: 'Товары с вариантами', href: '/admin/products/new-complete', icon: <Layers size={20} /> },
|
||||
{ name: 'Категории', href: '/admin/categories', icon: <FolderTree size={20} /> },
|
||||
{ name: 'Коллекции', href: '/admin/collections', icon: <FolderTree size={20} /> },
|
||||
{ name: 'Размеры', href: '/admin/sizes', icon: <FolderTree size={20} /> },
|
||||
{ name: 'Пользователи', href: '/admin/users', icon: <Users size={20} /> },
|
||||
{ name: 'Настройки', href: '/admin/settings', icon: <Settings size={20} /> },
|
||||
];
|
||||
|
||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
// Состояние для проверки, авторизован ли пользователь
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
// Проверка является ли пользователь администратором
|
||||
function isUserAdmin(userProfile: any): boolean {
|
||||
console.log('Проверка прав администратора для:', userProfile);
|
||||
|
||||
if (!userProfile) {
|
||||
console.log('Профиль пользователя пустой или не определен');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Детальная проверка различных способов указания прав администратора
|
||||
if (userProfile.is_admin === true) {
|
||||
console.log('Пользователь админ по свойству is_admin');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (userProfile.role === 'admin') {
|
||||
console.log('Пользователь админ по роли admin');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (userProfile.role === 'ADMIN') {
|
||||
console.log('Пользователь админ по роли ADMIN');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Проверка прав в массиве
|
||||
if (Array.isArray(userProfile.roles) && userProfile.roles.includes('admin')) {
|
||||
console.log('Пользователь админ по массиву ролей');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Проверка поля admin_access если оно есть
|
||||
if (userProfile.admin_access === true) {
|
||||
console.log('Пользователь админ по свойству admin_access');
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('Пользователь НЕ админ, не найдены признаки админских прав');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Проверяем токен при загрузке страницы
|
||||
// Константа для ключа токена в localStorage
|
||||
const TOKEN_KEY = 'token';
|
||||
|
||||
// Функция получения токена
|
||||
const getToken = (): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении токена (layout.tsx):', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
// Состояние для проверки, авторизован ли пользователь и его роль
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
|
||||
// Функция выхода
|
||||
const handleLogout = () => {
|
||||
authApi.logout();
|
||||
window.location.href = '/admin/login';
|
||||
};
|
||||
|
||||
// Проверяем токен и права доступа при загрузке страницы
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
setIsAuthenticated(!!token);
|
||||
setIsLoading(false);
|
||||
|
||||
// Если токена нет и это не страница логина, перенаправляем
|
||||
if (!token && window.location.pathname !== '/admin/login') {
|
||||
window.location.href = '/admin/login';
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const token = getToken();
|
||||
console.log('Проверка токена в layout:', token ? token.substring(0, 20) + '...' : 'нет токена');
|
||||
|
||||
if (!token) {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setIsLoading(false);
|
||||
|
||||
// Перенаправляем на страницу входа, если мы не там
|
||||
if (window.location.pathname.startsWith('/admin') &&
|
||||
window.location.pathname !== '/admin/login') {
|
||||
window.location.href = '/admin/login';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем токен и получаем профиль пользователя
|
||||
const userProfile = await authApi.getProfile();
|
||||
console.log('Профиль пользователя в layout:', userProfile);
|
||||
|
||||
if (userProfile) {
|
||||
setIsAuthenticated(true);
|
||||
setCurrentUser(userProfile);
|
||||
|
||||
// Проверяем права администратора
|
||||
const userAdminStatus = isUserAdmin(userProfile);
|
||||
console.log('Результат проверки админских прав:', userAdminStatus);
|
||||
setIsAdmin(userAdminStatus);
|
||||
|
||||
// Если пользователь авторизован, но не админ - показываем ошибку и предлагаем выйти
|
||||
if (!userAdminStatus && window.location.pathname !== '/admin/login') {
|
||||
setAuthError('У вас нет прав доступа к админ-панели');
|
||||
console.log('Доступ запрещен: пользователь не админ');
|
||||
} else {
|
||||
console.log('Доступ разрешен: пользователь админ');
|
||||
}
|
||||
} else {
|
||||
// Если профиль не получен, сбрасываем состояние
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
// Не удаляем токен при перезагрузке страницы
|
||||
// localStorage.removeItem(TOKEN_KEY);
|
||||
console.log('Профиль не получен, но токен сохраняем');
|
||||
|
||||
// Перенаправляем на страницу входа
|
||||
if (window.location.pathname.startsWith('/admin') &&
|
||||
window.location.pathname !== '/admin/login') {
|
||||
window.location.href = '/admin/login';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при проверке авторизации:', error);
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
// Не удаляем токен при ошибке
|
||||
// localStorage.removeItem(TOKEN_KEY);
|
||||
console.log('Ошибка авторизации, но токен сохраняем');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -69,8 +197,43 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Если пользователь не авторизован и это не страница логина, не показываем содержимое
|
||||
if (!isAuthenticated && window.location.pathname !== '/admin/login') {
|
||||
// Если это страница логина, показываем только содержимое без меню
|
||||
if (window.location.pathname === '/admin/login') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Если пользователь авторизован, но не админ - показываем ошибку доступа
|
||||
if (isAuthenticated && !isAdmin) {
|
||||
return (
|
||||
<div className="flex flex-col h-screen items-center justify-center">
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-6 py-4 rounded mb-6 max-w-md">
|
||||
<h2 className="text-xl font-bold mb-2">Доступ запрещен</h2>
|
||||
<p className="mb-4">{authError || 'У вас нет прав доступа к админ-панели.'}</p>
|
||||
<p>Войдите с аккаунтом администратора.</p>
|
||||
|
||||
{currentUser && (
|
||||
<div className="mt-4 text-sm text-gray-700 p-3 bg-gray-100 rounded-lg">
|
||||
<p>Текущий пользователь: <strong>{currentUser.email}</strong></p>
|
||||
<p>Роль: {currentUser.role || 'Пользователь'}</p>
|
||||
<p>Админ: {String(currentUser.is_admin)}</p>
|
||||
<pre className="mt-2 text-xs bg-gray-200 p-2 rounded overflow-auto max-h-40">
|
||||
{JSON.stringify(currentUser, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Если пользователь не авторизован, не показываем содержимое
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-lg">Перенаправление на страницу входа...</div>
|
||||
@ -78,18 +241,18 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Если это страница логина, показываем только содержимое без меню
|
||||
if (window.location.pathname === '/admin/login') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Для авторизованных пользователей показываем полный интерфейс
|
||||
// Для авторизованных пользователей с правами админа показываем полный интерфейс
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
{/* Боковое меню */}
|
||||
<div className="w-64 bg-white shadow-md">
|
||||
<div className="p-4 border-b">
|
||||
<h1 className="text-xl font-bold">Админ-панель</h1>
|
||||
{currentUser && (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{currentUser.email} {currentUser.is_admin ? '(Админ)' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<nav>
|
||||
@ -108,10 +271,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
/>
|
||||
{/* Кнопка выхода */}
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/admin/login';
|
||||
}}
|
||||
onClick={handleLogout}
|
||||
className="flex items-center px-4 py-2 rounded-md mb-1 w-full text-left text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
|
||||
@ -2,36 +2,86 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { authApi } from '@/lib/api';
|
||||
import { Eye, EyeOff, UserX, LogOut } from 'lucide-react';
|
||||
import { authApi } from '@/lib/auth-api';
|
||||
|
||||
// Добавляем интерфейс для типизации ответа API
|
||||
interface AuthResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
user?: {
|
||||
id: number;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
[key: string]: any;
|
||||
};
|
||||
// Интерфейс пользователя
|
||||
interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
is_admin: boolean;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
// Константа для ключа токена в localStorage
|
||||
const TOKEN_KEY = 'token';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [authState, setAuthState] = useState<'not_authenticated' | 'authenticated_not_admin' | 'checking'>('checking');
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// Функция получения токена из localStorage
|
||||
const getToken = (): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении токена:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Проверяем, авторизован ли пользователь, при загрузке страницы
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
window.location.href = '/admin';
|
||||
}
|
||||
}, []);
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
setAuthState('checking');
|
||||
const token = getToken();
|
||||
console.log('Проверка токена на странице входа:', token ? token.substring(0, 20) + '...' : 'нет токена');
|
||||
|
||||
if (!token) {
|
||||
setAuthState('not_authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
// Если токен есть, пробуем получить профиль
|
||||
const userProfile = await authApi.getProfile();
|
||||
console.log('Профиль пользователя на странице входа:', userProfile);
|
||||
|
||||
if (userProfile) {
|
||||
setCurrentUser(userProfile);
|
||||
// Если пользователь администратор, перенаправляем в админку
|
||||
if (userProfile.role === 'admin' || userProfile.is_admin) {
|
||||
console.log('Пользователь является администратором, перенаправление на дашборд');
|
||||
router.push('/admin');
|
||||
} else {
|
||||
// Пользователь авторизован, но не админ
|
||||
console.log('Пользователь авторизован, но не является администратором');
|
||||
setAuthState('authenticated_not_admin');
|
||||
}
|
||||
} else {
|
||||
// Если профиль не получен, считаем что пользователь не авторизован
|
||||
console.log('Профиль не получен, пользователь не авторизован');
|
||||
setAuthState('not_authenticated');
|
||||
// Не удаляем токен, чтобы он не исчезал при перезагрузке страницы
|
||||
}
|
||||
} catch (err) {
|
||||
// В случае ошибки считаем, что пользователь не авторизован, но токен не удаляем
|
||||
console.error('Ошибка проверки авторизации:', err);
|
||||
setAuthState('not_authenticated');
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@ -47,38 +97,31 @@ export default function AdminLoginPage() {
|
||||
try {
|
||||
console.log('Попытка входа с email:', email);
|
||||
|
||||
// Добавляем отладочный код, чтобы видеть данные формы до отправки
|
||||
const testFormData = new URLSearchParams();
|
||||
testFormData.append('username', email);
|
||||
testFormData.append('password', password);
|
||||
console.log('Отладка - данные формы:', testFormData.toString());
|
||||
|
||||
const response = await authApi.login(email, password);
|
||||
console.log('Ответ от сервера:', response);
|
||||
|
||||
// Проверяем успешность запроса
|
||||
if (!response.success) {
|
||||
// Если есть информация об ошибке, показываем ее
|
||||
const errorMsg = typeof response.error === 'string'
|
||||
? response.error
|
||||
: (response.error?.detail || 'Неизвестная ошибка');
|
||||
|
||||
console.error('Ошибка при входе:', errorMsg);
|
||||
setError(`Ошибка: ${errorMsg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Приводим ответ к типу AuthResponse
|
||||
const authData = response.data as AuthResponse;
|
||||
|
||||
if (authData && authData.access_token) {
|
||||
localStorage.setItem('token', authData.access_token);
|
||||
console.log('Вход успешен, перенаправление на дашборд');
|
||||
if (response.success) {
|
||||
console.log('Успешная авторизация, получаем профиль пользователя');
|
||||
|
||||
window.location.href = '/admin';
|
||||
// После успешного входа получаем профиль пользователя
|
||||
const userProfile = await authApi.getProfile();
|
||||
console.log('Профиль пользователя после входа:', userProfile);
|
||||
|
||||
if (userProfile && (userProfile.role === 'admin' || userProfile.is_admin)) {
|
||||
console.log('Вход успешен, перенаправление на дашборд');
|
||||
router.push('/admin');
|
||||
} else {
|
||||
// Если пользователь не админ, покажем ошибку
|
||||
console.log('Пользователь не является администратором');
|
||||
setError('У вас нет прав доступа к админ-панели');
|
||||
setCurrentUser(userProfile);
|
||||
setAuthState('authenticated_not_admin');
|
||||
// Токен не удаляем, так как пользователь авторизован, просто не имеет прав админа
|
||||
}
|
||||
} else {
|
||||
console.log('Ошибка входа: неверные учетные данные или неверный формат ответа');
|
||||
setError('Неверные учетные данные');
|
||||
// В случае ошибки показываем сообщение
|
||||
const errorMsg = response.error || 'Неверные учетные данные';
|
||||
console.log('Ошибка входа:', errorMsg);
|
||||
setError(errorMsg);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка входа:', err);
|
||||
@ -88,10 +131,65 @@ export default function AdminLoginPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
authApi.logout();
|
||||
setCurrentUser(null);
|
||||
setAuthState('not_authenticated');
|
||||
};
|
||||
|
||||
const toggleShowPassword = () => {
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
// Если проверяем авторизацию, показываем индикатор загрузки
|
||||
if (authState === 'checking') {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-lg">Проверка авторизации...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Если пользователь авторизован, но не является администратором
|
||||
if (authState === 'authenticated_not_admin') {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<UserX size={64} className="mx-auto h-16 w-16 text-red-600" />
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Доступ запрещен
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-lg text-gray-600">
|
||||
У вас нет прав для доступа к админ-панели.
|
||||
</p>
|
||||
{currentUser && (
|
||||
<div className="mt-4 text-sm text-gray-700 p-3 bg-gray-100 rounded-lg">
|
||||
<p>Текущий пользователь: <strong>{currentUser.email}</strong></p>
|
||||
<p>Роль: {currentUser.role || 'Пользователь'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<LogOut className="h-5 w-5 mr-2" />
|
||||
Выйти из системы
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center text-sm text-gray-600">
|
||||
<p>Для доступа к админ-панели вам нужно войти с учетной записью администратора.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Стандартная форма входа для неавторизованных пользователей
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
@ -190,9 +288,6 @@ export default function AdminLoginPage() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center text-sm text-gray-600">
|
||||
<p>Демо-доступ: admin@example.com / password</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -2,451 +2,585 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Search, Filter, ChevronDown } from 'lucide-react';
|
||||
import { fetchOrders, Order } from '@/lib/api';
|
||||
import { formatDate, formatPrice } from '@/lib/utils';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Search,
|
||||
ArrowUpDown,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Loader2,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { formatDistance, format, subDays, startOfDay, endOfDay, subMonths } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale';
|
||||
import { toast } from 'sonner';
|
||||
import { DateRange } from 'react-day-picker';
|
||||
|
||||
// Компонент для фильтрации заказов
|
||||
interface FilterDropdownProps {
|
||||
options: { value: string; label: string }[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
// UI компоненты
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { DateRangePicker } from '@/components/ui/date-range-picker';
|
||||
import {
|
||||
Badge,
|
||||
} from '@/components/ui/badge';
|
||||
|
||||
// API и типы
|
||||
import api, { ApiResponse } from '@/lib/api';
|
||||
|
||||
// Расширенный интерфейс Order чтобы включить все необходимые поля
|
||||
interface Order {
|
||||
id: number;
|
||||
user_id: number;
|
||||
user_name?: string;
|
||||
user_email?: string;
|
||||
status: string;
|
||||
total_amount: number;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
items?: any[];
|
||||
items_count?: number;
|
||||
shipping_address?: any;
|
||||
tracking_number?: string;
|
||||
payment_method?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
const FilterDropdown = ({ options, value, onChange, label }: FilterDropdownProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// Функция форматирования цены
|
||||
function formatPrice(price: number): string {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
// Функция форматирования даты
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return format(date, 'dd.MM.yyyy HH:mm', { locale: ru });
|
||||
}
|
||||
|
||||
// Статусы заказов
|
||||
const ORDER_STATUSES = [
|
||||
{ value: 'all', label: 'Все статусы' },
|
||||
{ value: 'pending', label: 'Ожидает оплаты', color: 'bg-amber-100 text-amber-800' },
|
||||
{ value: 'processing', label: 'В обработке', color: 'bg-blue-100 text-blue-800' },
|
||||
{ value: 'shipped', label: 'Отправлен', color: 'bg-purple-100 text-purple-800' },
|
||||
{ value: 'delivered', label: 'Доставлен', color: 'bg-green-100 text-green-800' },
|
||||
{ value: 'cancelled', label: 'Отменен', color: 'bg-red-100 text-red-800' },
|
||||
{ value: 'refunded', label: 'Возвращен', color: 'bg-gray-100 text-gray-800' },
|
||||
];
|
||||
|
||||
// Компонент для отображения статуса заказа
|
||||
function OrderStatusBadge({ status }: { status: string }) {
|
||||
const statusInfo = ORDER_STATUSES.find((s) => s.value === status) || {
|
||||
label: status,
|
||||
color: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center px-3 py-2 border border-gray-300 rounded-md bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{label}: {options.find(option => option.value === value)?.label || 'Все'}
|
||||
<ChevronDown className="ml-2 h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-10">
|
||||
<div className="py-1" role="menu" aria-orientation="vertical">
|
||||
{options.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={`block px-4 py-2 text-sm w-full text-left ${
|
||||
value === option.value
|
||||
? 'bg-indigo-100 text-indigo-900'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${statusInfo.color} border-none whitespace-nowrap capitalize`}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default function OrdersPage() {
|
||||
const router = useRouter();
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [filteredOrders, setFilteredOrders] = useState<Order[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [dateFilter, setDateFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [dateRange, setDateRange] = useState<DateRange | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sortField, setSortField] = useState<string>('created_at');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
// Массив статусов заказов
|
||||
const statusOptions = [
|
||||
{ value: '', label: 'Все статусы' },
|
||||
{ value: 'pending', label: 'Ожидает оплаты' },
|
||||
{ value: 'processing', label: 'В обработке' },
|
||||
{ value: 'shipped', label: 'Отправлен' },
|
||||
{ value: 'delivered', label: 'Доставлен' },
|
||||
{ value: 'cancelled', label: 'Отменен' },
|
||||
{ value: 'refunded', label: 'Возвращен' }
|
||||
];
|
||||
|
||||
// Массив фильтров по дате
|
||||
const dateOptions = [
|
||||
{ value: '', label: 'За все время' },
|
||||
{ value: 'today', label: 'Сегодня' },
|
||||
{ value: 'yesterday', label: 'Вчера' },
|
||||
{ value: 'week', label: 'За неделю' },
|
||||
{ value: 'month', label: 'За месяц' }
|
||||
];
|
||||
|
||||
// Функция сортировки заказов
|
||||
const sortOrders = (orders: Order[], field: string, direction: 'asc' | 'desc') => {
|
||||
return [...orders].sort((a, b) => {
|
||||
let valueA, valueB;
|
||||
|
||||
switch (field) {
|
||||
case 'id':
|
||||
valueA = a.id;
|
||||
valueB = b.id;
|
||||
break;
|
||||
case 'user_name':
|
||||
valueA = a.user_name || '';
|
||||
valueB = b.user_name || '';
|
||||
break;
|
||||
case 'created_at':
|
||||
valueA = new Date(a.created_at).getTime();
|
||||
valueB = new Date(b.created_at).getTime();
|
||||
break;
|
||||
case 'items_count':
|
||||
valueA = a.items_count || 0;
|
||||
valueB = b.items_count || 0;
|
||||
break;
|
||||
case 'total':
|
||||
valueA = a.total || 0;
|
||||
valueB = b.total || 0;
|
||||
break;
|
||||
default:
|
||||
valueA = a[field as keyof Order] || '';
|
||||
valueB = b[field as keyof Order] || '';
|
||||
}
|
||||
|
||||
if (valueA < valueB) return direction === 'asc' ? -1 : 1;
|
||||
if (valueA > valueB) return direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
// Обработчик клика по заголовку таблицы для сортировки
|
||||
const handleSort = (field: string) => {
|
||||
const newDirection = sortField === field && sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
setSortField(field);
|
||||
setSortDirection(newDirection);
|
||||
|
||||
// Сортируем заказы
|
||||
const sortedOrders = sortOrders(filteredOrders, field, newDirection);
|
||||
setFilteredOrders(sortedOrders);
|
||||
};
|
||||
|
||||
// Получить иконку направления сортировки
|
||||
const getSortIcon = (field: string) => {
|
||||
if (sortField !== field) return null;
|
||||
|
||||
return (
|
||||
<span className="ml-1 inline-block">
|
||||
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
|
||||
|
||||
// Загрузка заказов
|
||||
useEffect(() => {
|
||||
const loadOrders = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Формируем параметры запроса
|
||||
const params: any = {
|
||||
limit: 100 // Максимальное количество заказов
|
||||
};
|
||||
|
||||
// Добавляем фильтр по статусу
|
||||
if (statusFilter) {
|
||||
// Подготовка параметров запроса
|
||||
const params: Record<string, any> = {};
|
||||
|
||||
if (statusFilter && statusFilter !== 'all') {
|
||||
params.status = statusFilter;
|
||||
}
|
||||
|
||||
// Добавляем фильтр по дате
|
||||
if (dateFilter) {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const weekAgo = new Date(today);
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
const monthAgo = new Date(today);
|
||||
monthAgo.setMonth(monthAgo.getMonth() - 1);
|
||||
|
||||
switch (dateFilter) {
|
||||
case 'today':
|
||||
params.dateFrom = today.toISOString();
|
||||
break;
|
||||
case 'yesterday':
|
||||
params.dateFrom = yesterday.toISOString();
|
||||
params.dateTo = today.toISOString();
|
||||
break;
|
||||
case 'week':
|
||||
params.dateFrom = weekAgo.toISOString();
|
||||
break;
|
||||
case 'month':
|
||||
params.dateFrom = monthAgo.toISOString();
|
||||
break;
|
||||
}
|
||||
|
||||
if (dateRange?.from && dateRange?.to) {
|
||||
params.date_from = startOfDay(dateRange.from).toISOString();
|
||||
params.date_to = endOfDay(dateRange.to).toISOString();
|
||||
}
|
||||
|
||||
// Добавляем поисковый запрос
|
||||
|
||||
if (searchTerm) {
|
||||
params.search = searchTerm;
|
||||
}
|
||||
|
||||
const response = await fetchOrders(params);
|
||||
// Запрос к API
|
||||
const response = await api.get('/orders', { params });
|
||||
|
||||
if (response.data) {
|
||||
// Обработка возможных форматов ответа API
|
||||
let orderData: Order[] = [];
|
||||
|
||||
if (Array.isArray(response.data)) {
|
||||
orderData = response.data;
|
||||
} else {
|
||||
// Если response.data - это объект, проверяем наличие свойства orders
|
||||
const data = response.data as any;
|
||||
if (data.orders && Array.isArray(data.orders)) {
|
||||
orderData = data.orders;
|
||||
// Проверяем формат ответа и обрабатываем его соответственно
|
||||
let ordersData: Order[] = [];
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
// API вернул массив заказов напрямую
|
||||
ordersData = response;
|
||||
} else if (response && typeof response === 'object') {
|
||||
// Пробуем получить данные из объекта ответа
|
||||
const apiResponse = response as any;
|
||||
if (apiResponse.success && apiResponse.data) {
|
||||
if (Array.isArray(apiResponse.data)) {
|
||||
ordersData = apiResponse.data;
|
||||
} else if (apiResponse.data.orders && Array.isArray(apiResponse.data.orders)) {
|
||||
ordersData = apiResponse.data.orders;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ordersData.length > 0) {
|
||||
setOrders(ordersData);
|
||||
|
||||
// Применяем сортировку к загруженным данным
|
||||
const sortedOrders = sortOrders(orderData, sortField, sortDirection);
|
||||
|
||||
setOrders(orderData);
|
||||
// Сортировка и фильтрация
|
||||
const sortedOrders = sortOrders(ordersData);
|
||||
setFilteredOrders(sortedOrders);
|
||||
} else {
|
||||
throw new Error('Не удалось получить данные заказов');
|
||||
console.log("Получен пустой список заказов или неверный формат данных:", response);
|
||||
setOrders([]);
|
||||
setFilteredOrders([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке заказов:', err);
|
||||
setError('Не удалось загрузить заказы');
|
||||
toast.error('Ошибка при загрузке заказов');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadOrders();
|
||||
}, [statusFilter, dateFilter, searchTerm, sortField, sortDirection]);
|
||||
}, [statusFilter, dateRange, searchTerm]);
|
||||
|
||||
// Получить строковое представление статуса заказа
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'Ожидает оплаты';
|
||||
case 'processing':
|
||||
return 'В обработке';
|
||||
case 'shipped':
|
||||
return 'Отправлен';
|
||||
case 'delivered':
|
||||
return 'Доставлен';
|
||||
case 'cancelled':
|
||||
return 'Отменен';
|
||||
case 'refunded':
|
||||
return 'Возвращен';
|
||||
default:
|
||||
return status;
|
||||
// Функция сортировки заказов
|
||||
const sortOrders = (ordersToSort: Order[] = orders): Order[] => {
|
||||
return [...ordersToSort].sort((a, b) => {
|
||||
if (!a || !b) return 0;
|
||||
|
||||
let valA, valB;
|
||||
|
||||
// Выбор значений для сортировки в зависимости от поля
|
||||
switch (sortField) {
|
||||
case 'id':
|
||||
valA = a.id;
|
||||
valB = b.id;
|
||||
break;
|
||||
case 'user_name':
|
||||
valA = a.user_name || '';
|
||||
valB = b.user_name || '';
|
||||
break;
|
||||
case 'created_at':
|
||||
valA = new Date(a.created_at).getTime();
|
||||
valB = new Date(b.created_at).getTime();
|
||||
break;
|
||||
case 'total':
|
||||
valA = a.total_amount || 0;
|
||||
valB = b.total_amount || 0;
|
||||
break;
|
||||
case 'status':
|
||||
valA = a.status || '';
|
||||
valB = b.status || '';
|
||||
break;
|
||||
default:
|
||||
valA = a.id;
|
||||
valB = b.id;
|
||||
}
|
||||
|
||||
// Направление сортировки
|
||||
const direction = sortDirection === 'asc' ? 1 : -1;
|
||||
|
||||
// Сравнение значений
|
||||
if (typeof valA === 'string' && typeof valB === 'string') {
|
||||
return valA.localeCompare(valB) * direction;
|
||||
} else {
|
||||
return ((valA as number) - (valB as number)) * direction;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Обработчик изменения сортировки
|
||||
const handleSort = (field: string) => {
|
||||
if (field === sortField) {
|
||||
// Если поле то же, меняем направление сортировки
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
// Если поле новое, устанавливаем его и сортируем по убыванию
|
||||
setSortField(field);
|
||||
setSortDirection('desc');
|
||||
}
|
||||
};
|
||||
|
||||
// Получить класс для стиля статуса заказа
|
||||
const getStatusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'processing':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'shipped':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
case 'delivered':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'cancelled':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'refunded':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
// Эффект для применения сортировки
|
||||
useEffect(() => {
|
||||
const sorted = sortOrders();
|
||||
setFilteredOrders(sorted);
|
||||
}, [sortField, sortDirection, orders]);
|
||||
|
||||
// Получение иконки сортировки
|
||||
const getSortIcon = (field: string) => {
|
||||
if (field !== sortField) {
|
||||
return <ArrowUpDown className="ml-1 h-4 w-4" />;
|
||||
}
|
||||
return sortDirection === 'asc' ? (
|
||||
<ArrowUp className="ml-1 h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="ml-1 h-4 w-4" />
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6">
|
||||
<strong className="font-bold">Ошибка!</strong>
|
||||
<span className="block sm:inline"> {error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Предустановленные диапазоны дат
|
||||
const handleDateRangeSelect = (range: string) => {
|
||||
const today = new Date();
|
||||
|
||||
switch (range) {
|
||||
case 'today':
|
||||
setDateRange({
|
||||
from: startOfDay(today),
|
||||
to: endOfDay(today),
|
||||
});
|
||||
break;
|
||||
case 'yesterday':
|
||||
const yesterday = subDays(today, 1);
|
||||
setDateRange({
|
||||
from: startOfDay(yesterday),
|
||||
to: endOfDay(yesterday),
|
||||
});
|
||||
break;
|
||||
case 'week':
|
||||
setDateRange({
|
||||
from: startOfDay(subDays(today, 7)),
|
||||
to: endOfDay(today),
|
||||
});
|
||||
break;
|
||||
case 'month':
|
||||
setDateRange({
|
||||
from: startOfDay(subDays(today, 30)),
|
||||
to: endOfDay(today),
|
||||
});
|
||||
break;
|
||||
case 'year':
|
||||
setDateRange({
|
||||
from: startOfDay(subMonths(today, 12)),
|
||||
to: endOfDay(today),
|
||||
});
|
||||
break;
|
||||
case 'all':
|
||||
default:
|
||||
setDateRange(undefined);
|
||||
}
|
||||
|
||||
setIsDatePickerOpen(false);
|
||||
};
|
||||
|
||||
// Рендер страницы
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Управление заказами</h2>
|
||||
{filteredOrders.length > 0 && (
|
||||
<div className="text-sm text-gray-500">
|
||||
Найдено заказов: <span className="font-medium">{filteredOrders.length}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
<div className="container mx-auto py-6">
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<CardTitle>Управление заказами</CardTitle>
|
||||
<CardDescription>
|
||||
Просмотр и управление заказами клиентов
|
||||
</CardDescription>
|
||||
</div>
|
||||
{filteredOrders.length > 0 && (
|
||||
<Badge variant="outline" className="bg-slate-100">
|
||||
Найдено заказов: {filteredOrders.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{/* Фильтры */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Поиск по ID или имени клиента"
|
||||
className="pl-8"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={setStatusFilter}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Выберите статус" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ORDER_STATUSES.map((status) => (
|
||||
<SelectItem key={status.value} value={status.value}>
|
||||
{status.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Popover open={isDatePickerOpen} onOpenChange={setIsDatePickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-[180px] justify-start">
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
{dateRange?.from ? (
|
||||
dateRange.to ? (
|
||||
<>
|
||||
{format(dateRange.from, 'dd.MM.yy')} - {format(dateRange.to, 'dd.MM.yy')}
|
||||
</>
|
||||
) : (
|
||||
format(dateRange.from, 'dd.MM.yyyy')
|
||||
)
|
||||
) : (
|
||||
'Выберите даты'
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<div className="p-3 border-b">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Быстрый выбор</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDateRangeSelect('today')}
|
||||
>
|
||||
Сегодня
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDateRangeSelect('yesterday')}
|
||||
>
|
||||
Вчера
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDateRangeSelect('week')}
|
||||
>
|
||||
Неделя
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDateRangeSelect('month')}
|
||||
>
|
||||
Месяц
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDateRangeSelect('all')}
|
||||
>
|
||||
Все время
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DateRangePicker
|
||||
defaultValue={dateRange}
|
||||
onUpdate={(range) => {
|
||||
setDateRange(range);
|
||||
if (range.to) setIsDatePickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по имени клиента или номеру заказа"
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<FilterDropdown
|
||||
options={statusOptions}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
label="Статус"
|
||||
/>
|
||||
|
||||
<FilterDropdown
|
||||
options={dateOptions}
|
||||
value={dateFilter}
|
||||
onChange={setDateFilter}
|
||||
label="Дата"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('id')}
|
||||
{/* Сообщение об ошибке */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Таблица заказов */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : filteredOrders.length === 0 ? (
|
||||
<div className="text-center p-8 bg-slate-50 rounded-md">
|
||||
<Filter className="h-12 w-12 mx-auto text-slate-300 mb-2" />
|
||||
<h3 className="text-lg font-medium mb-1">Заказы не найдены</h3>
|
||||
<p className="text-slate-500 mb-4">Попробуйте изменить параметры фильтрации</p>
|
||||
{(statusFilter || dateRange?.from || searchTerm) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setStatusFilter('all');
|
||||
setDateRange(undefined);
|
||||
setSearchTerm('');
|
||||
}}
|
||||
>
|
||||
№ заказа {getSortIcon('id')}
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('user_name')}
|
||||
>
|
||||
Клиент {getSortIcon('user_name')}
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('created_at')}
|
||||
>
|
||||
Дата {getSortIcon('created_at')}
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('items_count')}
|
||||
>
|
||||
Товары {getSortIcon('items_count')}
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('status')}
|
||||
>
|
||||
Статус {getSortIcon('status')}
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('total')}
|
||||
>
|
||||
Сумма {getSortIcon('total')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredOrders.length > 0 ? (
|
||||
filteredOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">#{order.id}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="font-medium">{order.user_name || `Пользователь #${order.user_id}`}</div>
|
||||
{order.user_email && (
|
||||
<div className="text-xs text-gray-400">{order.user_email}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div title={order.created_at}>
|
||||
{formatDate(order.created_at)}
|
||||
</div>
|
||||
{order.updated_at && order.updated_at !== order.created_at && (
|
||||
<div className="text-xs text-gray-400" title={`Обновлен: ${order.updated_at}`}>
|
||||
изменен: {new Date(order.updated_at).toLocaleDateString('ru-RU')}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium">{order.items_count || (order.items && order.items.length) || 0}</span>
|
||||
<span className="ml-1">шт.</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusClass(order.status)}`}
|
||||
title={order.updated_at ? `Обновлено: ${formatDate(order.updated_at)}` : ''}>
|
||||
{getStatusLabel(order.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{formatPrice(order.total || 0)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link
|
||||
href={`/admin/orders/${order.id}`}
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Подробнее
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center text-sm text-gray-500">
|
||||
<div className="flex flex-col items-center">
|
||||
<p className="text-lg mb-2">Заказы не найдены</p>
|
||||
<p className="text-sm text-gray-400">Попробуйте изменить параметры поиска или фильтрации</p>
|
||||
<button
|
||||
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setStatusFilter('');
|
||||
setDateFilter('');
|
||||
}}
|
||||
>
|
||||
Сбросить фильтры
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
Сбросить все фильтры
|
||||
</Button>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="w-[80px] cursor-pointer"
|
||||
onClick={() => handleSort('id')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
ID {getSortIcon('id')}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleSort('user_name')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Клиент {getSortIcon('user_name')}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer w-[180px]"
|
||||
onClick={() => handleSort('created_at')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Дата {getSortIcon('created_at')}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px]">Товары</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer w-[140px]"
|
||||
onClick={() => handleSort('status')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Статус {getSortIcon('status')}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer text-right w-[120px]"
|
||||
onClick={() => handleSort('total')}
|
||||
>
|
||||
<div className="flex items-center justify-end">
|
||||
Сумма {getSortIcon('total')}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right w-[120px]">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredOrders.map((order) => (
|
||||
<TableRow key={order.id} className="hover:bg-muted/50">
|
||||
<TableCell className="font-medium">#{order.id}</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">
|
||||
{order.user_name || `Пользователь #${order.user_id}`}
|
||||
</div>
|
||||
{order.user_email && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{order.user_email}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div title={order.created_at}>
|
||||
{formatDate(order.created_at)}
|
||||
</div>
|
||||
{order.updated_at && order.updated_at !== order.created_at && (
|
||||
<div
|
||||
className="text-xs text-muted-foreground"
|
||||
title={`Обновлен: ${order.updated_at}`}
|
||||
>
|
||||
обновлен: {new Date(order.updated_at).toLocaleDateString('ru-RU')}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium">
|
||||
{order.items_count || (order.items && order.items.length) || 0}
|
||||
</span>
|
||||
<span className="ml-1">шт.</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<OrderStatusBadge status={order.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatPrice(order.total_amount || 0)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/admin/orders/${order.id}`)}
|
||||
>
|
||||
Подробнее
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,199 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import ProductCompleteForm from '@/components/admin/ProductCompleteForm';
|
||||
import {
|
||||
Product,
|
||||
ProductUpdateComplete,
|
||||
Size,
|
||||
fetchProduct,
|
||||
updateProductComplete,
|
||||
deleteProduct
|
||||
} from '@/lib/api';
|
||||
import { fetchCategories, Category } from '@/lib/catalog-admin';
|
||||
import { fetchSizes, uploadProductImage } from '@/lib/catalog';
|
||||
|
||||
interface EditProductPageProps {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function EditProductPage({ params }: EditProductPageProps) {
|
||||
const productId = parseInt(params.id);
|
||||
const router = useRouter();
|
||||
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [sizes, setSizes] = useState<Size[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Загрузка данных при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Загружаем продукт, категории и размеры параллельно
|
||||
const [productResponse, categoriesResponse, sizesResponse] = await Promise.all([
|
||||
fetchProduct(productId),
|
||||
fetchCategories(),
|
||||
fetchSizes()
|
||||
]);
|
||||
|
||||
// Обрабатываем результаты
|
||||
if (!productResponse.success || !productResponse.data) {
|
||||
setError(productResponse.error || 'Не удалось загрузить данные о продукте');
|
||||
return;
|
||||
}
|
||||
|
||||
setProduct(productResponse.data);
|
||||
setCategories(categoriesResponse.data || []);
|
||||
setSizes(sizesResponse.data || []);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке данных:', error);
|
||||
setError('Не удалось загрузить необходимые данные');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [productId]);
|
||||
|
||||
// Обработчик обновления продукта
|
||||
const handleUpdate = async (formData: ProductUpdateComplete & { localImages?: File[] }) => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
// Извлекаем локальные изображения из formData
|
||||
const localImages = formData.localImages || [];
|
||||
const formDataToSend = { ...formData };
|
||||
delete formDataToSend.localImages;
|
||||
|
||||
// Отправляем данные на сервер
|
||||
const response = await updateProductComplete(productId, formDataToSend);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Произошла ошибка при обновлении продукта');
|
||||
}
|
||||
|
||||
// Загружаем новые изображения, если они есть
|
||||
if (localImages.length > 0) {
|
||||
const imagePromises = localImages.map(async (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('is_primary', 'false');
|
||||
formData.append('alt_text', file.name);
|
||||
|
||||
return uploadProductImage(productId, formData);
|
||||
});
|
||||
|
||||
await Promise.all(imagePromises);
|
||||
}
|
||||
|
||||
toast.success('Продукт успешно обновлен');
|
||||
|
||||
// Обновляем продукт в состоянии
|
||||
const updatedProductResponse = await fetchProduct(productId);
|
||||
if (updatedProductResponse.success && updatedProductResponse.data) {
|
||||
setProduct(updatedProductResponse.data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка при обновлении продукта:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
setError(error.message);
|
||||
} else {
|
||||
setError('Произошла ошибка при обновлении продукта');
|
||||
}
|
||||
|
||||
toast.error('Не удалось обновить продукт');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик удаления продукта
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Вы уверены, что хотите удалить этот продукт? Это действие нельзя отменить.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
|
||||
const response = await deleteProduct(productId);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Произошла ошибка при удалении продукта');
|
||||
}
|
||||
|
||||
toast.success('Продукт успешно удален');
|
||||
router.push('/admin/products');
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка при удалении продукта:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
setError(error.message);
|
||||
} else {
|
||||
setError('Произошла ошибка при удалении продукта');
|
||||
}
|
||||
|
||||
toast.error('Не удалось удалить продукт');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="text-center p-12">
|
||||
<div className="loader"></div>
|
||||
<p className="mt-4 text-gray-600">Загрузка данных...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6">
|
||||
{error}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/admin/products')}
|
||||
className="bg-gray-600 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Вернуться к списку продуктов
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-2xl font-bold mb-6">Редактирование товара</h1>
|
||||
|
||||
{product && (
|
||||
<ProductCompleteForm
|
||||
initialData={product}
|
||||
categories={categories}
|
||||
sizes={sizes}
|
||||
onSubmit={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
isLoading={isSaving || isDeleting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,100 +1,165 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, Edit, Trash } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { fetchProduct, updateProduct, deleteProduct, Product, ApiResponse, BASE_URL } from '@/lib/api';
|
||||
import { fetchCategories, Category } from '@/lib/catalog-admin';
|
||||
import ProductForm from '@/components/admin/ProductForm';
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
export default function EditProductPage({ params }: { params: { id: string } }) {
|
||||
import catalogService, { ProductUpdateComplete, ProductDetails, Category, Size, Collection } from "@/lib/catalog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import ProductCompleteForm from "@/components/admin/ProductCompleteForm";
|
||||
|
||||
export default function ProductPage({ params }: { params: { id: string } }) {
|
||||
const router = useRouter();
|
||||
const productId = parseInt(params.id);
|
||||
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
|
||||
const [product, setProduct] = useState<ProductDetails | null>(null);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [sizes, setSizes] = useState<Size[]>([]);
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingCategories, setLoadingCategories] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Загрузка данных товара
|
||||
|
||||
const handleError = (err: any, defaultMessage: string) => {
|
||||
console.error(err);
|
||||
let errorMessage = defaultMessage;
|
||||
if (err?.response?.data?.detail) {
|
||||
errorMessage = err.response.data.detail;
|
||||
} else if (err?.message) {
|
||||
errorMessage = err.message;
|
||||
}
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
};
|
||||
|
||||
// Загрузка товара, категорий и размеров
|
||||
useEffect(() => {
|
||||
const loadProduct = async () => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetchProduct(productId);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setProduct(response.data);
|
||||
} else {
|
||||
throw new Error(response.error || 'Не удалось загрузить товар');
|
||||
setError(null);
|
||||
|
||||
const [productResponse, categoriesResponse, sizesResponse, collectionsResponse] = await Promise.all([
|
||||
catalogService.getProductById(productId),
|
||||
catalogService.getCategoriesTree(),
|
||||
catalogService.getSizes(),
|
||||
catalogService.getCollections()
|
||||
]);
|
||||
|
||||
if (!productResponse) {
|
||||
throw new Error("Товар не найден");
|
||||
}
|
||||
|
||||
setProduct(productResponse);
|
||||
setCategories(Array.isArray(categoriesResponse) ? categoriesResponse : []);
|
||||
setSizes(Array.isArray(sizesResponse) ? sizesResponse : []);
|
||||
setCollections(Array.isArray(collectionsResponse?.collections) ? collectionsResponse.collections : []);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке товара:', err);
|
||||
setError('Не удалось загрузить товар');
|
||||
handleError(err, "Не удалось загрузить данные");
|
||||
// Устанавливаем пустые массивы в случае ошибки
|
||||
setCategories([]);
|
||||
setSizes([]);
|
||||
setCollections([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (productId) {
|
||||
loadProduct();
|
||||
}
|
||||
loadData();
|
||||
}, [productId]);
|
||||
|
||||
// Загрузка категорий
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
setLoadingCategories(true);
|
||||
const response = await fetchCategories();
|
||||
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
setCategories(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке категорий:', err);
|
||||
} finally {
|
||||
setLoadingCategories(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
// Обработчик сохранения товара
|
||||
const handleSubmit = async (formData: Partial<Product>) => {
|
||||
// Обработчик сохранения
|
||||
const handleSubmit = async (formData: any) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
console.log('Сохранение товара с данными:', formData);
|
||||
|
||||
// Подготавливаем данные для обновления
|
||||
const updateData: Partial<Product> = {
|
||||
// Подготовка данных для комплексного обновления товара
|
||||
const updateData: ProductUpdateComplete = {
|
||||
name: formData.name,
|
||||
slug: formData.slug || undefined,
|
||||
slug: formData.slug,
|
||||
description: formData.description,
|
||||
price: formData.price,
|
||||
discount_price: formData.discount_price,
|
||||
price: parseFloat(formData.price),
|
||||
discount_price: formData.discount_price ? parseFloat(formData.discount_price) : undefined,
|
||||
care_instructions: formData.care_instructions,
|
||||
is_active: formData.is_active,
|
||||
category_id: formData.category_id ? parseInt(formData.category_id.toString()) : undefined,
|
||||
collection_id: formData.collection_id ? parseInt(formData.collection_id.toString()) : undefined,
|
||||
care_instructions: formData.care_instructions
|
||||
category_id: formData.category_id ? parseInt(formData.category_id) : undefined,
|
||||
collection_id: formData.collection_id ? parseInt(formData.collection_id) : undefined,
|
||||
|
||||
// Обработка вариантов товара
|
||||
variants: formData.variants?.map((variant: any) => ({
|
||||
id: variant.id, // если id есть - будет обновление, если нет - создание
|
||||
size_id: variant.size_id,
|
||||
sku: variant.sku || '',
|
||||
stock: variant.stock || 0,
|
||||
is_active: variant.is_active !== false
|
||||
})),
|
||||
|
||||
// Добавляем список вариантов для удаления
|
||||
variants_to_remove: formData.variantsToRemove,
|
||||
|
||||
// Добавляем список обновленных изображений
|
||||
images: formData.images?.filter((img: any) => img.id).map((img: any) => ({
|
||||
id: img.id,
|
||||
image_url: img.image_url,
|
||||
alt_text: img.alt_text || '',
|
||||
is_primary: img.is_primary || false
|
||||
})),
|
||||
|
||||
// Добавляем список изображений для удаления
|
||||
images_to_remove: formData.imagesToRemove
|
||||
};
|
||||
|
||||
console.log('Отправка запроса на комплексное обновление товара:', updateData);
|
||||
|
||||
console.log('Отправляем данные для обновления:', updateData);
|
||||
|
||||
const response = await updateProduct(productId, updateData);
|
||||
|
||||
if (response.success) {
|
||||
console.log('Товар успешно обновлен');
|
||||
router.push('/admin/products');
|
||||
} else {
|
||||
throw new Error(`Ошибка обновления: ${response.error}`);
|
||||
try {
|
||||
const updatedProduct = await catalogService.updateProductComplete(productId, updateData);
|
||||
|
||||
if (!updatedProduct) {
|
||||
throw new Error('Не удалось обновить товар');
|
||||
}
|
||||
console.log('Товар успешно обновлен:', updatedProduct);
|
||||
|
||||
// Загрузка локальных изображений отдельно
|
||||
if (formData.localImages && Array.isArray(formData.localImages) && formData.localImages.length > 0) {
|
||||
console.log('Загрузка новых изображений:', formData.localImages.length);
|
||||
for (let i = 0; i < formData.localImages.length; i++) {
|
||||
try {
|
||||
const file = formData.localImages[i];
|
||||
console.log(`Загрузка изображения ${i + 1}/${formData.localImages.length}: ${file.name}`);
|
||||
|
||||
// Делаем изображение основным, только если нет других изображений
|
||||
const isPrimary = i === 0 && (!updatedProduct.images || updatedProduct.images.length === 0);
|
||||
await catalogService.uploadProductImage(productId, file, isPrimary);
|
||||
|
||||
// Добавляем небольшую задержку между загрузками
|
||||
if (i < formData.localImages.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
}
|
||||
} catch (imageErr) {
|
||||
console.error(`Ошибка при загрузке изображения ${i+1}:`, imageErr);
|
||||
toast.error(`Ошибка при загрузке изображения ${i+1}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toast.success('Товар успешно обновлен');
|
||||
|
||||
// Перезагружаем товар, чтобы отобразить актуальные данные
|
||||
const refreshedProduct = await catalogService.getProductById(productId);
|
||||
if (refreshedProduct) {
|
||||
setProduct(refreshedProduct);
|
||||
}
|
||||
} catch (apiErr: any) {
|
||||
console.error('Ошибка при API-запросе:', apiErr);
|
||||
handleError(apiErr, 'Не удалось обновить товар через комплексный API');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при сохранении товара:', err);
|
||||
setError('Не удалось сохранить товар');
|
||||
handleError(err, 'Ошибка при подготовке данных товара');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@ -102,199 +167,88 @@ export default function EditProductPage({ params }: { params: { id: string } })
|
||||
|
||||
// Обработчик удаления товара
|
||||
const handleDelete = async () => {
|
||||
if (window.confirm('Вы уверены, что хотите удалить этот товар?')) {
|
||||
try {
|
||||
setSaving(true);
|
||||
const response = await deleteProduct(productId);
|
||||
if (response.success) {
|
||||
router.push('/admin/products');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении товара:', err);
|
||||
setError('Не удалось удалить товар');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
if (!confirm('Вы уверены, что хотите удалить этот товар?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
await catalogService.deleteProduct(productId);
|
||||
toast.success('Товар удален');
|
||||
router.push('/admin/products');
|
||||
} catch (err) {
|
||||
handleError(err, 'Не удалось удалить товар');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Link href="/admin/products" className="mr-4">
|
||||
<ArrowLeft className="h-6 w-6 text-gray-500 hover:text-gray-700" />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{loading ? 'Загрузка...' : `Просмотр: ${product?.name}`}
|
||||
</h1>
|
||||
// Возвращаемся на страницу списка товаров
|
||||
const handleCancel = () => {
|
||||
router.push('/admin/products');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold">Редактирование товара</h1>
|
||||
<Button variant="outline" onClick={handleCancel}>Вернуться</Button>
|
||||
</div>
|
||||
<Separator className="mb-4" />
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !product) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold">Редактирование товара</h1>
|
||||
<Button variant="outline" onClick={handleCancel}>Вернуться</Button>
|
||||
</div>
|
||||
<Separator className="mb-4" />
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button onClick={handleCancel}>Вернуться к списку</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold">Редактирование товара</h1>
|
||||
<Button variant="outline" onClick={handleCancel}>Вернуться</Button>
|
||||
</div>
|
||||
<Separator className="mb-4" />
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-400 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Alert variant="destructive" className="mb-4 rounded">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading || loadingCategories ? (
|
||||
<div className="flex justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
) : product ? (
|
||||
<>
|
||||
<div className="flex space-x-2 mb-6">
|
||||
<Link
|
||||
href={`/admin/products/${product.id}/edit`}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 px-4 py-2 rounded text-white flex items-center"
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Редактировать
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={`/admin/products/${product.id}/edit-complete`}
|
||||
className="bg-green-600 hover:bg-green-700 px-4 py-2 rounded text-white flex items-center"
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Полное редактирование
|
||||
</Link>
|
||||
|
||||
<button
|
||||
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded flex items-center"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash className="w-4 h-4 mr-2" />
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">{product.name}</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-3">Основная информация</h3>
|
||||
<dl className="space-y-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<dt className="text-gray-500">ID:</dt>
|
||||
<dd className="col-span-2">{product.id}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<dt className="text-gray-500">Название:</dt>
|
||||
<dd className="col-span-2">{product.name}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<dt className="text-gray-500">Слаг:</dt>
|
||||
<dd className="col-span-2">{product.slug}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<dt className="text-gray-500">Категория:</dt>
|
||||
<dd className="col-span-2">{product.category?.name || `ID: ${product.category_id}`}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<dt className="text-gray-500">Активен:</dt>
|
||||
<dd className="col-span-2">
|
||||
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
product.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{product.is_active ? 'Да' : 'Нет'}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<dt className="text-gray-500">Цена:</dt>
|
||||
<dd className="col-span-2">
|
||||
{product.price} ₽
|
||||
{product.discount_price && (
|
||||
<span className="text-red-600 ml-2">{product.discount_price} ₽</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-3">Варианты товара</h3>
|
||||
{product.variants && product.variants.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Размер</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Артикул</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Остаток</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{product.variants.map((variant, index) => (
|
||||
<tr key={variant.id || index} className="border-b">
|
||||
<td className="px-3 py-2 text-sm">{variant.size?.name || 'Не указан'}</td>
|
||||
<td className="px-3 py-2 text-sm">{variant.sku || '-'}</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${variant.stock > 10 ? 'bg-green-100 text-green-800' :
|
||||
variant.stock > 0 ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'}`}>
|
||||
{variant.stock || 0}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500">Нет вариантов товара</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-medium mb-3">Описание</h3>
|
||||
<div className="bg-gray-50 p-4 rounded-md">
|
||||
{product.description ? (
|
||||
<p className="whitespace-pre-line">{product.description}</p>
|
||||
) : (
|
||||
<p className="text-gray-500 italic">Описание отсутствует</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{product.images && product.images.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-medium mb-3">Изображения</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{product.images.map((image: any, index: number) => (
|
||||
<div key={index} className="relative">
|
||||
<img
|
||||
src={BASE_URL + image.image_url || image.url || image}
|
||||
alt={`Изображение ${index + 1}`}
|
||||
className="h-32 w-full object-cover rounded-md"
|
||||
/>
|
||||
{(image.is_primary || index === 0) && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<span className="bg-indigo-600 text-white text-xs rounded-full px-2 py-1">
|
||||
Основное
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-red-500">Продукт не найден</p>
|
||||
</div>
|
||||
)}
|
||||
<ProductCompleteForm
|
||||
initialData={product || undefined}
|
||||
categories={categories}
|
||||
sizes={sizes}
|
||||
collections={collections}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
isLoading={saving}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,965 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger
|
||||
} from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
fetchProduct,
|
||||
updateProduct,
|
||||
createProduct,
|
||||
Product,
|
||||
fetchSizes,
|
||||
Size,
|
||||
getImageUrl,
|
||||
uploadProductImage,
|
||||
deleteProductImage,
|
||||
setProductImageAsPrimary
|
||||
} from '@/lib/api';
|
||||
import { fetchCategories, Category } from '@/lib/catalog-admin';
|
||||
import { Upload, X, Plus, Minus, Star, StarOff } from 'lucide-react';
|
||||
|
||||
// Типы для работы с изображениями
|
||||
interface ProductImage {
|
||||
id: number;
|
||||
image_url: string;
|
||||
is_primary?: boolean;
|
||||
alt_text?: string;
|
||||
}
|
||||
|
||||
// Тип для локального изображения, ожидающего загрузки
|
||||
interface LocalImage {
|
||||
file: File;
|
||||
preview: string;
|
||||
isLocal: true;
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
|
||||
interface EditProductDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
productId?: number; // Если не указан, то это создание нового товара
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export default function EditProductDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
productId,
|
||||
onComplete
|
||||
}: EditProductDialogProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('general');
|
||||
|
||||
// Данные
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [sizes, setSizes] = useState<Size[]>([]);
|
||||
|
||||
// Основные данные формы
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
discount_price: null as number | null,
|
||||
care_instructions: {} as Record<string, string>,
|
||||
is_active: true,
|
||||
category_id: '',
|
||||
collection_id: '',
|
||||
images: [] as Array<string | ProductImage | LocalImage>
|
||||
});
|
||||
|
||||
// Состояние для загрузки файлов
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Варианты размеров
|
||||
const [variantSizes, setVariantSizes] = useState<{
|
||||
size_id: number;
|
||||
stock: number;
|
||||
checked: boolean;
|
||||
}[]>([]);
|
||||
|
||||
// Загрузка данных товара
|
||||
useEffect(() => {
|
||||
if (isOpen && productId) {
|
||||
const loadProduct = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetchProduct(productId);
|
||||
|
||||
// Проверяем структуру ответа
|
||||
console.log('Ответ API при загрузке товара:', response);
|
||||
|
||||
// Получаем данные товара в зависимости от структуры ответа
|
||||
let productData: Product | null = null;
|
||||
if (response.data) {
|
||||
if (typeof response.data === 'object' && 'product' in response.data) {
|
||||
// Новая структура API: { data: { product: {...}, success: true } }
|
||||
productData = response.data.product as Product;
|
||||
} else {
|
||||
// Старая структура API: { data: {...} }
|
||||
productData = response.data as Product;
|
||||
}
|
||||
}
|
||||
|
||||
if (productData) {
|
||||
console.log('Загруженные данные товара:', productData);
|
||||
|
||||
// Обработка изображений
|
||||
let productImages: Array<string | ProductImage> = [];
|
||||
if (productData.images) {
|
||||
productImages = Array.isArray(productData.images)
|
||||
? productData.images.map((img: any) => {
|
||||
// Сохраняем объект изображения как есть
|
||||
if (typeof img === 'object' && img !== null && img.image_url) {
|
||||
return {
|
||||
id: img.id || 0,
|
||||
image_url: img.image_url,
|
||||
is_primary: img.is_primary || false,
|
||||
alt_text: img.alt_text || ''
|
||||
} as ProductImage;
|
||||
}
|
||||
// Для строк возвращаем строку
|
||||
return typeof img === 'string' ? img : '';
|
||||
}).filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
// Обработка care_instructions
|
||||
let careInstructions: Record<string, string> = {};
|
||||
if (productData.care_instructions) {
|
||||
if (typeof productData.care_instructions === 'string') {
|
||||
try {
|
||||
// Пробуем распарсить строку как JSON
|
||||
careInstructions = JSON.parse(productData.care_instructions);
|
||||
} catch (e) {
|
||||
// Если не получилось, сохраняем как текст
|
||||
careInstructions = { text: productData.care_instructions };
|
||||
}
|
||||
} else if (typeof productData.care_instructions === 'object') {
|
||||
careInstructions = productData.care_instructions as Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
setProduct(productData);
|
||||
setFormData({
|
||||
name: productData.name || '',
|
||||
slug: productData.slug || '',
|
||||
description: productData.description || '',
|
||||
price: typeof productData.price === 'number' ? productData.price : 0,
|
||||
discount_price: productData.discount_price || null,
|
||||
care_instructions: careInstructions,
|
||||
is_active: productData.is_active !== false, // По умолчанию true
|
||||
category_id: productData.category?.id?.toString() ||
|
||||
(productData.category_id ? productData.category_id.toString() : 'none'),
|
||||
collection_id: productData.collection?.id?.toString() ||
|
||||
(productData.collection_id ? productData.collection_id.toString() : 'none'),
|
||||
images: productImages
|
||||
});
|
||||
|
||||
// Если есть варианты размеров, загрузим их
|
||||
if (productData.variants && Array.isArray(productData.variants)) {
|
||||
// Инициализируем варианты при загрузке размеров
|
||||
console.log('Загружены варианты товара:', productData.variants);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке товара:', err);
|
||||
setError('Не удалось загрузить товар');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadProduct();
|
||||
} else if (isOpen) {
|
||||
// Сброс формы для создания нового товара
|
||||
setFormData({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
discount_price: null,
|
||||
care_instructions: {},
|
||||
is_active: true,
|
||||
category_id: 'none',
|
||||
collection_id: 'none',
|
||||
images: []
|
||||
});
|
||||
setProduct(null);
|
||||
}
|
||||
}, [isOpen, productId]);
|
||||
|
||||
// Загрузка категорий и размеров
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// Загрузка категорий
|
||||
const categoriesResponse = await fetchCategories();
|
||||
if (categoriesResponse.data && Array.isArray(categoriesResponse.data)) {
|
||||
setCategories(categoriesResponse.data);
|
||||
}
|
||||
|
||||
// Загрузка размеров
|
||||
const sizesResponse = await fetchSizes();
|
||||
if (sizesResponse.data && Array.isArray(sizesResponse.data)) {
|
||||
setSizes(sizesResponse.data);
|
||||
|
||||
// Подготовка вариантов размеров
|
||||
const initialVariants = sizesResponse.data.map(size => {
|
||||
// Если редактируем товар и у него есть варианты
|
||||
let existingVariant = null;
|
||||
if (product?.variants && Array.isArray(product.variants)) {
|
||||
existingVariant = product.variants.find(v => v.size_id === size.id);
|
||||
}
|
||||
|
||||
return {
|
||||
size_id: size.id,
|
||||
stock: existingVariant?.stock || 0,
|
||||
checked: !!existingVariant // Отмечаем, если вариант существует
|
||||
};
|
||||
});
|
||||
|
||||
console.log('Инициализированы варианты размеров:', initialVariants);
|
||||
setVariantSizes(initialVariants);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных:', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}
|
||||
}, [isOpen, product]);
|
||||
|
||||
// Обработчик изменения полей формы
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
if (name === 'care_instructions') {
|
||||
try {
|
||||
// Пытаемся распарсить JSON если пользователь ввел валидный JSON
|
||||
const jsonValue = value.trim() ? JSON.parse(value) : {};
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: jsonValue
|
||||
}));
|
||||
} catch (error) {
|
||||
// Иначе сохраняем как строку в объекте
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: { text: value }
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'number'
|
||||
? (value ? parseFloat(value) : 0)
|
||||
: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик изменения чекбокса
|
||||
const handleCheckboxChange = (name: string, checked: boolean) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: checked
|
||||
}));
|
||||
};
|
||||
|
||||
// Обработчик изменения variant size
|
||||
const handleVariantSizeChange = (sizeId: number, checked: boolean) => {
|
||||
setVariantSizes(prev =>
|
||||
prev.map(item =>
|
||||
item.size_id === sizeId
|
||||
? { ...item, checked }
|
||||
: item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Обработчик изменения stock варианта
|
||||
const handleVariantStockChange = (sizeId: number, stock: number) => {
|
||||
setVariantSizes(prev =>
|
||||
prev.map(item =>
|
||||
item.size_id === sizeId
|
||||
? { ...item, stock }
|
||||
: item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Обработчик загрузки изображения
|
||||
const handleImageUpload = () => {
|
||||
console.log('Открытие диалога выбора файла');
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
} else {
|
||||
console.error('Отсутствует ссылка на элемент для загрузки файла');
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик выбора файла - с локальным предпросмотром перед отправкой
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
console.log('Выбраны файлы:', files);
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
console.error('Не выбраны файлы');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// Создаем локальные копии изображений для предпросмотра
|
||||
Array.from(files).forEach((file, index) => {
|
||||
// Создаем URL для предпросмотра
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
|
||||
// Добавляем изображение в состояние с пометкой, что оно локальное
|
||||
const localImage: LocalImage = {
|
||||
file,
|
||||
preview: previewUrl,
|
||||
isLocal: true,
|
||||
isPrimary: formData.images.length === 0 && index === 0 // Первое изображение будет основным, если нет других
|
||||
};
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
images: [...prev.images, localImage]
|
||||
}));
|
||||
|
||||
console.log(`Добавлен предпросмотр ${index + 1}:`, file.name);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка при добавлении файлов:', error);
|
||||
setError('Не удалось добавить выбранные изображения');
|
||||
} finally {
|
||||
// Сбрасываем input для возможности повторной загрузки тех же файлов
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик удаления изображения
|
||||
const handleRemoveImage = async (index: number) => {
|
||||
const image = formData.images[index];
|
||||
|
||||
// Удаление с сервера если это серверное изображение (ProductImage)
|
||||
if (productId && typeof image === 'object' && 'id' in image) {
|
||||
try {
|
||||
const response = await deleteProductImage(productId, image.id);
|
||||
if (response.status !== 200 && response.status !== 204) {
|
||||
console.error('Ошибка при удалении изображения с сервера');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении изображения:', err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Для локальных изображений освобождаем URL
|
||||
if (typeof image === 'object' && 'isLocal' in image && image.isLocal) {
|
||||
URL.revokeObjectURL(image.preview);
|
||||
}
|
||||
|
||||
// Обновление состояния
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
images: prev.images.filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
|
||||
// Обработчик установки изображения как основного
|
||||
const handleSetPrimary = async (index: number) => {
|
||||
const image = formData.images[index];
|
||||
|
||||
// Для серверных изображений делаем API-запрос
|
||||
if (productId && typeof image === 'object' && 'id' in image) {
|
||||
try {
|
||||
const response = await setProductImageAsPrimary(productId, image.id);
|
||||
|
||||
if (response.status !== 200 && response.status !== 204) {
|
||||
console.error('Ошибка при установке основного изображения');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при установке основного изображения:', err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем все изображения, устанавливая is_primary/isPrimary только для выбранного
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
images: prev.images.map((img, i) => {
|
||||
if (typeof img === 'object') {
|
||||
if ('isLocal' in img && img.isLocal) {
|
||||
// Локальное изображение
|
||||
return {
|
||||
...img,
|
||||
isPrimary: i === index
|
||||
};
|
||||
} else if ('id' in img) {
|
||||
// Серверное изображение
|
||||
return {
|
||||
...img,
|
||||
is_primary: i === index
|
||||
};
|
||||
}
|
||||
}
|
||||
return img;
|
||||
})
|
||||
}));
|
||||
};
|
||||
|
||||
// Функция для загрузки локальных файлов на сервер
|
||||
const uploadLocalImages = async (newProductId: number) => {
|
||||
const localImages = formData.images.filter(
|
||||
(img): img is LocalImage => typeof img === 'object' && 'isLocal' in img && img.isLocal
|
||||
);
|
||||
|
||||
if (localImages.length === 0) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
console.log(`Загрузка ${localImages.length} локальных изображений на сервер`);
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
for (const image of localImages) {
|
||||
console.log(`Загрузка файла ${image.file.name}`);
|
||||
|
||||
const response = await uploadProductImage(newProductId, image.file);
|
||||
|
||||
if (response.status !== 201 || !response.data) {
|
||||
console.error('Ошибка при загрузке изображения:', response);
|
||||
return {
|
||||
success: false,
|
||||
error: response.error || `Не удалось загрузить изображение (код ${response.status})`
|
||||
};
|
||||
}
|
||||
|
||||
// Если изображение было отмечено как основное, устанавливаем его основным на сервере
|
||||
const uploadedImageData = response.data;
|
||||
if (image.isPrimary && uploadedImageData && typeof uploadedImageData === 'object') {
|
||||
// Получаем ID изображения в зависимости от структуры ответа
|
||||
const imageId = 'id' in uploadedImageData ?
|
||||
uploadedImageData.id :
|
||||
(uploadedImageData.product && typeof uploadedImageData.product === 'object' && 'id' in uploadedImageData.product ?
|
||||
uploadedImageData.product.id : null);
|
||||
|
||||
if (imageId) {
|
||||
await setProductImageAsPrimary(newProductId, imageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
console.error('Ошибка при загрузке изображений:', err);
|
||||
return {
|
||||
success: false,
|
||||
error: err.message || 'Ошибка при загрузке изображений'
|
||||
};
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик сохранения
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
// Подготовка данных для API
|
||||
const productData = {
|
||||
name: formData.name,
|
||||
slug: formData.slug || formData.name.toLowerCase().replace(/\s+/g, '-'),
|
||||
description: formData.description,
|
||||
price: formData.price,
|
||||
discount_price: formData.discount_price,
|
||||
care_instructions: formData.care_instructions,
|
||||
is_active: formData.is_active,
|
||||
category_id: formData.category_id && formData.category_id !== "none"
|
||||
? parseInt(formData.category_id)
|
||||
: undefined,
|
||||
collection_id: formData.collection_id && formData.collection_id !== "none"
|
||||
? parseInt(formData.collection_id)
|
||||
: undefined
|
||||
// Изображения обрабатываются отдельно через API загрузки изображений
|
||||
};
|
||||
|
||||
let response;
|
||||
if (productId) {
|
||||
// Обновление существующего товара
|
||||
response = await updateProduct(productId, productData);
|
||||
} else {
|
||||
// Создание нового товара
|
||||
response = await createProduct(productData);
|
||||
}
|
||||
|
||||
if (response.status === 200 || response.status === 201) {
|
||||
// Успешное сохранение
|
||||
console.log('Товар успешно сохранен:', response.data);
|
||||
|
||||
// Получаем ID созданного или обновленного товара
|
||||
const savedProductId = productId ||
|
||||
(response.data && typeof response.data === 'object' ?
|
||||
('id' in response.data ?
|
||||
(response.data as Product).id :
|
||||
(response.data.product && typeof response.data.product === 'object' && 'id' in response.data.product ?
|
||||
response.data.product.id : null))
|
||||
: null);
|
||||
|
||||
if (savedProductId) {
|
||||
// Загружаем локальные изображения на сервер
|
||||
const uploadResult = await uploadLocalImages(savedProductId);
|
||||
|
||||
if (!uploadResult.success) {
|
||||
setError(uploadResult.error || 'Не удалось загрузить изображения');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Успешно сохранено и загружены изображения
|
||||
onComplete();
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
throw new Error(`Ошибка сохранения: ${response.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при сохранении товара:', err);
|
||||
setError('Не удалось сохранить товар');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Очистка локальных превью при закрытии диалога
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Освобождаем URL-ы для предпросмотра при закрытии диалога
|
||||
formData.images.forEach(image => {
|
||||
if (typeof image === 'object' && 'isLocal' in image && image.isLocal) {
|
||||
URL.revokeObjectURL(image.preview);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isOpen, formData.images]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{productId ? 'Редактирование товара' : 'Создание нового товара'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Заполните информацию о товаре и его вариантах
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-6 text-center">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full mx-auto"></div>
|
||||
<p className="mt-2 text-sm text-gray-500">Загрузка данных...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Tabs defaultValue="general" value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid grid-cols-3 mb-6">
|
||||
<TabsTrigger value="general">Основная информация</TabsTrigger>
|
||||
<TabsTrigger value="variants">Варианты и размеры</TabsTrigger>
|
||||
<TabsTrigger value="images">Изображения</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Основная информация */}
|
||||
<TabsContent value="general" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Название товара*</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">URL-slug (автоматически)</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
name="slug"
|
||||
value={formData.slug}
|
||||
onChange={handleChange}
|
||||
placeholder="auto-generated-from-name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="price">Цена (₽)*</Label>
|
||||
<Input
|
||||
id="price"
|
||||
name="price"
|
||||
type="number"
|
||||
value={formData.price}
|
||||
onChange={handleChange}
|
||||
min="0"
|
||||
step="0.01"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="discount_price">Цена со скидкой (₽)</Label>
|
||||
<Input
|
||||
id="discount_price"
|
||||
name="discount_price"
|
||||
type="number"
|
||||
value={formData.discount_price || ''}
|
||||
onChange={handleChange}
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="Не указана"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category_id">Категория*</Label>
|
||||
<Select
|
||||
name="category_id"
|
||||
value={formData.category_id}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, category_id: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите категорию" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Не выбрано</SelectItem>
|
||||
{categories.map(category => (
|
||||
<SelectItem key={category.id} value={category.id.toString()}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Описание</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="care_instructions">Инструкции по уходу (JSON)</Label>
|
||||
<Textarea
|
||||
id="care_instructions"
|
||||
name="care_instructions"
|
||||
value={
|
||||
typeof formData.care_instructions === 'string'
|
||||
? formData.care_instructions
|
||||
: formData.care_instructions.text ||
|
||||
JSON.stringify(formData.care_instructions, null, 2)
|
||||
}
|
||||
onChange={handleChange}
|
||||
rows={5}
|
||||
placeholder='Например: {"стирка": "30°C", "глажка": "Средняя температура"}'
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p>Введите инструкции по уходу в формате JSON-объекта:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Используйте двойные кавычки для ключей и значений</li>
|
||||
<li>Разделяйте пары ключ-значение запятыми</li>
|
||||
<li>Пример: {`{"стирка": "30°C", "глажка": "Средняя температура"}`}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="is_active"
|
||||
checked={formData.is_active}
|
||||
onCheckedChange={(checked) =>
|
||||
handleCheckboxChange('is_active', !!checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="is_active">Товар активен</Label>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Варианты товара */}
|
||||
<TabsContent value="variants" className="space-y-4">
|
||||
<div className="bg-muted/50 p-4 rounded-md">
|
||||
<h3 className="font-medium mb-2">Доступные размеры</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Выберите размеры, в которых доступен товар, и укажите количество.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{variantSizes.map((variant) => {
|
||||
const size = sizes.find(s => s.id === variant.size_id);
|
||||
return (
|
||||
<div key={variant.size_id} className="flex items-center space-x-4 p-2 rounded border bg-card">
|
||||
<Checkbox
|
||||
id={`size-${variant.size_id}`}
|
||||
checked={variant.checked}
|
||||
onCheckedChange={(checked) =>
|
||||
handleVariantSizeChange(variant.size_id, !!checked)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`size-${variant.size_id}`}
|
||||
className="flex-grow font-medium"
|
||||
>
|
||||
{size?.name || `Размер ${variant.size_id}`}
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<Label htmlFor={`stock-${variant.size_id}`} className="text-sm">
|
||||
Остаток:
|
||||
</Label>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-8 w-8 rounded-r-none"
|
||||
onClick={() => handleVariantStockChange(
|
||||
variant.size_id,
|
||||
Math.max(0, variant.stock - 1)
|
||||
)}
|
||||
disabled={!variant.checked}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Input
|
||||
id={`stock-${variant.size_id}`}
|
||||
type="number"
|
||||
min="0"
|
||||
value={variant.stock}
|
||||
onChange={(e) => handleVariantStockChange(
|
||||
variant.size_id,
|
||||
parseInt(e.target.value) || 0
|
||||
)}
|
||||
className="w-16 h-8 rounded-none text-center"
|
||||
disabled={!variant.checked}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-8 w-8 rounded-l-none"
|
||||
onClick={() => handleVariantStockChange(
|
||||
variant.size_id,
|
||||
variant.stock + 1
|
||||
)}
|
||||
disabled={!variant.checked}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{variantSizes.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
Нет доступных размеров. Добавьте размеры в разделе "Размеры".
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Изображения */}
|
||||
<TabsContent value="images" className="space-y-4">
|
||||
<div className="bg-muted/50 p-4 rounded-md">
|
||||
<h3 className="font-medium mb-2">Изображения товара</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Загрузите фотографии товара. Установите основное изображение, которое будет отображаться первым.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
||||
{formData.images.map((image, index) => {
|
||||
// Определение URL изображения в зависимости от типа
|
||||
let imageUrl = '';
|
||||
let isPrimary = false;
|
||||
let altText = '';
|
||||
|
||||
if (typeof image === 'object' && 'isLocal' in image && image.isLocal) {
|
||||
// Локальный файл с превью
|
||||
imageUrl = image.preview;
|
||||
isPrimary = image.isPrimary || false;
|
||||
altText = image.file.name;
|
||||
} else if (typeof image === 'object' && 'image_url' in image) {
|
||||
// Изображение с сервера
|
||||
imageUrl = getImageUrl(image.image_url);
|
||||
isPrimary = image.is_primary || false;
|
||||
altText = image.alt_text || `Изображение ${index + 1}`;
|
||||
} else if (typeof image === 'string') {
|
||||
// Просто URL строка
|
||||
imageUrl = getImageUrl(image);
|
||||
altText = `Изображение ${index + 1}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index} className="relative group">
|
||||
<div className={`relative border rounded-md overflow-hidden ${isPrimary ? 'ring-2 ring-primary ring-offset-1' : ''}`}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={altText}
|
||||
className="h-32 w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<div className="flex gap-2">
|
||||
{isPrimary ? (
|
||||
<span className="p-1.5 bg-primary text-white rounded-full shadow-sm">
|
||||
<Star className="h-4 w-4" />
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSetPrimary(index)}
|
||||
className="p-1.5 bg-white/80 hover:bg-white text-gray-700 rounded-full transition-colors shadow-sm"
|
||||
title="Сделать основным"
|
||||
>
|
||||
<StarOff className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveImage(index)}
|
||||
className="p-1.5 bg-red-500/80 hover:bg-red-500 text-white rounded-full transition-colors shadow-sm"
|
||||
title="Удалить"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isPrimary && (
|
||||
<div className="absolute bottom-1 left-1 bg-primary text-white text-xs px-1.5 py-0.5 rounded-sm shadow-sm">
|
||||
Основное
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImageUpload}
|
||||
disabled={isUploading}
|
||||
className="h-32 border-2 border-dashed border-gray-300 rounded-md flex flex-col items-center justify-center text-gray-500 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<div className="h-6 w-6 mb-2 animate-spin border-2 border-current border-t-transparent rounded-full"></div>
|
||||
<span className="text-sm font-medium">Загрузка...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mx-auto h-6 w-6 mb-2" />
|
||||
<span className="text-sm font-medium">Добавить изображение</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Скрытый input для выбора файлов */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.images.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground italic text-center mt-4">
|
||||
Нет изображений. Добавьте хотя бы одно изображение товара.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{error && (
|
||||
<div className="bg-destructive/10 text-destructive p-3 rounded-md text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="h-4 w-4 mr-2 animate-spin border-2 border-current border-t-transparent rounded-full"></div>
|
||||
Сохранение...
|
||||
</>
|
||||
) : (
|
||||
'Сохранить'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -1,102 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { createProduct, ApiResponse, Product } from '@/lib/api';
|
||||
import { fetchCategories, Category } from '@/lib/catalog-admin';
|
||||
import ProductForm from '@/components/admin/ProductForm';
|
||||
|
||||
export default function CreateProductPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loadingCategories, setLoadingCategories] = useState(true);
|
||||
|
||||
// Загрузка категорий
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
setLoadingCategories(true);
|
||||
const response = await fetchCategories();
|
||||
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
setCategories(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке категорий:', err);
|
||||
} finally {
|
||||
setLoadingCategories(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
// Обработчик сохранения товара
|
||||
const handleSubmit = async (formData: any) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const response = await createProduct({
|
||||
name: formData.name,
|
||||
slug: formData.slug || undefined,
|
||||
description: formData.description,
|
||||
price: formData.price,
|
||||
discount_price: formData.discount_price,
|
||||
is_active: formData.is_active,
|
||||
category_id: parseInt(formData.category_id),
|
||||
collection_id: formData.collection_id ? parseInt(formData.collection_id) : undefined,
|
||||
care_instructions: formData.care_instructions
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
router.push('/admin/products');
|
||||
} else {
|
||||
throw new Error(response.error || 'Не удалось создать товар');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при создании товара:', err);
|
||||
setError('Не удалось создать товар');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Link href="/admin/products" className="mr-4">
|
||||
<ArrowLeft className="h-6 w-6 text-gray-500 hover:text-gray-700" />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">Создание товара</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-400 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingCategories ? (
|
||||
<div className="flex justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
) : (
|
||||
<ProductForm
|
||||
categories={categories}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={saving}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,228 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import toast from 'react-hot-toast';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import ProductCompleteForm from '@/components/admin/ProductCompleteForm';
|
||||
import Loader from '@/components/ui/Loader';
|
||||
import { ProductCreateComplete, createProductComplete, Size } from '@/lib/api';
|
||||
import { fetchCategories, Category } from '@/lib/catalog-admin';
|
||||
import { fetchSizes, uploadProductImage } from '@/lib/catalog';
|
||||
|
||||
export default function NewProductPage() {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [sizes, setSizes] = useState<Size[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingStatus, setLoadingStatus] = useState<string>('');
|
||||
|
||||
// Загрузка категорий и размеров при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setLoadingStatus('Загрузка категорий и размеров...');
|
||||
console.log('Загрузка категорий и размеров...');
|
||||
const [categoriesData, sizesData] = await Promise.all([
|
||||
fetchCategories(),
|
||||
fetchSizes()
|
||||
]);
|
||||
|
||||
console.log('Получены категории:', categoriesData);
|
||||
console.log('Получены размеры:', sizesData);
|
||||
|
||||
setCategories(categoriesData.data || []);
|
||||
setSizes(sizesData.data || []);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке данных:', error);
|
||||
setError('Не удалось загрузить необходимые данные');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setLoadingStatus('');
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Обработчик создания продукта
|
||||
const handleCreate = async (formData: ProductCreateComplete & { localImages?: File[] }) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
setLoadingStatus('Подготовка данных продукта...');
|
||||
console.log('Отправка данных для создания продукта:', formData);
|
||||
|
||||
// Извлекаем локальные изображения из formData
|
||||
const localImages = formData.localImages || [];
|
||||
const formDataToSend = { ...formData };
|
||||
delete formDataToSend.localImages;
|
||||
|
||||
// Создаем продукт без изображений
|
||||
setLoadingStatus('Создание товара на сервере...');
|
||||
console.log('Вызов API createProductComplete с данными:', formDataToSend);
|
||||
const response = await createProductComplete(formDataToSend);
|
||||
console.log('Ответ API:', response);
|
||||
|
||||
if (!response.success) {
|
||||
console.error('Ошибка при создании товара:', response.error);
|
||||
throw new Error(response.error || 'Произошла ошибка при создании товара');
|
||||
}
|
||||
|
||||
if (!response.data || !response.data.id) {
|
||||
console.error('Неверный формат данных от сервера:', response.data);
|
||||
throw new Error('Сервер вернул неверный формат данных');
|
||||
}
|
||||
|
||||
const productId = response.data.id;
|
||||
console.log('Продукт создан с ID:', productId);
|
||||
|
||||
// Загружаем изображения отдельно после создания продукта
|
||||
if (localImages.length > 0) {
|
||||
setLoadingStatus(`Загрузка изображений (0/${localImages.length})...`);
|
||||
console.log(`Загрузка ${localImages.length} изображений для продукта...`);
|
||||
|
||||
for (let i = 0; i < localImages.length; i++) {
|
||||
try {
|
||||
const file = localImages[i];
|
||||
setLoadingStatus(`Загрузка изображения ${i+1}/${localImages.length}...`);
|
||||
console.log(`Загрузка изображения ${i + 1}/${localImages.length}: ${file.name}`);
|
||||
|
||||
// Создаем объект FormData и убеждаемся, что файл добавляется правильно
|
||||
const formData = new FormData();
|
||||
// Сначала добавляем файл
|
||||
formData.append('file', file);
|
||||
// Затем добавляем остальные поля
|
||||
formData.append('is_primary', i === 0 ? 'true' : 'false');
|
||||
|
||||
// Добавляем временные поля для обхода ошибки валидации на сервере
|
||||
// Эти поля должны устанавливаться автоматически на бэкенде
|
||||
formData.append('id', '0'); // Временный ID
|
||||
formData.append('created_at', new Date().toISOString()); // Текущая дата
|
||||
|
||||
// Проверяем содержимое FormData
|
||||
const formDataEntries = Array.from(formData.entries());
|
||||
console.log(`FormData для изображения ${i + 1} содержит следующие поля:`);
|
||||
formDataEntries.forEach(([key, value]) => {
|
||||
console.log(`FormData[${key}] =`, value instanceof File
|
||||
? `File(${value.name}, ${value.size} bytes, ${value.type})`
|
||||
: value);
|
||||
});
|
||||
|
||||
// Загружаем файл с таймаутом
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
console.log(`Отправка запроса на загрузку изображения ${i + 1}...`);
|
||||
const imageResponse = await uploadProductImage(productId, formData);
|
||||
console.log(`Ответ на загрузку изображения ${i + 1}:`, imageResponse);
|
||||
|
||||
if (!imageResponse.success) {
|
||||
console.error(`Ошибка при загрузке изображения ${i + 1}:`, imageResponse.error);
|
||||
toast.error(`Ошибка при загрузке изображения ${i + 1}: ${imageResponse.error}`);
|
||||
continue; // Продолжаем с следующим изображением
|
||||
}
|
||||
|
||||
// Добавляем задержку между запросами для предотвращения конфликтов транзакций
|
||||
if (i < localImages.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
} catch (imageError: any) {
|
||||
console.error(`Ошибка при загрузке изображения ${i + 1}:`, imageError);
|
||||
|
||||
// Извлекаем сообщение об ошибке из разных возможных форматов
|
||||
let errorMessage = 'Неизвестная ошибка';
|
||||
if (imageError instanceof Error) {
|
||||
errorMessage = imageError.message;
|
||||
} else if (typeof imageError === 'object') {
|
||||
errorMessage = imageError.error || imageError.message || errorMessage;
|
||||
if (imageError.response) {
|
||||
console.error('Ответ сервера:', imageError.response.data || imageError.response);
|
||||
errorMessage = imageError.response.data?.detail || imageError.response.data?.error || errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
toast.error(`Ошибка при загрузке изображения ${i + 1}: ${errorMessage}`);
|
||||
// Продолжаем загрузку остальных изображений
|
||||
}
|
||||
}
|
||||
console.log('Загрузка изображений завершена');
|
||||
}
|
||||
|
||||
setLoadingStatus('Товар успешно создан!');
|
||||
console.log('Продукт успешно создан, перенаправление...');
|
||||
toast.success('Товар успешно создан');
|
||||
|
||||
// Небольшая задержка перед перенаправлением
|
||||
setTimeout(() => {
|
||||
router.push(`/admin/products/${productId}`);
|
||||
}, 1000);
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка при создании продукта:', error);
|
||||
|
||||
// Формируем понятное сообщение об ошибке
|
||||
let errorMessage = 'Произошла ошибка при создании товара';
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
// Обрабатываем специфические ошибки сервера
|
||||
if (errorMessage.includes('transaction is already begun')) {
|
||||
errorMessage = 'Ошибка базы данных: уже есть активная транзакция. Пожалуйста, попробуйте еще раз.';
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setLoadingStatus('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="flex items-center space-x-2 mb-6">
|
||||
<Link href="/admin/products" className="inline-flex items-center text-gray-700 hover:text-indigo-600">
|
||||
<ArrowLeft size={20} className="mr-1" />
|
||||
Назад к списку товаров
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold mb-6">Создание нового товара</h1>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
||||
<Loader size="large" message={loadingStatus || 'Пожалуйста, подождите...'} />
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
||||
<div className="text-red-500 mb-4">Не удалось загрузить данные категорий и размеров</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
Повторить загрузку
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<ProductCompleteForm
|
||||
categories={categories}
|
||||
sizes={sizes}
|
||||
onSubmit={handleCreate}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
frontend/app/admin/products/new/page.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import toast from 'react-hot-toast';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
import ProductCompleteForm from '@/components/admin/ProductCompleteForm';
|
||||
import catalogService, { Size, ProductCreateComplete, Collection } from '@/lib/catalog';
|
||||
import { Category } from '@/lib/catalog-admin';
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export default function NewProductPage() {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [sizes, setSizes] = useState<Size[]>([]);
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingData, setLoadingData] = useState(true);
|
||||
|
||||
// Обработчик ошибок
|
||||
const handleError = (err: any, defaultMessage: string) => {
|
||||
console.error(err);
|
||||
let errorMessage = defaultMessage;
|
||||
if (err?.response?.data?.detail) {
|
||||
errorMessage = err.response.data.detail;
|
||||
} else if (err?.message) {
|
||||
errorMessage = err.message;
|
||||
}
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
};
|
||||
|
||||
// Загрузка категорий и размеров при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoadingData(true);
|
||||
setError(null);
|
||||
console.log('Загрузка категорий и размеров...');
|
||||
|
||||
// Параллельная загрузка категорий, размеров и коллекций
|
||||
const [categoriesData, sizesData, collectionsData] = await Promise.all([
|
||||
catalogService.getCategoriesTree(),
|
||||
catalogService.getSizes(),
|
||||
catalogService.getCollections()
|
||||
]);
|
||||
|
||||
console.log('Получены категории:', categoriesData);
|
||||
console.log('Получены размеры:', sizesData);
|
||||
console.log('Получены коллекции:', collectionsData);
|
||||
|
||||
setCategories(Array.isArray(categoriesData) ? categoriesData : []);
|
||||
setSizes(Array.isArray(sizesData) ? sizesData : []);
|
||||
setCollections(Array.isArray(collectionsData?.collections) ? collectionsData.collections : []);
|
||||
} catch (err) {
|
||||
handleError(err, 'Не удалось загрузить необходимые данные');
|
||||
// Устанавливаем пустые массивы в случае ошибки
|
||||
setCategories([]);
|
||||
setSizes([]);
|
||||
setCollections([]);
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Обработчик создания продукта
|
||||
const handleCreate = async (formData: any) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log('Отправка данных для создания продукта:', formData);
|
||||
|
||||
// Подготовка данных продукта для комплексного создания
|
||||
const productData: ProductCreateComplete = {
|
||||
name: formData.name,
|
||||
slug: formData.slug || '',
|
||||
description: formData.description || '',
|
||||
price: parseFloat(formData.price) || 0,
|
||||
discount_price: formData.discount_price ? parseFloat(formData.discount_price) : undefined,
|
||||
care_instructions: formData.care_instructions,
|
||||
is_active: formData.is_active !== false,
|
||||
category_id: parseInt(formData.category_id) || 0,
|
||||
collection_id: formData.collection_id ? parseInt(formData.collection_id) : undefined,
|
||||
variants: formData.variants?.map((variant: any) => ({
|
||||
size_id: variant.size_id,
|
||||
sku: variant.sku || '',
|
||||
stock: variant.stock || 0,
|
||||
is_active: variant.is_active !== false
|
||||
})) || []
|
||||
};
|
||||
|
||||
// Сохраняем локальные изображения для отдельной загрузки после создания товара
|
||||
const localImages = formData.localImages || [];
|
||||
|
||||
// Создаем товар и его варианты через комплексный эндпоинт
|
||||
console.log('Отправка данных товара через комплексный эндпоинт:', productData);
|
||||
|
||||
try {
|
||||
const product = await catalogService.createProductComplete(productData);
|
||||
|
||||
if (!product || !product.id) {
|
||||
throw new Error('Не удалось создать товар');
|
||||
}
|
||||
|
||||
const productId = product.id;
|
||||
console.log('Товар создан с ID:', productId);
|
||||
|
||||
// Загружаем локальные изображения отдельно
|
||||
if (localImages.length > 0) {
|
||||
console.log(`Загрузка ${localImages.length} изображений для товара...`);
|
||||
|
||||
for (let i = 0; i < localImages.length; i++) {
|
||||
try {
|
||||
const file = localImages[i];
|
||||
console.log(`Загрузка изображения ${i+1}/${localImages.length}: ${file.name}`);
|
||||
|
||||
// Первое изображение делаем основным
|
||||
const isPrimary = i === 0;
|
||||
await catalogService.uploadProductImage(productId, file, isPrimary);
|
||||
|
||||
// Добавляем задержку между запросами
|
||||
if (i < localImages.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
}
|
||||
} catch (imageErr) {
|
||||
console.error(`Ошибка при загрузке изображения ${i+1}:`, imageErr);
|
||||
toast.error(`Ошибка при загрузке изображения ${i+1}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Товар успешно создан, перенаправление...');
|
||||
toast.success('Товар успешно создан');
|
||||
|
||||
// Перенаправляем на страницу редактирования товара
|
||||
router.push(`/admin/products/${productId}`);
|
||||
} catch (apiErr: any) {
|
||||
console.error('Ошибка при API-запросе:', apiErr);
|
||||
handleError(apiErr, 'Не удалось создать товар');
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err, 'Ошибка при подготовке данных товара');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Возвращаемся на страницу списка товаров
|
||||
const handleCancel = () => {
|
||||
router.push('/admin/products');
|
||||
};
|
||||
|
||||
if (loadingData) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold">Создание нового товара</h1>
|
||||
<Button variant="outline" onClick={handleCancel}>Вернуться</Button>
|
||||
</div>
|
||||
<Separator className="mb-4" />
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold">Создание нового товара</h1>
|
||||
<Button variant="outline" onClick={handleCancel}>Вернуться</Button>
|
||||
</div>
|
||||
<Separator className="mb-4" />
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4 rounded">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ProductCompleteForm
|
||||
categories={categories}
|
||||
sizes={sizes}
|
||||
collections={collections}
|
||||
onSubmit={handleCreate}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,25 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Search, Plus, Edit, Trash, ChevronLeft, ChevronRight, Eye, Filter } from 'lucide-react';
|
||||
import { fetchProducts, deleteProduct, Product, getImageUrl } from '@/lib/api';
|
||||
import { Search, Plus, Edit, Trash, ChevronLeft, ChevronRight, Eye, Filter, RefreshCw } from 'lucide-react';
|
||||
import catalogService, { getImageUrl, ProductDetails } from '@/lib/catalog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
|
||||
import { apiStatus } from '@/lib/api';
|
||||
|
||||
// Компонент таблицы товаров
|
||||
interface ProductsTableProps {
|
||||
products: Product[];
|
||||
products: ProductDetails[];
|
||||
loading: boolean;
|
||||
onDelete: (id: number) => void;
|
||||
selectedProducts: number[];
|
||||
onSelectProduct: (id: number) => void;
|
||||
onSelectAll: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const ProductsTable = ({ products, loading, onDelete }: ProductsTableProps) => {
|
||||
const ProductsTable = ({ products, loading, onDelete, selectedProducts, onSelectProduct, onSelectAll }: ProductsTableProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
if (loading) {
|
||||
@ -40,29 +47,37 @@ const ProductsTable = ({ products, loading, onDelete }: ProductsTableProps) => {
|
||||
}
|
||||
|
||||
// Функция для получения URL первого изображения товара
|
||||
const getProductImageUrl = (product: Product): string | null => {
|
||||
const getProductImageUrl = (product: ProductDetails): string => {
|
||||
if (product.primary_image) {
|
||||
return product.primary_image;
|
||||
}
|
||||
|
||||
if (product.images && Array.isArray(product.images) && product.images.length > 0) {
|
||||
// Если images - массив строк URL
|
||||
if (typeof product.images[0] === 'string') {
|
||||
return getImageUrl(product.images[0]);
|
||||
return product.images[0];
|
||||
}
|
||||
// Если images - массив объектов с полем image_url
|
||||
else if (typeof product.images[0] === 'object' && product.images[0] !== null) {
|
||||
const img = product.images[0] as any;
|
||||
return getImageUrl(img.image_url || '');
|
||||
// Ищем основное изображение
|
||||
const primaryImage = product.images.find(img => img.is_primary);
|
||||
if (primaryImage) {
|
||||
return primaryImage.image_url;
|
||||
}
|
||||
// Или возвращаем первое
|
||||
return product.images[0].image_url;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
return '/placeholder.jpg';
|
||||
};
|
||||
|
||||
// Функция для получения общего количества товаров в наличии
|
||||
const getProductStock = (product: Product): number => {
|
||||
// Если есть variants - суммируем stock по всем вариантам
|
||||
const getProductStock = (product: ProductDetails): number => {
|
||||
if (product.variants && Array.isArray(product.variants) && product.variants.length > 0) {
|
||||
return product.variants.reduce((sum: number, variant) =>
|
||||
sum + (typeof variant.stock === 'number' ? variant.stock : 0), 0);
|
||||
}
|
||||
// Иначе используем стандартное поле stock
|
||||
return typeof product.stock === 'number' ? product.stock : 0;
|
||||
};
|
||||
|
||||
@ -72,10 +87,13 @@ const ProductsTable = ({ products, loading, onDelete }: ProductsTableProps) => {
|
||||
};
|
||||
|
||||
// Функция для просмотра товара в магазине
|
||||
const getProductStoreUrl = (product: Product): string => {
|
||||
const getProductStoreUrl = (product: ProductDetails): string => {
|
||||
return `/catalog/${product.slug || product.id}`;
|
||||
};
|
||||
|
||||
// Проверка, выбраны ли все продукты на текущей странице
|
||||
const areAllSelected = products.length > 0 && products.every(p => selectedProducts.includes(p.id));
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
@ -83,14 +101,21 @@ const ProductsTable = ({ products, loading, onDelete }: ProductsTableProps) => {
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">ID</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Изображение</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Название</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Категория</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Размеры</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Цена</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Остаток</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase tracking-wider">Действия</th>
|
||||
<th className="px-2 py-3 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={areAllSelected}
|
||||
onChange={(e) => onSelectAll(e.target.checked)}
|
||||
className="h-4 w-4 rounded-sm border-muted-foreground"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Изображение</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Название</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Категория</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Цена</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Остаток</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-muted-foreground uppercase tracking-wider">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-muted/20">
|
||||
@ -104,34 +129,29 @@ const ProductsTable = ({ products, loading, onDelete }: ProductsTableProps) => {
|
||||
products.map((product) => {
|
||||
const imageUrl = getProductImageUrl(product);
|
||||
const stockAmount = getProductStock(product);
|
||||
|
||||
// Получаем строку размеров
|
||||
const sizesString = product.variants && product.variants.length > 0
|
||||
? product.variants
|
||||
.filter(v => v.size)
|
||||
.map(v => v.size?.code || v.size?.name)
|
||||
.join(', ')
|
||||
: 'Не указаны';
|
||||
const isSelected = selectedProducts.includes(product.id);
|
||||
|
||||
return (
|
||||
<tr key={product.id} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">#{product.id}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{imageUrl ? (
|
||||
<div className="h-16 w-16 rounded-md overflow-hidden">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-16 w-16 rounded-md bg-muted flex items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground">Нет фото</span>
|
||||
</div>
|
||||
)}
|
||||
<tr key={product.id} className={`hover:bg-muted/30 transition-colors ${isSelected ? 'bg-blue-50' : ''}`}>
|
||||
<td className="px-2 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onSelectProduct(product.id)}
|
||||
className="h-4 w-4 rounded-sm border-muted-foreground"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-4 py-4 whitespace-nowrap text-sm text-muted-foreground">#{product.id}</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="h-16 w-16 rounded-md overflow-hidden">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="max-w-[150px]">
|
||||
<div className="font-medium truncate" title={product.name}>
|
||||
{product.name || 'Без названия'}
|
||||
@ -141,18 +161,12 @@ const ProductsTable = ({ products, loading, onDelete }: ProductsTableProps) => {
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<td className="px-4 py-4 whitespace-nowrap text-sm">
|
||||
<div className="max-w-[120px] truncate" title={product.category?.name}>
|
||||
{product.category?.name ||
|
||||
(product.category_id ? `ID: ${product.category_id}` : '-')}
|
||||
{product.category?.name || (product.category_id ? `ID: ${product.category_id}` : '-')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div className="max-w-[120px] truncate" title={sizesString}>
|
||||
{sizesString}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<td className="px-4 py-4 whitespace-nowrap text-sm">
|
||||
{typeof product.price === 'number' && (
|
||||
<div>
|
||||
<span className="font-medium">{product.price.toLocaleString('ru-RU')} ₽</span>
|
||||
@ -164,7 +178,7 @@ const ProductsTable = ({ products, loading, onDelete }: ProductsTableProps) => {
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<Badge variant={
|
||||
stockAmount > 20 ? 'secondary' :
|
||||
stockAmount > 10 ? 'default' :
|
||||
@ -173,7 +187,7 @@ const ProductsTable = ({ products, loading, onDelete }: ProductsTableProps) => {
|
||||
{stockAmount > 0 ? stockAmount : 'Нет в наличии'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<td className="px-4 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@ -195,18 +209,21 @@ const ProductsTable = ({ products, loading, onDelete }: ProductsTableProps) => {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
asChild
|
||||
<Link
|
||||
href={getProductStoreUrl(product)}
|
||||
target="_blank"
|
||||
passHref
|
||||
>
|
||||
<Link href={getProductStoreUrl(product)} target="_blank">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<Eye size={16} />
|
||||
</Link>
|
||||
</Button>
|
||||
</Button>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Открыть в магазине</p>
|
||||
<p>Просмотреть в магазине</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@ -214,12 +231,12 @@ const ProductsTable = ({ products, loading, onDelete }: ProductsTableProps) => {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(product.id)}
|
||||
>
|
||||
<Trash size={16} className="text-destructive" />
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@ -317,201 +334,304 @@ const Pagination = ({ currentPage, totalPages, onPageChange }: PaginationProps)
|
||||
};
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const router = useRouter();
|
||||
const [products, setProducts] = useState<ProductDetails[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const router = useRouter();
|
||||
const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [productToDelete, setProductToDelete] = useState<number | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// Загрузка товаров
|
||||
const loadProducts = async (page = 1, search = '') => {
|
||||
// Обработчик ошибок
|
||||
const handleError = (err: any, message = 'Произошла ошибка') => {
|
||||
console.error(`${message}:`, err);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
};
|
||||
|
||||
// Загрузка данных
|
||||
const loadProducts = useCallback(async (page = 1, searchQuery = '') => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetchProducts({
|
||||
page,
|
||||
search,
|
||||
limit: 10
|
||||
setError(null);
|
||||
console.log(`Загрузка товаров (страница ${page}, поиск: "${searchQuery}")`);
|
||||
|
||||
const response = await catalogService.getProducts({
|
||||
skip: (page - 1) * pageSize,
|
||||
limit: pageSize,
|
||||
search: searchQuery
|
||||
});
|
||||
|
||||
console.log('Полученные товары:', response);
|
||||
|
||||
// Проверяем разные форматы данных
|
||||
let productsData: Product[] = [];
|
||||
|
||||
if (response.data) {
|
||||
// Проверяем, есть ли обертка product в ответе
|
||||
if (typeof response.data === 'object' && 'product' in response.data) {
|
||||
const productData = response.data.product;
|
||||
// Формат { data: { product: { items: [...] } } }
|
||||
if (productData && typeof productData === 'object' && 'items' in productData) {
|
||||
productsData = productData.items as Product[];
|
||||
// @ts-ignore - игнорируем ошибку типа, так как мы проверяем существование свойства
|
||||
setTotalPages(productData.total_pages || 1);
|
||||
}
|
||||
// Формат { data: { product: [...] } }
|
||||
else if (Array.isArray(productData)) {
|
||||
productsData = productData;
|
||||
setTotalPages(Math.ceil(productData.length / 10) || 1);
|
||||
}
|
||||
}
|
||||
// Формат { data: { items: [...] } }
|
||||
else if (typeof response.data === 'object' && 'items' in response.data) {
|
||||
productsData = response.data.items as Product[];
|
||||
// @ts-ignore - игнорируем ошибку типа, так как мы проверяем существование свойства
|
||||
setTotalPages(response.data.total_pages || 1);
|
||||
}
|
||||
// Формат { data: [...] } - массив товаров
|
||||
else if (Array.isArray(response.data)) {
|
||||
productsData = response.data;
|
||||
setTotalPages(Math.ceil(productsData.length / 10) || 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Отфильтровываем по поиску, если сервер не поддерживает поиск
|
||||
if (search && Array.isArray(productsData)) {
|
||||
productsData = productsData.filter(product =>
|
||||
product.name.toLowerCase().includes(search.toLowerCase()));
|
||||
}
|
||||
|
||||
// Делаем пагинацию на клиенте, если сервер не поддерживает пагинацию
|
||||
if (page > 1 && Array.isArray(productsData) && response.data && !('total_pages' in response.data)) {
|
||||
const startIndex = (page - 1) * 10;
|
||||
productsData = productsData.slice(startIndex, startIndex + 10);
|
||||
}
|
||||
|
||||
setProducts(productsData);
|
||||
setError(null);
|
||||
console.log('Получено товаров:', response.products?.length || 0, 'из', response.total);
|
||||
setProducts(response.products as unknown as ProductDetails[] || []);
|
||||
setTotalPages(Math.ceil((response.total || 0) / pageSize) || 1);
|
||||
setCurrentPage(page);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке товаров:', err);
|
||||
setError('Не удалось загрузить товары');
|
||||
setProducts([]);
|
||||
handleError(err, 'Не удалось загрузить товары');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [pageSize]);
|
||||
|
||||
// Загрузка при монтировании и изменении страницы/поиска
|
||||
// Загрузка при монтировании компонента
|
||||
useEffect(() => {
|
||||
loadProducts(currentPage, searchQuery);
|
||||
}, [currentPage]);
|
||||
loadProducts(1, search);
|
||||
}, [loadProducts]);
|
||||
|
||||
// Обработчик поиска
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(1);
|
||||
loadProducts(1, searchQuery);
|
||||
const handleSearch = () => {
|
||||
loadProducts(1, search);
|
||||
};
|
||||
|
||||
// Обработчик изменения страницы
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page < 1 || page > totalPages) return;
|
||||
setCurrentPage(page);
|
||||
loadProducts(page, search);
|
||||
};
|
||||
|
||||
// Обработчик обновления списка
|
||||
const handleRefresh = () => {
|
||||
loadProducts(currentPage, search);
|
||||
};
|
||||
|
||||
// Обработчик удаления товара
|
||||
const handleDelete = async (id: number) => {
|
||||
if (window.confirm('Вы уверены, что хотите удалить этот товар?')) {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
const response = await deleteProduct(id);
|
||||
try {
|
||||
setDeleting(true);
|
||||
console.log(`Удаление товара с ID: ${id}`);
|
||||
|
||||
const success = await catalogService.deleteProduct(id);
|
||||
if (success) {
|
||||
console.log(`Товар с ID ${id} успешно удален`);
|
||||
toast.success('Товар успешно удален');
|
||||
|
||||
if (response.success) {
|
||||
toast({
|
||||
title: "Товар удален",
|
||||
description: "Товар успешно удален из каталога",
|
||||
variant: "default",
|
||||
});
|
||||
// Обновляем список товаров после удаления
|
||||
loadProducts(currentPage, searchQuery);
|
||||
} else {
|
||||
throw new Error(response.error || 'Ошибка при удалении');
|
||||
// Обновляем список товаров
|
||||
loadProducts(
|
||||
// Если на странице остался 1 товар и мы его удалили, то переходим на предыдущую страницу
|
||||
products.length === 1 && currentPage > 1 ? currentPage - 1 : currentPage,
|
||||
search
|
||||
);
|
||||
|
||||
// Если товар был выбран, удаляем его из выбранных
|
||||
if (selectedProducts.includes(id)) {
|
||||
setSelectedProducts(prev => prev.filter(productId => productId !== id));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении товара:', err);
|
||||
toast({
|
||||
title: "Ошибка",
|
||||
description: "Не удалось удалить товар",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
} else {
|
||||
throw new Error('Не удалось удалить товар');
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err, 'Не удалось удалить товар');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setProductToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик выбора товара
|
||||
const handleSelectProduct = (id: number) => {
|
||||
setSelectedProducts(prev => {
|
||||
if (prev.includes(id)) {
|
||||
return prev.filter(productId => productId !== id);
|
||||
} else {
|
||||
return [...prev, id];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Обработчик выбора всех товаров
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedProducts(products.map(p => p.id));
|
||||
} else {
|
||||
setSelectedProducts([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Показ диалога подтверждения удаления
|
||||
const confirmDelete = (id: number) => {
|
||||
setProductToDelete(id);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// Обработчик удаления выбранных товаров
|
||||
const handleDeleteSelected = async () => {
|
||||
if (!selectedProducts.length) return;
|
||||
|
||||
try {
|
||||
setDeleting(true);
|
||||
let successCount = 0;
|
||||
|
||||
for (const id of selectedProducts) {
|
||||
try {
|
||||
const success = await catalogService.deleteProduct(id);
|
||||
if (success) {
|
||||
successCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Ошибка при удалении товара с ID ${id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(`Удалено ${successCount} товаров`);
|
||||
setSelectedProducts([]);
|
||||
// Обновляем список товаров
|
||||
loadProducts(currentPage, search);
|
||||
} else {
|
||||
toast.error('Не удалось удалить выбранные товары');
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err, 'Ошибка при удалении товаров');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="container mx-auto py-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Товары</h1>
|
||||
<Link href="/admin/products/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Создать товар
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-400 p-4 rounded">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-2xl font-bold">Управление товарами</CardTitle>
|
||||
<div className="flex space-x-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/products/new">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Добавить товар
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/admin/products/new-complete">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Добавить товар (полная форма)
|
||||
</Link>
|
||||
</Button>
|
||||
<CardHeader className="border-b px-5 py-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Список товаров</CardTitle>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={loading}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Обновить
|
||||
</Button>
|
||||
|
||||
{selectedProducts.length > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={deleting}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
Удалить выбранные ({selectedProducts.length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form onSubmit={handleSearch} className="flex gap-2 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Поиск товаров..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<div className="flex-1">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Поиск товаров..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSearch} disabled={loading}>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">
|
||||
Найти
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => router.push('/admin/products/filters')}>
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Фильтры
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border-l-4 border-destructive p-4 mb-4 rounded">
|
||||
<p className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={pageSize.toString()}
|
||||
onValueChange={(value) => {
|
||||
setPageSize(parseInt(value));
|
||||
loadProducts(1, search);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Показывать по" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5 на странице</SelectItem>
|
||||
<SelectItem value="10">10 на странице</SelectItem>
|
||||
<SelectItem value="20">20 на странице</SelectItem>
|
||||
<SelectItem value="50">50 на странице</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
<ProductsTable
|
||||
products={products}
|
||||
loading={loading}
|
||||
onDelete={handleDelete}
|
||||
products={products}
|
||||
loading={loading}
|
||||
onDelete={confirmDelete}
|
||||
selectedProducts={selectedProducts}
|
||||
onSelectProduct={handleSelectProduct}
|
||||
onSelectAll={handleSelectAll}
|
||||
/>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-sm text-muted-foreground">
|
||||
<div>
|
||||
Показано {products.length} из многих товаров
|
||||
</div>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Показано {products.length > 0 ? (currentPage - 1) * pageSize + 1 : 0} - {Math.min(currentPage * pageSize, (currentPage - 1) * pageSize + products.length)} из {totalPages * pageSize}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || loading}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center px-4">
|
||||
Страница {currentPage} из {totalPages}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Подтверждение удаления</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Вы уверены, что хотите удалить этот товар? Это действие нельзя отменить.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => productToDelete && handleDelete(productToDelete)}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? 'Удаление...' : 'Удалить'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
frontend/app/admin/sizes/page.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { SizesManager } from '@/components/admin/SizesManager';
|
||||
import { AdminPageHeader } from '@/components/admin/AdminPageHeader';
|
||||
import { AdminPageContainer } from '@/components/admin/AdminPageContainer';
|
||||
|
||||
export default function SizesPage() {
|
||||
return (
|
||||
<AdminPageContainer>
|
||||
<AdminPageHeader
|
||||
title="Управление размерами"
|
||||
description="Создание, редактирование и удаление размеров"
|
||||
/>
|
||||
<SizesManager />
|
||||
</AdminPageContainer>
|
||||
);
|
||||
}
|
||||
107
frontend/app/cart-backup/page.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
205
frontend/app/checkout-backup/page.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -4,65 +4,65 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(222.2 84% 4.9%);
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(222.2 84% 4.9%);
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(222.2 84% 4.9%);
|
||||
|
||||
--primary: 0 0% 0%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--primary: 151 31% 27%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--secondary: 0 0% 20%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--secondary: 84 38% 37%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--accent: 60 41% 82%;
|
||||
--accent-foreground: 151 31% 27%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 151 31% 27%;
|
||||
|
||||
--radius: 0.25rem;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--background: hsl(222.2 84% 4.9%);
|
||||
--foreground: hsl(210 40% 98%);
|
||||
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--card: hsl(222.2 84% 4.9%);
|
||||
--card-foreground: hsl(210 40% 98%);
|
||||
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--popover: hsl(222.2 84% 4.9%);
|
||||
--popover-foreground: hsl(210 40% 98%);
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--primary: 151 31% 27%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--secondary: 84 38% 37%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--accent: 60 41% 82%;
|
||||
--accent-foreground: 151 31% 27%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 151 31% 27%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,25 +74,81 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
/* Применяем шрифт Arimo к заголовкам */
|
||||
/* Применяем шрифт для заголовков */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply font-arimo tracking-tight font-bold;
|
||||
@apply font-serif tracking-tight;
|
||||
}
|
||||
|
||||
/* Типографика */
|
||||
h1 {
|
||||
@apply font-light;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply font-normal;
|
||||
}
|
||||
|
||||
/* Добавляем более строгую типографику */
|
||||
.heading-text {
|
||||
@apply font-arimo tracking-tight;
|
||||
}
|
||||
|
||||
/* Добавляем более строгие стили для кнопок */
|
||||
/* Стили для кнопок */
|
||||
button,
|
||||
.button {
|
||||
@apply transition-all duration-200;
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
}
|
||||
|
||||
/* Исправления для изображений в продуктах */
|
||||
.product-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: cover;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.product-image-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Анимация загрузки для изображений */
|
||||
.image-loading {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-loading::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #f0f0f0 0%, #f8f8f8 50%, #f0f0f0 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Фиксы для мобильных устройств */
|
||||
@media (max-width: 768px) {
|
||||
.product-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,23 @@
|
||||
import type { Metadata } from "next"
|
||||
import { GeistSans } from "geist/font/sans"
|
||||
import { GeistMono } from "geist/font/mono"
|
||||
import { Playfair_Display } from "next/font/google"
|
||||
import "./globals.css"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider"
|
||||
import { CartProvider } from "@/hooks/use-cart"
|
||||
import { WishlistProvider } from "@/hooks/use-wishlist"
|
||||
import { AuthProvider } from "@/lib/auth"
|
||||
|
||||
const playfair = Playfair_Display({
|
||||
subsets: ['latin', 'cyrillic'],
|
||||
variable: '--font-playfair',
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
// Метаданные сайта
|
||||
export const metadata: Metadata = {
|
||||
title: "Одежда для успеха - интернет-магазин стильной одежды",
|
||||
description: "Интернет-магазин стильной одежды. Большой выбор одежды для мужчин и женщин. Доставка по всей России.",
|
||||
title: "Dressed for Success - бренд женской одежды",
|
||||
description: "Натуральные ткани, утонченный дизайн, комфорт и элегантность. Создаем одежду, в которой удобно и красиво жить свою жизнь.",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
@ -20,19 +27,24 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ru" suppressHydrationWarning>
|
||||
<body className={`${GeistSans.variable} ${GeistMono.variable} font-sans antialiased`}>
|
||||
<body className={`${GeistSans.variable} ${GeistMono.variable} ${playfair.variable} font-sans antialiased`}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="light"
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
themes={['light', 'dark']}
|
||||
value={{
|
||||
light: 'light',
|
||||
dark: 'dark'
|
||||
}}
|
||||
>
|
||||
<CartProvider>
|
||||
<AuthProvider>
|
||||
<WishlistProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</WishlistProvider>
|
||||
</CartProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -14,6 +14,7 @@ module.exports = {
|
||||
fontFamily: {
|
||||
sans: ["var(--font-inter)", "sans-serif"],
|
||||
arimo: ["var(--font-arimo)", "sans-serif"],
|
||||
serif: ["var(--font-playfair)", "serif"],
|
||||
},
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
@ -22,16 +23,16 @@ module.exports = {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "#000000", // Черный для строгого дизайна
|
||||
DEFAULT: "#2B5F47", // Глубокий зеленый
|
||||
foreground: "#FFFFFF",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "#333333", // Темно-серый
|
||||
DEFAULT: "#63823B", // Оливковый
|
||||
foreground: "#FFFFFF",
|
||||
},
|
||||
tertiary: {
|
||||
DEFAULT: "#F5F5F5", // Светло-серый
|
||||
foreground: "#000000",
|
||||
DEFAULT: "#E2E2C1", // Песочный/экрю
|
||||
foreground: "#2B5F47",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
@ -55,9 +56,15 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
DEFAULT: '1rem',
|
||||
'xs': '0.75rem',
|
||||
'sm': '0.875rem',
|
||||
'md': '1.25rem',
|
||||
'lg': '1.75rem',
|
||||
'xl': '2rem',
|
||||
'2xl': '2.5rem',
|
||||
'3xl': '3rem',
|
||||
'full': '9999px',
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
|
||||
BIN
frontend/components/.DS_Store
vendored
14
frontend/components/admin/AdminPageContainer.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
interface AdminPageContainerProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AdminPageContainer({ children, className = '' }: AdminPageContainerProps) {
|
||||
return (
|
||||
<div className={`container mx-auto py-6 space-y-6 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
frontend/components/admin/AdminPageHeader.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
interface AdminPageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AdminPageHeader({ title, description, actions }: AdminPageHeaderProps) {
|
||||
return (
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
|
||||
{description && (
|
||||
<p className="text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div>{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -151,8 +151,8 @@ export function CategoryForm({
|
||||
<FormItem>
|
||||
<FormLabel>Родительская категория</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || undefined}
|
||||
onValueChange={(value) => field.onChange(value === "null" ? null : value)}
|
||||
value={field.value === null ? "null" : field.value || undefined}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
@ -160,7 +160,7 @@ export function CategoryForm({
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Нет родительской категории</SelectItem>
|
||||
<SelectItem value="null">Нет родительской категории</SelectItem>
|
||||
{getFilteredCategories().map((category) => (
|
||||
<SelectItem key={category.id} value={category.id.toString()}>
|
||||
{category.name}
|
||||
|
||||
@ -2,21 +2,36 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Save, Trash, Upload, X, Plus, Minus } from 'lucide-react';
|
||||
import {
|
||||
BASE_URL,
|
||||
Product,
|
||||
Size,
|
||||
ProductVariantCreateNested,
|
||||
ProductImageCreateNested,
|
||||
ProductVariantUpdateNested,
|
||||
ProductImageUpdateNested
|
||||
} from '@/lib/api';
|
||||
import { ProductDetails, ProductVariant, ProductImage, Size, Collection } from '@/lib/catalog';
|
||||
import { Category } from '@/lib/catalog-admin';
|
||||
import { normalizeProductImage } from '@/lib/catalog';
|
||||
|
||||
interface ProductVariantCreateNested {
|
||||
size_id: number;
|
||||
sku: string;
|
||||
stock?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
interface ProductVariantUpdateNested extends ProductVariantCreateNested {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface ProductImageCreateNested {
|
||||
image_url: string;
|
||||
alt_text?: string;
|
||||
is_primary?: boolean;
|
||||
}
|
||||
|
||||
interface ProductImageUpdateNested extends ProductImageCreateNested {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface ProductCompleteFormProps {
|
||||
initialData?: Partial<Product>;
|
||||
initialData?: Partial<ProductDetails>;
|
||||
categories: Category[];
|
||||
sizes: Size[];
|
||||
collections: Collection[];
|
||||
onSubmit: (formData: any) => Promise<void>;
|
||||
onDelete?: () => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
@ -26,6 +41,7 @@ const ProductCompleteForm = ({
|
||||
initialData,
|
||||
categories,
|
||||
sizes,
|
||||
collections,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
isLoading = false
|
||||
@ -55,6 +71,13 @@ const ProductCompleteForm = ({
|
||||
|
||||
// Обновление формы при изменении начальных данных
|
||||
useEffect(() => {
|
||||
// Сбрасываем состояние при изменении начальных данных
|
||||
setVariants([]);
|
||||
setImages([]);
|
||||
setVariantsToRemove([]);
|
||||
setImagesToRemove([]);
|
||||
setLocalImages([]);
|
||||
|
||||
if (initialData) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
@ -251,8 +274,8 @@ const ProductCompleteForm = ({
|
||||
variants,
|
||||
// Отправляем только существующие изображения с сервера (с ID)
|
||||
images: images.filter(img => 'id' in img),
|
||||
variants_to_remove: variantsToRemove.length > 0 ? variantsToRemove : undefined,
|
||||
images_to_remove: imagesToRemove.length > 0 ? imagesToRemove : undefined,
|
||||
variantsToRemove: variantsToRemove,
|
||||
imagesToRemove: imagesToRemove,
|
||||
// Добавляем локальные изображения для последующей обработки
|
||||
localImages: localImages
|
||||
};
|
||||
@ -319,6 +342,29 @@ const ProductCompleteForm = ({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="collection_id" className="block text-sm font-medium text-gray-700">
|
||||
Коллекция
|
||||
</label>
|
||||
<select
|
||||
id="collection_id"
|
||||
name="collection_id"
|
||||
value={formData.collection_id?.toString() || ''}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">Выберите коллекцию</option>
|
||||
{Array.isArray(collections) && collections.map(collection => (
|
||||
<option key={collection.id} value={collection.id}>
|
||||
{collection.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="is_active" className="flex items-center space-x-2 mt-5">
|
||||
<input
|
||||
@ -485,7 +531,7 @@ const ProductCompleteForm = ({
|
||||
{images.map((image, index) => (
|
||||
<div key={index} className="relative">
|
||||
<img
|
||||
src={`${BASE_URL}${image.image_url}`}
|
||||
src={normalizeProductImage(image.image_url)}
|
||||
alt={image.alt_text || `Изображение ${index + 1}`}
|
||||
className={`h-32 w-full object-cover rounded-md ${image.is_primary ? 'ring-2 ring-indigo-500' : ''}`}
|
||||
/>
|
||||
|
||||
@ -1,34 +1,28 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { productsApi } from "@/lib/api";
|
||||
import api from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Loader2, Upload, X, Check, AlertCircle, Image as ImageIcon } from "lucide-react";
|
||||
import { Loader2, Upload, X, Check, AlertCircle, Image as ImageIcon, Plus, Trash } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Интерфейсы
|
||||
interface ProductImage {
|
||||
id: string;
|
||||
url: string;
|
||||
is_primary: boolean;
|
||||
alt_text?: string;
|
||||
}
|
||||
import { ProductDetails, ProductImage } from '@/lib/catalog';
|
||||
import { getImageUrl } from '@/lib/catalog';
|
||||
|
||||
interface ProductImagesProps {
|
||||
productId: string;
|
||||
productName: string;
|
||||
product: ProductDetails;
|
||||
onUpdate: (images: ProductImage[]) => void;
|
||||
}
|
||||
|
||||
export function ProductImages({ productId, productName }: ProductImagesProps) {
|
||||
export default function ProductImages({ product, onUpdate }: ProductImagesProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [images, setImages] = useState<ProductImage[]>([]);
|
||||
const [images, setImages] = useState<ProductImage[]>(product.images || []);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [imageToDelete, setImageToDelete] = useState<string | null>(null);
|
||||
const [imageToDelete, setImageToDelete] = useState<number | null>(null);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [currentImage, setCurrentImage] = useState<ProductImage | null>(null);
|
||||
const [altText, setAltText] = useState("");
|
||||
@ -38,10 +32,10 @@ export function ProductImages({ productId, productName }: ProductImagesProps) {
|
||||
const fetchImages = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await productsApi.getImages(productId);
|
||||
const response = await api.get<ProductImage[]>(`/catalog/products/${product.id}/images`);
|
||||
|
||||
if (response.status === 200 && response.data) {
|
||||
setImages(response.data as ProductImage[]);
|
||||
if (response) {
|
||||
setImages(response);
|
||||
} else {
|
||||
throw new Error("Не удалось загрузить изображения");
|
||||
}
|
||||
@ -56,51 +50,47 @@ export function ProductImages({ productId, productName }: ProductImagesProps) {
|
||||
};
|
||||
|
||||
fetchImages();
|
||||
}, [productId]);
|
||||
}, [product.id]);
|
||||
|
||||
// Обработчик выбора файла
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
if (!files) return;
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', files[0]);
|
||||
formData.append('is_primary', images.length === 0 ? 'true' : 'false');
|
||||
|
||||
const response = await productsApi.uploadImage(productId, formData);
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
formData.append('images', files[i]);
|
||||
}
|
||||
|
||||
const response = await api.post<ProductImage[]>(`/catalog/products/${product.id}/images`, formData);
|
||||
|
||||
if (response.status === 201 && response.data) {
|
||||
// Добавляем новое изображение в список
|
||||
setImages(prev => [...prev, response.data as ProductImage]);
|
||||
toast("Успех", {
|
||||
description: "Изображение успешно загружено",
|
||||
if (response) {
|
||||
setImages(response);
|
||||
toast("Успешно", {
|
||||
description: "Изображения загружены",
|
||||
});
|
||||
} else {
|
||||
throw new Error(response.error || "Не удалось загрузить изображение");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка при загрузке изображения:", error);
|
||||
console.error('Ошибка при загрузке изображений:', error);
|
||||
toast("Ошибка", {
|
||||
description: "Не удалось загрузить изображение",
|
||||
description: "Не удалось загрузить изображения",
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
// Сбрасываем значение input, чтобы можно было загрузить тот же файл повторно
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Установка изображения как основного
|
||||
const setAsPrimary = async (imageId: string) => {
|
||||
const setAsPrimary = async (imageId: number) => {
|
||||
try {
|
||||
const response = await productsApi.updateImage(imageId, { is_primary: true });
|
||||
const response = await api.put<ProductImage>(`/catalog/products/${product.id}/images/${imageId}`, {
|
||||
is_primary: true
|
||||
});
|
||||
|
||||
if (response.status === 200 && response.data) {
|
||||
if (response) {
|
||||
// Обновляем список изображений
|
||||
setImages(prev =>
|
||||
prev.map(img => ({
|
||||
@ -112,8 +102,6 @@ export function ProductImages({ productId, productName }: ProductImagesProps) {
|
||||
toast("Успех", {
|
||||
description: "Основное изображение обновлено",
|
||||
});
|
||||
} else {
|
||||
throw new Error(response.error || "Не удалось обновить изображение");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка при обновлении изображения:", error);
|
||||
@ -135,12 +123,12 @@ export function ProductImages({ productId, productName }: ProductImagesProps) {
|
||||
if (!currentImage) return;
|
||||
|
||||
try {
|
||||
const response = await productsApi.updateImage(currentImage.id, {
|
||||
const response = await api.put<ProductImage>(`/catalog/products/${product.id}/images/${currentImage.id}`, {
|
||||
alt_text: altText,
|
||||
is_primary: currentImage.is_primary
|
||||
});
|
||||
|
||||
if (response.status === 200 && response.data) {
|
||||
if (response) {
|
||||
// Обновляем изображение в списке
|
||||
setImages(prev =>
|
||||
prev.map(img => img.id === currentImage.id ? {
|
||||
@ -154,8 +142,6 @@ export function ProductImages({ productId, productName }: ProductImagesProps) {
|
||||
});
|
||||
|
||||
setEditDialogOpen(false);
|
||||
} else {
|
||||
throw new Error(response.error || "Не удалось обновить изображение");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка при обновлении изображения:", error);
|
||||
@ -166,7 +152,7 @@ export function ProductImages({ productId, productName }: ProductImagesProps) {
|
||||
};
|
||||
|
||||
// Открытие диалога удаления
|
||||
const openDeleteDialog = (imageId: string) => {
|
||||
const openDeleteDialog = (imageId: number) => {
|
||||
setImageToDelete(imageId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
@ -176,9 +162,9 @@ export function ProductImages({ productId, productName }: ProductImagesProps) {
|
||||
if (!imageToDelete) return;
|
||||
|
||||
try {
|
||||
const response = await productsApi.deleteImage(imageToDelete);
|
||||
const response = await api.delete(`/catalog/products/${product.id}/images/${imageToDelete}`);
|
||||
|
||||
if (response.status === 204 || response.status === 200) {
|
||||
if (response) {
|
||||
// Удаляем изображение из списка
|
||||
const newImages = images.filter(img => img.id !== imageToDelete);
|
||||
setImages(newImages);
|
||||
@ -193,8 +179,6 @@ export function ProductImages({ productId, productName }: ProductImagesProps) {
|
||||
});
|
||||
|
||||
setDeleteDialogOpen(false);
|
||||
} else {
|
||||
throw new Error(response.error || "Не удалось удалить изображение");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка при удалении изображения:", error);
|
||||
@ -203,115 +187,91 @@ export function ProductImages({ productId, productName }: ProductImagesProps) {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleRemoveImage = (index: number) => {
|
||||
setImages(images.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleReorderImages = (fromIndex: number, toIndex: number) => {
|
||||
const newImages = [...images];
|
||||
const [movedImage] = newImages.splice(fromIndex, 1);
|
||||
newImages.splice(toIndex, 0, movedImage);
|
||||
setImages(newImages);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Изображения продукта</CardTitle>
|
||||
<CardDescription>
|
||||
Управление изображениями товара "{productName}"
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-6">
|
||||
<Label htmlFor="image-upload" className="mb-2 block">
|
||||
Загрузить новое изображение
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Изображения товара</CardTitle>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
id="image-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
ref={fileInputRef}
|
||||
multiple
|
||||
className="hidden"
|
||||
id="image-upload"
|
||||
onChange={handleFileChange}
|
||||
disabled={uploading}
|
||||
className="max-w-md"
|
||||
/>
|
||||
{uploading && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => document.getElementById('image-upload')?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{uploading ? 'Загрузка...' : 'Загрузить'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Поддерживаемые форматы: JPG, PNG, WebP. Максимальный размер: 5 МБ.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : images.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<ImageIcon className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-lg font-medium">Нет изображений</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
У этого продукта еще нет изображений. Загрузите изображения, чтобы покупатели могли увидеть товар.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{images.map((image) => (
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{images.length === 0 ? (
|
||||
<div className="col-span-full text-center py-8 text-muted-foreground">
|
||||
Нет изображений
|
||||
</div>
|
||||
) : (
|
||||
images.map((image, index) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className={`relative rounded-lg overflow-hidden border ${
|
||||
image.is_primary ? 'ring-2 ring-primary' : ''
|
||||
}`}
|
||||
key={image.id}
|
||||
className="relative group aspect-square rounded-lg overflow-hidden bg-muted"
|
||||
>
|
||||
<div className="aspect-square relative">
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.alt_text || productName}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-2 right-2 flex space-x-1">
|
||||
{!image.is_primary && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 bg-white/80 hover:bg-white"
|
||||
onClick={() => setAsPrimary(image.id)}
|
||||
title="Сделать основным"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<img
|
||||
src={getImageUrl(image.image_url)}
|
||||
alt={image.alt_text || `Изображение ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center space-x-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 bg-white/80 hover:bg-white"
|
||||
onClick={() => openEditDialog(image)}
|
||||
title="Редактировать описание"
|
||||
onClick={() => handleReorderImages(index, Math.max(0, index - 1))}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
↑
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => handleReorderImages(index, Math.min(images.length - 1, index + 1))}
|
||||
disabled={index === images.length - 1}
|
||||
>
|
||||
↓
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="h-8 w-8 bg-white/80 hover:bg-white"
|
||||
onClick={() => openDeleteDialog(image.id)}
|
||||
title="Удалить"
|
||||
onClick={() => handleRemoveImage(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<Trash className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{image.is_primary && (
|
||||
<div className="absolute top-2 left-2 bg-primary text-primary-foreground px-2 py-1 text-xs rounded-md">
|
||||
Основное
|
||||
</div>
|
||||
)}
|
||||
|
||||
{image.alt_text && (
|
||||
<div className="p-2 text-sm truncate">
|
||||
{image.alt_text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Диалог редактирования alt-текста */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
|
||||
129
frontend/components/admin/ProductPreview.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import { ProductDetails } from '@/lib/catalog';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getImageUrl } from '@/lib/catalog';
|
||||
import { Eye } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface ProductPreviewProps {
|
||||
product: ProductDetails;
|
||||
}
|
||||
|
||||
export default function ProductPreview({ product }: ProductPreviewProps) {
|
||||
const getProductImageUrl = (product: ProductDetails): string | null => {
|
||||
if (product.images && Array.isArray(product.images) && product.images.length > 0) {
|
||||
if (typeof product.images[0] === 'string') {
|
||||
return getImageUrl(product.images[0]);
|
||||
}
|
||||
else if (typeof product.images[0] === 'object' && product.images[0] !== null) {
|
||||
const img = product.images[0] as any;
|
||||
return getImageUrl(img.image_url || '');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getProductStock = (product: ProductDetails): number => {
|
||||
if (product.variants && Array.isArray(product.variants) && product.variants.length > 0) {
|
||||
return product.variants.reduce((sum: number, variant) =>
|
||||
sum + (typeof variant.stock === 'number' ? variant.stock : 0), 0);
|
||||
}
|
||||
return typeof product.stock === 'number' ? product.stock : 0;
|
||||
};
|
||||
|
||||
const imageUrl = getProductImageUrl(product);
|
||||
const stockAmount = getProductStock(product);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Предпросмотр товара</CardTitle>
|
||||
<Link
|
||||
href={`/catalog/${product.slug || product.id}`}
|
||||
target="_blank"
|
||||
className="text-sm text-muted-foreground hover:text-primary flex items-center"
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
Открыть в магазине
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Изображение */}
|
||||
<div className="aspect-square relative rounded-lg overflow-hidden bg-muted">
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={product.name}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="text-muted-foreground">Нет изображения</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Основная информация */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">{product.name}</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant={product.is_active ? "default" : "secondary"}>
|
||||
{product.is_active ? "Активен" : "Неактивен"}
|
||||
</Badge>
|
||||
<Badge variant={
|
||||
stockAmount > 20 ? 'secondary' :
|
||||
stockAmount > 10 ? 'default' :
|
||||
stockAmount > 0 ? 'destructive' : 'outline'
|
||||
}>
|
||||
{stockAmount > 0 ? `В наличии: ${stockAmount}` : 'Нет в наличии'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Цены */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-2xl font-bold">
|
||||
{(product.price || 0).toLocaleString('ru-RU')} ₽
|
||||
</div>
|
||||
{product.discount_price && (
|
||||
<div className="text-lg text-red-600">
|
||||
{(product.discount_price || 0).toLocaleString('ru-RU')} ₽
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Категория */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Категория: {product.category?.name || 'Не указана'}
|
||||
</div>
|
||||
|
||||
{/* Варианты */}
|
||||
{product.variants && product.variants.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Варианты:</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{product.variants.map((variant, index) => (
|
||||
<div
|
||||
key={variant.id || index}
|
||||
className="text-sm p-2 rounded bg-muted"
|
||||
>
|
||||
<div className="font-medium">{variant.size?.name || 'Без размера'}</div>
|
||||
<div className="text-muted-foreground">
|
||||
Артикул: {variant.sku || '-'}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Остаток: {variant.stock || 0}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -1,439 +1,228 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { productsApi, sizesApi } from "@/lib/api";
|
||||
import api from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Loader2, Plus, Edit, Trash, AlertCircle } from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Trash, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Интерфейсы
|
||||
interface Size {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ProductVariant {
|
||||
id: string;
|
||||
product_id: string;
|
||||
size_id: string;
|
||||
size: Size;
|
||||
sku: string;
|
||||
stock: number;
|
||||
price?: number;
|
||||
}
|
||||
import { ProductDetails, ProductVariant, ProductVariantCreate, ProductVariantUpdate, Size } from '@/lib/catalog';
|
||||
|
||||
interface ProductVariantsProps {
|
||||
productId: string;
|
||||
productName: string;
|
||||
defaultPrice: number;
|
||||
product: ProductDetails;
|
||||
onUpdate: (variants: ProductVariant[]) => void;
|
||||
}
|
||||
|
||||
export function ProductVariants({ productId, productName, defaultPrice }: ProductVariantsProps) {
|
||||
const [variants, setVariants] = useState<ProductVariant[]>([]);
|
||||
export default function ProductVariants({ product, onUpdate }: ProductVariantsProps) {
|
||||
const [variants, setVariants] = useState<ProductVariant[]>(product.variants || []);
|
||||
const [sizes, setSizes] = useState<Size[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [currentVariant, setCurrentVariant] = useState<ProductVariant | null>(null);
|
||||
|
||||
// Состояние формы
|
||||
const [selectedSizeId, setSelectedSizeId] = useState("");
|
||||
const [sku, setSku] = useState("");
|
||||
const [stock, setStock] = useState<number>(0);
|
||||
const [price, setPrice] = useState<number | undefined>(undefined);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// Загрузка данных о вариантах и размерах
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Загрузка списка размеров
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const fetchSizes = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Загружаем варианты продукта
|
||||
const variantsResponse = await productsApi.getVariants(productId);
|
||||
if (variantsResponse.status === 200 && variantsResponse.data) {
|
||||
setVariants(variantsResponse.data as ProductVariant[]);
|
||||
}
|
||||
|
||||
// Загружаем доступные размеры
|
||||
const sizesResponse = await sizesApi.getAll();
|
||||
if (sizesResponse.status === 200 && sizesResponse.data) {
|
||||
setSizes(sizesResponse.data as Size[]);
|
||||
const response = await api.get<Size[]>('/catalog/sizes');
|
||||
if (response) {
|
||||
setSizes(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка при загрузке данных:", error);
|
||||
toast("Ошибка при загрузке данных", {
|
||||
description: "Не удалось загрузить варианты продукта или размеры",
|
||||
console.error('Ошибка при загрузке размеров:', error);
|
||||
toast("Ошибка", {
|
||||
description: "Не удалось загрузить список размеров"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [productId]);
|
||||
|
||||
// Открытие диалога для создания нового варианта
|
||||
const openCreateDialog = () => {
|
||||
setIsEditing(false);
|
||||
setCurrentVariant(null);
|
||||
setSelectedSizeId("");
|
||||
setSku("");
|
||||
setStock(0);
|
||||
setPrice(defaultPrice);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
// Открытие диалога для редактирования варианта
|
||||
const openEditDialog = (variant: ProductVariant) => {
|
||||
setIsEditing(true);
|
||||
setCurrentVariant(variant);
|
||||
setSelectedSizeId(variant.size_id);
|
||||
setSku(variant.sku);
|
||||
setStock(variant.stock);
|
||||
setPrice(variant.price !== undefined ? variant.price : defaultPrice);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
// Открытие диалога для удаления варианта
|
||||
const openDeleteDialog = (variant: ProductVariant) => {
|
||||
setCurrentVariant(variant);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// Создание нового варианта
|
||||
const createVariant = async () => {
|
||||
if (!selectedSizeId) {
|
||||
toast("Выберите размер", {
|
||||
description: "Необходимо выбрать размер для варианта продукта",
|
||||
|
||||
fetchSizes();
|
||||
}, []);
|
||||
|
||||
const handleAddVariant = () => {
|
||||
// Берем первый доступный размер из списка
|
||||
const firstSize = sizes[0];
|
||||
if (!firstSize) {
|
||||
toast("Ошибка", {
|
||||
description: "Нет доступных размеров для создания варианта"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Временно создаем вариант для отображения в интерфейсе
|
||||
const tempVariant: ProductVariant = {
|
||||
id: Date.now(), // Временный ID
|
||||
product_id: product.id,
|
||||
size_id: firstSize.id,
|
||||
sku: "",
|
||||
stock: 0,
|
||||
is_active: true,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
setVariants([...variants, tempVariant]);
|
||||
};
|
||||
|
||||
const handleRemoveVariant = (index: number) => {
|
||||
setVariants(variants.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleUpdateVariant = (index: number, field: keyof ProductVariantUpdate, value: any) => {
|
||||
const newVariants = [...variants];
|
||||
newVariants[index] = {
|
||||
...newVariants[index],
|
||||
[field]: value
|
||||
};
|
||||
setVariants(newVariants);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const variantData = {
|
||||
product_id: productId,
|
||||
size_id: selectedSizeId,
|
||||
sku: sku,
|
||||
stock: stock,
|
||||
price: price !== defaultPrice ? price : undefined,
|
||||
};
|
||||
|
||||
const response = await productsApi.addVariant(productId, variantData);
|
||||
|
||||
if (response.status === 201 && response.data) {
|
||||
// Добавляем новый вариант в список
|
||||
const newVariant = response.data as ProductVariant;
|
||||
setVariants(prev => [...prev, newVariant]);
|
||||
|
||||
toast("Вариант создан", {
|
||||
description: "Вариант продукта успешно создан",
|
||||
setSaving(true);
|
||||
const response = await api.put<ProductDetails>(`/catalog/products/${product.id}/variants`, {
|
||||
variants: variants.map(variant => ({
|
||||
size_id: variant.size_id,
|
||||
sku: variant.sku,
|
||||
stock: variant.stock,
|
||||
is_active: variant.is_active
|
||||
}))
|
||||
});
|
||||
|
||||
if (response) {
|
||||
setVariants(response.variants);
|
||||
onUpdate(response.variants);
|
||||
toast("Успешно", {
|
||||
description: "Варианты товара обновлены"
|
||||
});
|
||||
|
||||
setIsDialogOpen(false);
|
||||
} else {
|
||||
throw new Error(response.error || "Не удалось создать вариант");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка при создании варианта:", error);
|
||||
console.error("Ошибка при сохранении вариантов:", error);
|
||||
toast("Ошибка", {
|
||||
description: "Не удалось создать вариант продукта",
|
||||
description: "Не удалось сохранить варианты товара"
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Обновление варианта
|
||||
const updateVariant = async () => {
|
||||
if (!currentVariant) return;
|
||||
|
||||
try {
|
||||
const variantData = {
|
||||
product_id: productId,
|
||||
size_id: selectedSizeId,
|
||||
sku: sku,
|
||||
stock: stock,
|
||||
price: price !== defaultPrice ? price : undefined,
|
||||
};
|
||||
|
||||
const response = await productsApi.updateVariant(currentVariant.id, variantData);
|
||||
|
||||
if (response.status === 200 && response.data) {
|
||||
// Обновляем вариант в списке
|
||||
const updatedVariant = response.data as ProductVariant;
|
||||
setVariants(prev =>
|
||||
prev.map(v => v.id === updatedVariant.id ? updatedVariant : v)
|
||||
);
|
||||
|
||||
toast("Вариант обновлен", {
|
||||
description: "Вариант продукта успешно обновлен",
|
||||
});
|
||||
|
||||
setIsDialogOpen(false);
|
||||
} else {
|
||||
throw new Error(response.error || "Не удалось обновить вариант");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка при обновлении варианта:", error);
|
||||
toast("Ошибка", {
|
||||
description: "Не удалось обновить вариант продукта",
|
||||
});
|
||||
}
|
||||
|
||||
// Проверяем, используется ли размер в других вариантах
|
||||
const isSizeUsed = (sizeId: number, currentVariantId: number) => {
|
||||
return variants.some(v => v.size_id === sizeId && v.id !== currentVariantId);
|
||||
};
|
||||
|
||||
// Удаление варианта
|
||||
const deleteVariant = async () => {
|
||||
if (!currentVariant) return;
|
||||
|
||||
try {
|
||||
const response = await productsApi.deleteVariant(currentVariant.id);
|
||||
|
||||
if (response.status === 204 || response.status === 200) {
|
||||
// Удаляем вариант из списка
|
||||
setVariants(prev => prev.filter(v => v.id !== currentVariant.id));
|
||||
|
||||
toast("Вариант удален", {
|
||||
description: "Вариант продукта успешно удален",
|
||||
});
|
||||
|
||||
setIsDeleteDialogOpen(false);
|
||||
} else {
|
||||
throw new Error(response.error || "Не удалось удалить вариант");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка при удалении варианта:", error);
|
||||
toast("Ошибка", {
|
||||
description: "Не удалось удалить вариант продукта",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Проверка, доступен ли размер для выбора (не используется в существующих вариантах)
|
||||
const isSizeAvailable = (sizeId: string) => {
|
||||
return !variants.some(v => v.size_id === sizeId && (!currentVariant || v.id !== currentVariant.id));
|
||||
};
|
||||
|
||||
// Форматирование цены
|
||||
const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Варианты продукта</CardTitle>
|
||||
<CardDescription>
|
||||
Управление размерами и наличием товара "{productName}"
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4">
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Добавить вариант
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Варианты товара</CardTitle>
|
||||
<CardDescription>
|
||||
Добавьте варианты товара с разными размерами и характеристиками
|
||||
</CardDescription>
|
||||
</div>
|
||||
) : variants.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-lg font-medium">Нет вариантов</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
У этого продукта еще нет вариантов. Добавьте варианты, чтобы указать доступные размеры и количество.
|
||||
</p>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Добавить первый вариант
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddVariant}
|
||||
disabled={loading || sizes.length === 0}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Добавить вариант
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Загрузка...
|
||||
</div>
|
||||
) : sizes.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Нет доступных размеров. Сначала добавьте размеры в справочник.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Размер</TableHead>
|
||||
<TableHead>Артикул (SKU)</TableHead>
|
||||
<TableHead>В наличии</TableHead>
|
||||
<TableHead>Цена</TableHead>
|
||||
<TableHead className="text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{variants.map((variant) => (
|
||||
<TableRow key={variant.id}>
|
||||
<TableCell className="font-medium">{variant.size?.name || "—"}</TableCell>
|
||||
<TableCell>{variant.sku || "—"}</TableCell>
|
||||
<TableCell>{variant.stock}</TableCell>
|
||||
<TableCell>
|
||||
{variant.price !== undefined ? formatPrice(variant.price) : formatPrice(defaultPrice)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => openEditDialog(variant)}
|
||||
<div className="space-y-4">
|
||||
{variants.map((variant, index) => (
|
||||
<div key={variant.id} className="grid grid-cols-1 md:grid-cols-6 gap-4 p-4 border rounded-lg">
|
||||
<div className="md:col-span-2">
|
||||
<Label>Размер</Label>
|
||||
<Select
|
||||
value={variant.size_id.toString()}
|
||||
onValueChange={(value) => handleUpdateVariant(index, "size_id", parseInt(value))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите размер" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sizes.map(size => (
|
||||
<SelectItem
|
||||
key={size.id}
|
||||
value={size.id.toString()}
|
||||
disabled={isSizeUsed(size.id, variant.id)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => openDeleteDialog(variant)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{size.name} ({size.value})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label>Артикул (SKU)</Label>
|
||||
<Input
|
||||
value={variant.sku}
|
||||
onChange={(e) => handleUpdateVariant(index, "sku", e.target.value)}
|
||||
placeholder="Уникальный артикул"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-1">
|
||||
<Label>Наличие</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={variant.stock}
|
||||
onChange={(e) => handleUpdateVariant(index, "stock", parseInt(e.target.value))}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-1">
|
||||
<Label>Статус</Label>
|
||||
<Select
|
||||
value={variant.is_active ? "active" : "inactive"}
|
||||
onValueChange={(value) => handleUpdateVariant(index, "is_active", value === "active")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите статус" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Активный</SelectItem>
|
||||
<SelectItem value="inactive">Неактивный</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="md:col-span-1 flex items-end">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveVariant(index)}
|
||||
>
|
||||
<Trash className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Диалог создания/редактирования варианта */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditing ? "Редактировать вариант" : "Добавить вариант"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing
|
||||
? "Измените параметры варианта продукта"
|
||||
: "Заполните информацию о новом варианте продукта"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="size">Размер</Label>
|
||||
<Select
|
||||
value={selectedSizeId}
|
||||
onValueChange={setSelectedSizeId}
|
||||
disabled={isEditing}
|
||||
>
|
||||
<SelectTrigger id="size">
|
||||
<SelectValue placeholder="Выберите размер" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sizes.map((size) => (
|
||||
<SelectItem
|
||||
key={size.id}
|
||||
value={size.id}
|
||||
disabled={!isSizeAvailable(size.id)}
|
||||
>
|
||||
{size.name} ({size.value})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sku">Артикул (SKU)</Label>
|
||||
<Input
|
||||
id="sku"
|
||||
value={sku}
|
||||
onChange={(e) => setSku(e.target.value)}
|
||||
placeholder="Например: BLK-T-M"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="stock">Количество в наличии</Label>
|
||||
<Input
|
||||
id="stock"
|
||||
type="number"
|
||||
min="0"
|
||||
value={stock}
|
||||
onChange={(e) => setStock(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="price">
|
||||
Цена (опционально, если отличается от основной)
|
||||
</Label>
|
||||
<Input
|
||||
id="price"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(parseFloat(e.target.value) || 0)}
|
||||
placeholder={`По умолчанию: ${formatPrice(defaultPrice)}`}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Оставьте значение по умолчанию, если цена не отличается от основной
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={isEditing ? updateVariant : createVariant}>
|
||||
{isEditing ? "Сохранить" : "Добавить"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Диалог подтверждения удаления */}
|
||||
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Удалить вариант</DialogTitle>
|
||||
<DialogDescription>
|
||||
Вы уверены, что хотите удалить этот вариант продукта?
|
||||
Это действие нельзя отменить.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={deleteVariant}>
|
||||
Удалить
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@ -10,7 +10,8 @@ import {
|
||||
Users,
|
||||
Settings,
|
||||
Menu,
|
||||
X
|
||||
X,
|
||||
Ruler
|
||||
} from 'lucide-react';
|
||||
|
||||
// Массив навигации
|
||||
@ -20,11 +21,17 @@ const navigation = [
|
||||
{ name: 'Товары', href: '/admin/products', icon: Package },
|
||||
{ name: 'Категории', href: '/admin/categories', icon: Layers },
|
||||
{ name: 'Коллекции', href: '/admin/collections', icon: Grid },
|
||||
{ name: 'Размеры', href: '/admin/sizes', icon: Ruler },
|
||||
{ name: 'Пользователи', href: '/admin/users', icon: Users },
|
||||
{ name: 'Настройки', href: '/admin/settings', icon: Settings },
|
||||
];
|
||||
|
||||
export default function Sidebar({ isOpen, setIsOpen }) {
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
|
||||
383
frontend/components/admin/SizesManager.tsx
Normal file
@ -0,0 +1,383 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Pencil, Trash2, Loader2, Check, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
// UI компоненты
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
// API и типы
|
||||
import api, { ApiResponse } from '@/lib/api';
|
||||
import { Size, sizeService } from '@/lib/catalog';
|
||||
|
||||
// Схема валидации для формы размера
|
||||
const sizeFormSchema = z.object({
|
||||
name: z.string().min(1, 'Название размера обязательно'),
|
||||
value: z.string().min(1, 'Значение размера обязательно'),
|
||||
category_id: z.number().optional(),
|
||||
is_active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
type SizeFormValues = z.infer<typeof sizeFormSchema>;
|
||||
|
||||
// Компонент для управления размерами
|
||||
export function SizesManager() {
|
||||
const [sizes, setSizes] = useState<Size[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [editingSize, setEditingSize] = useState<Size | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Инициализация формы
|
||||
const form = useForm<SizeFormValues>({
|
||||
resolver: zodResolver(sizeFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
value: '',
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Загрузка списка размеров
|
||||
const loadSizes = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await sizeService.getSizes();
|
||||
if (response) {
|
||||
setSizes(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке размеров:', error);
|
||||
toast.error('Не удалось загрузить размеры');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Загрузка при монтировании компонента
|
||||
useEffect(() => {
|
||||
loadSizes();
|
||||
}, []);
|
||||
|
||||
// Открытие диалога для создания нового размера
|
||||
const handleAddSize = () => {
|
||||
form.reset({
|
||||
name: '',
|
||||
value: '',
|
||||
is_active: true,
|
||||
});
|
||||
setEditingSize(null);
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
// Открытие диалога для редактирования размера
|
||||
const handleEditSize = (size: Size) => {
|
||||
form.reset({
|
||||
name: size.name,
|
||||
value: size.value,
|
||||
is_active: size.is_active,
|
||||
});
|
||||
setEditingSize(size);
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
// Обработчик удаления размера
|
||||
const handleDeleteSize = async (id: number) => {
|
||||
if (!confirm('Вы уверены, что хотите удалить этот размер?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await sizeService.deleteSize(id);
|
||||
if (success) {
|
||||
setSizes(sizes.filter(size => size.id !== id));
|
||||
toast.success('Размер успешно удален');
|
||||
} else {
|
||||
throw new Error('Не удалось удалить размер');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при удалении размера:', error);
|
||||
toast.error('Ошибка при удалении размера');
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик переключения активности размера
|
||||
const handleToggleActive = async (size: Size) => {
|
||||
try {
|
||||
const updatedSize = {
|
||||
...size,
|
||||
is_active: !size.is_active,
|
||||
};
|
||||
|
||||
const result = await sizeService.updateSize(updatedSize);
|
||||
if (result) {
|
||||
setSizes(sizes.map(s => s.id === size.id ? result : s));
|
||||
toast.success(`Размер ${result.is_active ? 'активирован' : 'деактивирован'}`);
|
||||
} else {
|
||||
throw new Error('Не удалось обновить размер');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при обновлении статуса размера:', error);
|
||||
toast.error('Ошибка при изменении статуса размера');
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик сохранения размера
|
||||
const onSubmit = async (values: SizeFormValues) => {
|
||||
try {
|
||||
let result: Size | null;
|
||||
|
||||
if (editingSize) {
|
||||
// Обновление существующего размера
|
||||
result = await sizeService.updateSize({
|
||||
...values,
|
||||
id: editingSize.id
|
||||
});
|
||||
|
||||
if (result) {
|
||||
setSizes(sizes.map(size => size.id === editingSize.id ? result! : size));
|
||||
toast.success('Размер успешно обновлен');
|
||||
} else {
|
||||
throw new Error('Не удалось обновить размер');
|
||||
}
|
||||
} else {
|
||||
// Создание нового размера
|
||||
result = await sizeService.createSize(values);
|
||||
|
||||
if (result) {
|
||||
setSizes([...sizes, result]);
|
||||
toast.success('Размер успешно создан');
|
||||
} else {
|
||||
throw new Error('Не удалось создать размер');
|
||||
}
|
||||
}
|
||||
|
||||
setShowDialog(false);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при сохранении размера:', error);
|
||||
toast.error('Ошибка при сохранении размера');
|
||||
}
|
||||
};
|
||||
|
||||
// Фильтрация размеров по поисковому запросу
|
||||
const filteredSizes = sizes.filter(size =>
|
||||
size.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
size.value.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<CardTitle>Управление размерами</CardTitle>
|
||||
<CardDescription>
|
||||
Создавайте и редактируйте размеры для товаров
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={handleAddSize} className="flex items-center gap-1">
|
||||
<Plus className="h-4 w-4" />
|
||||
Добавить размер
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Поиск */}
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
placeholder="Поиск размеров..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : filteredSizes.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{searchTerm ? 'Размеры не найдены' : 'Нет доступных размеров'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Название</TableHead>
|
||||
<TableHead>Значение</TableHead>
|
||||
<TableHead className="w-[100px] text-center">Статус</TableHead>
|
||||
<TableHead className="w-[100px] text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredSizes.map((size) => (
|
||||
<TableRow key={size.id}>
|
||||
<TableCell className="font-medium">{size.name}</TableCell>
|
||||
<TableCell>{size.value}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={size.is_active ? 'text-green-500 hover:text-green-600' : 'text-gray-400 hover:text-gray-500'}
|
||||
onClick={() => handleToggleActive(size)}
|
||||
>
|
||||
{size.is_active ? <Check className="h-4 w-4" /> : <X className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEditSize(size)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive/90"
|
||||
onClick={() => handleDeleteSize(size.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Диалог для создания/редактирования размера */}
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingSize ? 'Редактировать размер' : 'Добавить новый размер'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingSize
|
||||
? 'Измените информацию о размере и нажмите "Сохранить"'
|
||||
: 'Заполните форму для создания нового размера'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Название</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Введите название размера" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Название размера для отображения в админ-панели
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Значение</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Введите значение размера" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Значение размера, которое будет отображаться покупателю
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_active"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Активен</FormLabel>
|
||||
<FormDescription>
|
||||
Размер будет доступен на сайте
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" type="button" onClick={() => setShowDialog(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="submit">Сохранить</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
frontend/components/cart/CartItem.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Minus, Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CartItem as CartItemType } from '@/types/cart';
|
||||
import { formatPrice, getProperImageUrl } from '@/lib/utils';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface CartItemProps {
|
||||
item: CartItemType;
|
||||
onUpdateQuantity: (id: number, quantity: number) => Promise<void>;
|
||||
onRemove: (id: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function CartItem({ item, onUpdateQuantity, onRemove }: CartItemProps) {
|
||||
const handleIncreaseQuantity = () => {
|
||||
onUpdateQuantity(item.id, item.quantity + 1);
|
||||
};
|
||||
|
||||
const handleDecreaseQuantity = () => {
|
||||
if (item.quantity > 1) {
|
||||
onUpdateQuantity(item.id, item.quantity - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onRemove(item.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-4 py-4 border-b border-gray-200">
|
||||
<div className="flex-shrink-0 relative w-24 h-24 sm:w-32 sm:h-32 bg-gray-100 rounded-md overflow-hidden">
|
||||
{item.product_image ? (
|
||||
<Image
|
||||
src={getProperImageUrl(item.product_image)}
|
||||
alt={item.product_name}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100px, 128px"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||
Нет фото
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<Link href={`/catalog/${item.slug}`}>
|
||||
<h3 className="text-base font-medium text-gray-900 hover:text-primary">
|
||||
{item.product_name}
|
||||
</h3>
|
||||
</Link>
|
||||
<p className="text-sm text-gray-500">
|
||||
{item.variant_name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-base font-medium text-gray-900">
|
||||
{formatPrice(item.total_price)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between pt-4">
|
||||
<div className="flex items-center border rounded-md">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-none"
|
||||
onClick={handleDecreaseQuantity}
|
||||
disabled={item.quantity <= 1}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="w-10 text-center">{item.quantity}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-none"
|
||||
onClick={handleIncreaseQuantity}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-500 hover:text-red-500"
|
||||
onClick={handleRemove}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
<span>Удалить</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
frontend/components/cart/CartSummary.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ShoppingBag } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface CartSummaryProps {
|
||||
itemsCount: number;
|
||||
totalAmount: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function CartSummary({ itemsCount, totalAmount, disabled = false }: CartSummaryProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleCheckout = () => {
|
||||
router.push('/checkout');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Сводка заказа</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between">
|
||||
<span>Товары ({itemsCount})</span>
|
||||
<span>{totalAmount.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Доставка</span>
|
||||
<span>Рассчитывается при оформлении</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-4 mt-4">
|
||||
<div className="flex justify-between font-medium text-lg">
|
||||
<span>Итого</span>
|
||||
<span>{totalAmount.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handleCheckout}
|
||||
disabled={disabled || itemsCount === 0}
|
||||
>
|
||||
<ShoppingBag className="mr-2 h-5 w-5" />
|
||||
{itemsCount === 0 ? 'Корзина пуста' : 'Оформить заказ'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
24
frontend/components/cart/EmptyCart.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ShoppingBag } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function EmptyCart() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="rounded-full bg-gray-100 p-6 mb-6">
|
||||
<ShoppingBag className="h-12 w-12 text-gray-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Ваша корзина пуста</h2>
|
||||
<p className="text-gray-500 mb-8 text-center max-w-md">
|
||||
Похоже, вы еще не добавили товары в корзину.
|
||||
Предлагаем вам ознакомиться с нашим каталогом и выбрать что-то для себя.
|
||||
</p>
|
||||
<Link href="/catalog">
|
||||
<Button size="lg">
|
||||
Перейти в каталог
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
frontend/components/cart/MiniCart.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { ShoppingBag, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatPrice, getProperImageUrl } from '@/lib/utils';
|
||||
import { useCart } from '@/hooks/useCart';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
interface MiniCartProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MiniCart({ isOpen, onClose }: MiniCartProps) {
|
||||
const { cart, removeFromCart } = useCart();
|
||||
const isEmpty = cart.items.length === 0;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Ограничиваем количество отображаемых товаров
|
||||
const displayedItems = cart.items.slice(0, 3);
|
||||
const hasMoreItems = cart.items.length > 3;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute top-full right-0 mt-2 w-80 bg-white rounded-md shadow-lg z-50 overflow-hidden transition-all duration-300 ${
|
||||
isOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-[-20px] pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium">Корзина ({cart.total_items})</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="py-8 text-center">
|
||||
<ShoppingBag className="h-10 w-10 text-neutral-300 mx-auto mb-3" />
|
||||
<p className="text-neutral-500">Ваша корзина пуста</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3 max-h-60 overflow-auto">
|
||||
{displayedItems.map((item) => (
|
||||
<div key={item.id} className="flex gap-3 hover:bg-gray-50 p-2 rounded-md transition-colors">
|
||||
<div className="flex-shrink-0 relative w-16 h-16 bg-neutral-100 rounded-md overflow-hidden">
|
||||
{item.product_image ? (
|
||||
<Image
|
||||
src={getProperImageUrl(item.product_image)}
|
||||
alt={item.product_name}
|
||||
fill
|
||||
sizes="64px"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-neutral-400">
|
||||
<ShoppingBag className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium text-neutral-900 truncate">
|
||||
{item.product_name}
|
||||
</h4>
|
||||
<p className="text-xs text-neutral-500 mb-1">
|
||||
{item.variant_name} x {item.quantity}
|
||||
</p>
|
||||
<p className="text-sm font-medium">
|
||||
{formatPrice(item.price)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => removeFromCart(item.id)}
|
||||
className="text-neutral-400 hover:text-red-500 transition-colors self-start mt-1"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasMoreItems && (
|
||||
<p className="text-xs text-center text-neutral-500 italic mt-2">
|
||||
И еще {cart.items.length - 3} {cart.items.length - 3 === 1 ? 'товар' : 'товаров'}...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
<div className="flex justify-between text-sm font-medium mb-3">
|
||||
<span>Итого:</span>
|
||||
<span>{formatPrice(cart.total_price)}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Link href="/cart" onClick={onClose} className="col-span-2">
|
||||
<Button variant="default" className="w-full bg-primary hover:bg-secondary">
|
||||
Просмотр корзины
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
frontend/components/checkout/AddressForm.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ShippingAddress } from '@/types/order';
|
||||
|
||||
const addressSchema = z.object({
|
||||
address_line1: z.string().min(5, 'Адрес должен быть не менее 5 символов'),
|
||||
address_line2: z.string().optional(),
|
||||
city: z.string().min(2, 'Укажите город'),
|
||||
state: z.string().min(2, 'Укажите область/регион'),
|
||||
postal_code: z.string().min(5, 'Укажите почтовый индекс'),
|
||||
country: z.string().min(2, 'Укажите страну'),
|
||||
is_default: z.boolean().default(false)
|
||||
});
|
||||
|
||||
type AddressFormData = z.infer<typeof addressSchema>;
|
||||
|
||||
interface AddressFormProps {
|
||||
onSubmit: (data: AddressFormData) => void;
|
||||
initialData?: ShippingAddress;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
export function AddressForm({ onSubmit, initialData, isSubmitting = false }: AddressFormProps) {
|
||||
const form = useForm<AddressFormData>({
|
||||
resolver: zodResolver(addressSchema),
|
||||
defaultValues: initialData || {
|
||||
address_line1: '',
|
||||
address_line2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postal_code: '',
|
||||
country: 'Россия',
|
||||
is_default: false
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="address_line1"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Адрес</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="ул. Пушкина, д. 10" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="address_line2"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Дополнительная информация</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Квартира, подъезд, этаж и т.д. (необязательно)" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="city"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Город</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Москва" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="state"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Область/Регион</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Московская область" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="postal_code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Индекс</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="123456" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="country"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Страна</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Россия" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Сохранение...' : 'Сохранить адрес'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
88
frontend/components/checkout/CheckoutSummary.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CartItem as CartItemType } from "@/types/cart";
|
||||
import { formatPrice } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface CheckoutSummaryProps {
|
||||
cartItems: CartItemType[];
|
||||
totalAmount: number;
|
||||
onPlaceOrder: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function CheckoutSummary({ cartItems, totalAmount, onPlaceOrder }: CheckoutSummaryProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handlePlaceOrder = async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await onPlaceOrder();
|
||||
} catch (error) {
|
||||
console.error('Ошибка при оформлении заказа:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Ваш заказ</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{cartItems.map((item) => (
|
||||
<div key={item.id} className="flex justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{item.product_name} {item.variant_name && `(${item.variant_name})`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.quantity} шт. x {formatPrice(item.product_price)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-medium">{formatPrice(item.total_price)}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-between">
|
||||
<p className="text-sm font-medium">Подытог</p>
|
||||
<p className="text-sm font-medium">{formatPrice(totalAmount)}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<p className="text-sm font-medium">Доставка</p>
|
||||
<p className="text-sm font-medium">Бесплатно</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-between">
|
||||
<p className="text-base font-bold">Итого</p>
|
||||
<p className="text-base font-bold">{formatPrice(totalAmount)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handlePlaceOrder}
|
||||
disabled={isSubmitting || cartItems.length === 0}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Оформление...
|
||||
</>
|
||||
) : (
|
||||
"Оформить заказ"
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||