Обновлены зависимости в файле requirements.txt, включая FastAPI и Alembic. Добавлены новые настройки для CDEK API в конфигурацию приложения. Обновлены компоненты фронтенда, включая стили и структуру, для улучшения пользовательского интерфейса. Удалены устаревшие файлы и исправлены ошибки в обработке изображений.

This commit is contained in:
Zikil 2025-04-01 17:47:23 +07:00
parent 05f63d5713
commit 260636af5e
192 changed files with 16176 additions and 9475 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -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** для оркестрации в средах разработки и производства.
- Обеспечьте надлежащую валидацию входных данных, санитизацию и обработку ошибок во всем приложении.

Binary file not shown.

View 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

Binary file not shown.

BIN
backend/.DS_Store vendored

Binary file not shown.

Binary file not shown.

BIN
backend/app/.DS_Store vendored

Binary file not shown.

4
backend/app/.env Normal file
View 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

View File

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

View File

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

View File

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

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

View 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

View File

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

View File

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

View 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()

View File

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

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

BIN
frontend/.DS_Store vendored

Binary file not shown.

BIN
frontend/app/(main)/.DS_Store vendored Normal file

Binary file not shown.

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

View File

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

File diff suppressed because it is too large Load Diff

View 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>
)
}

View File

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

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

File diff suppressed because it is too large Load Diff

BIN
frontend/app/.DS_Store vendored

Binary file not shown.

View File

@ -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('Ошибка при сохранении категории');

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

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

View File

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

View File

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

View File

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

Binary file not shown.

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

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

View File

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

View File

@ -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' : ''}`}
/>

View File

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

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

View File

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

View File

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

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

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

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

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

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

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

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

Some files were not shown because too many files have changed in this diff Show More