Обновлены настройки индексов Meilisearch, добавлены новые атрибуты для фильтрации и сортировки. Внесены изменения в компоненты фронтенда для улучшения адаптивности и пользовательского интерфейса. Удалены устаревшие файлы и оптимизирован код.
@ -443,22 +443,50 @@ async def get_products_endpoint(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
category_id: Optional[int] = None,
|
||||
category_ids: Optional[str] = None, # Добавляем параметр category_ids в виде строки с разделителями-запятыми
|
||||
collection_id: Optional[int] = None,
|
||||
collection_ids: Optional[str] = None, # Добавляем параметр collection_ids в виде строки с разделителями-запятыми
|
||||
search: Optional[str] = None,
|
||||
min_price: Optional[float] = None,
|
||||
max_price: Optional[float] = None,
|
||||
is_active: Optional[bool] = True,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = "asc",
|
||||
size_ids: Optional[str] = None, # Параметр size_ids в виде строки с разделителями-запятыми
|
||||
sort: Optional[str] = None, # Параметр sort для прямой передачи опции сортировки
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Формируем фильтры для Meilisearch
|
||||
filters = []
|
||||
|
||||
if category_id is not None:
|
||||
# Обработка фильтрации по категориям
|
||||
if category_ids:
|
||||
try:
|
||||
# Преобразуем строку с ID категорий в список
|
||||
category_ids_list = [int(cat_id.strip()) for cat_id in category_ids.split(',') if cat_id.strip()]
|
||||
if category_ids_list:
|
||||
# Формируем условие для фильтрации по категориям
|
||||
category_filter = " OR ".join([f"category_id = {cat_id}" for cat_id in category_ids_list])
|
||||
filters.append(f"({category_filter})")
|
||||
except ValueError as e:
|
||||
logging.warning(f"Некорректный формат category_ids: {category_ids}. Ошибка: {str(e)}")
|
||||
# Для обратной совместимости
|
||||
elif category_id is not None:
|
||||
filters.append(f"category_id = {category_id}")
|
||||
|
||||
if collection_id is not None:
|
||||
# Обработка фильтрации по коллекциям
|
||||
if collection_ids:
|
||||
try:
|
||||
# Преобразуем строку с ID коллекций в список
|
||||
collection_ids_list = [int(coll_id.strip()) for coll_id in collection_ids.split(',') if coll_id.strip()]
|
||||
if collection_ids_list:
|
||||
# Формируем условие для фильтрации по коллекциям
|
||||
collection_filter = " OR ".join([f"collection_id = {coll_id}" for coll_id in collection_ids_list])
|
||||
filters.append(f"({collection_filter})")
|
||||
except ValueError as e:
|
||||
logging.warning(f"Некорректный формат collection_ids: {collection_ids}. Ошибка: {str(e)}")
|
||||
# Для обратной совместимости
|
||||
elif collection_id is not None:
|
||||
filters.append(f"collection_id = {collection_id}")
|
||||
|
||||
if is_active is not None:
|
||||
@ -470,10 +498,39 @@ async def get_products_endpoint(
|
||||
if max_price is not None:
|
||||
filters.append(f"price <= {max_price}")
|
||||
|
||||
# Обработка фильтрации по размерам
|
||||
if size_ids:
|
||||
try:
|
||||
# Преобразуем строку с ID размеров в список
|
||||
size_ids_list = [int(size_id.strip()) for size_id in size_ids.split(',') if size_id.strip()]
|
||||
if size_ids_list:
|
||||
# Формируем условие для фильтрации по размерам
|
||||
size_filter = " OR ".join([f"size_ids = {size_id}" for size_id in size_ids_list])
|
||||
filters.append(f"({size_filter})")
|
||||
except ValueError as e:
|
||||
logging.warning(f"Некорректный формат size_ids: {size_ids}. Ошибка: {str(e)}")
|
||||
# Если формат некорректный, игнорируем этот фильтр
|
||||
|
||||
# Формируем параметры сортировки
|
||||
sort = None
|
||||
if sort_by:
|
||||
sort = [f"{sort_by}:{sort_order}"]
|
||||
sort_param = None
|
||||
|
||||
# Если передан прямой параметр sort, используем его
|
||||
if sort:
|
||||
# Обрабатываем популярные варианты сортировки
|
||||
if sort == 'popular':
|
||||
sort_param = None # Используем сортировку по умолчанию
|
||||
elif sort == 'price_asc':
|
||||
sort_param = ['price:asc']
|
||||
elif sort == 'price_desc':
|
||||
sort_param = ['price:desc']
|
||||
elif sort == 'newest':
|
||||
sort_param = ['created_at:desc']
|
||||
else:
|
||||
# Если передан другой вариант, используем его напрямую
|
||||
sort_param = [sort]
|
||||
# Если передан sort_by, используем его
|
||||
elif sort_by:
|
||||
sort_param = [f"{sort_by}:{sort_order}"]
|
||||
|
||||
# Используем Meilisearch для поиска продуктов
|
||||
from app.services import meilisearch_service
|
||||
@ -485,7 +542,7 @@ async def get_products_endpoint(
|
||||
result = meilisearch_service.search_products(
|
||||
query=search or "",
|
||||
filters=filter_str,
|
||||
sort=sort,
|
||||
sort=sort_param,
|
||||
limit=limit,
|
||||
offset=skip
|
||||
)
|
||||
|
||||
@ -37,9 +37,10 @@ def format_product_for_meilisearch(product, variants, images):
|
||||
"created_at": product.created_at.isoformat() if product.created_at else None,
|
||||
"updated_at": product.updated_at.isoformat() if product.updated_at else None,
|
||||
"variants": [],
|
||||
"images": []
|
||||
"images": [],
|
||||
"size_ids": [] # Добавляем массив для хранения ID размеров
|
||||
}
|
||||
|
||||
|
||||
# Добавляем варианты продукта
|
||||
for variant in variants:
|
||||
variant_dict = {
|
||||
@ -49,7 +50,7 @@ def format_product_for_meilisearch(product, variants, images):
|
||||
"stock": variant.stock,
|
||||
"is_active": variant.is_active
|
||||
}
|
||||
|
||||
|
||||
# Добавляем информацию о размере, если она доступна
|
||||
if variant.size:
|
||||
variant_dict["size"] = {
|
||||
@ -57,9 +58,13 @@ def format_product_for_meilisearch(product, variants, images):
|
||||
"name": variant.size.name,
|
||||
"code": variant.size.code
|
||||
}
|
||||
|
||||
|
||||
# Добавляем ID размера в массив size_ids для фильтрации
|
||||
if variant.size_id not in product_dict["size_ids"]:
|
||||
product_dict["size_ids"].append(variant.size_id)
|
||||
|
||||
product_dict["variants"].append(variant_dict)
|
||||
|
||||
|
||||
# Добавляем изображения продукта
|
||||
for image in images:
|
||||
image_dict = {
|
||||
@ -69,11 +74,11 @@ def format_product_for_meilisearch(product, variants, images):
|
||||
"is_primary": image.is_primary
|
||||
}
|
||||
product_dict["images"].append(image_dict)
|
||||
|
||||
|
||||
# Добавляем основное изображение в корень продукта для удобства
|
||||
if image.is_primary:
|
||||
product_dict["primary_image"] = image.image_url
|
||||
|
||||
|
||||
return product_dict
|
||||
|
||||
|
||||
@ -127,10 +132,10 @@ def sync_products(db: Session):
|
||||
Синхронизирует все продукты с Meilisearch.
|
||||
"""
|
||||
logger.info("Syncing products...")
|
||||
|
||||
|
||||
# Получаем все продукты из базы данных
|
||||
products = db.query(Product).all()
|
||||
|
||||
|
||||
# Форматируем продукты для Meilisearch
|
||||
products_data = []
|
||||
for product in products:
|
||||
@ -138,15 +143,15 @@ def sync_products(db: Session):
|
||||
images = db.query(ProductImage).filter(ProductImage.product_id == product.id).all()
|
||||
product_data = format_product_for_meilisearch(product, variants, images)
|
||||
products_data.append(product_data)
|
||||
|
||||
|
||||
# Синхронизируем продукты с Meilisearch
|
||||
success = meilisearch_service.sync_all_products(products_data)
|
||||
|
||||
|
||||
if success:
|
||||
logger.info(f"Successfully synced {len(products_data)} products")
|
||||
else:
|
||||
logger.error("Failed to sync products")
|
||||
|
||||
|
||||
return success
|
||||
|
||||
|
||||
@ -155,21 +160,21 @@ def sync_categories(db: Session):
|
||||
Синхронизирует все категории с Meilisearch.
|
||||
"""
|
||||
logger.info("Syncing categories...")
|
||||
|
||||
|
||||
# Получаем все категории из базы данных
|
||||
categories = db.query(Category).all()
|
||||
|
||||
|
||||
# Форматируем категории для Meilisearch
|
||||
categories_data = [format_category_for_meilisearch(category) for category in categories]
|
||||
|
||||
|
||||
# Синхронизируем категории с Meilisearch
|
||||
success = meilisearch_service.sync_all_categories(categories_data)
|
||||
|
||||
|
||||
if success:
|
||||
logger.info(f"Successfully synced {len(categories_data)} categories")
|
||||
else:
|
||||
logger.error("Failed to sync categories")
|
||||
|
||||
|
||||
return success
|
||||
|
||||
|
||||
@ -178,21 +183,21 @@ def sync_collections(db: Session):
|
||||
Синхронизирует все коллекции с Meilisearch.
|
||||
"""
|
||||
logger.info("Syncing collections...")
|
||||
|
||||
|
||||
# Получаем все коллекции из базы данных
|
||||
collections = db.query(Collection).all()
|
||||
|
||||
|
||||
# Форматируем коллекции для Meilisearch
|
||||
collections_data = [format_collection_for_meilisearch(collection) for collection in collections]
|
||||
|
||||
|
||||
# Синхронизируем коллекции с Meilisearch
|
||||
success = meilisearch_service.sync_all_collections(collections_data)
|
||||
|
||||
|
||||
if success:
|
||||
logger.info(f"Successfully synced {len(collections_data)} collections")
|
||||
else:
|
||||
logger.error("Failed to sync collections")
|
||||
|
||||
|
||||
return success
|
||||
|
||||
|
||||
@ -201,21 +206,21 @@ def sync_sizes(db: Session):
|
||||
Синхронизирует все размеры с Meilisearch.
|
||||
"""
|
||||
logger.info("Syncing sizes...")
|
||||
|
||||
|
||||
# Получаем все размеры из базы данных
|
||||
sizes = db.query(Size).all()
|
||||
|
||||
|
||||
# Форматируем размеры для Meilisearch
|
||||
sizes_data = [format_size_for_meilisearch(size) for size in sizes]
|
||||
|
||||
|
||||
# Синхронизируем размеры с Meilisearch
|
||||
success = meilisearch_service.sync_all_sizes(sizes_data)
|
||||
|
||||
|
||||
if success:
|
||||
logger.info(f"Successfully synced {len(sizes_data)} sizes")
|
||||
else:
|
||||
logger.error("Failed to sync sizes")
|
||||
|
||||
|
||||
return success
|
||||
|
||||
|
||||
@ -224,20 +229,20 @@ def main():
|
||||
Основная функция для синхронизации всех данных с Meilisearch.
|
||||
"""
|
||||
logger.info("Starting Meilisearch synchronization...")
|
||||
|
||||
|
||||
# Инициализируем индексы в Meilisearch
|
||||
meilisearch_service.initialize_indexes()
|
||||
|
||||
|
||||
# Создаем сессию базы данных
|
||||
db = SessionLocal()
|
||||
|
||||
|
||||
try:
|
||||
# Синхронизируем все данные
|
||||
sync_categories(db)
|
||||
sync_collections(db)
|
||||
sync_sizes(db)
|
||||
sync_products(db)
|
||||
|
||||
|
||||
logger.info("Meilisearch synchronization completed successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during Meilisearch synchronization: {str(e)}")
|
||||
|
||||
@ -28,7 +28,7 @@ def initialize_indexes():
|
||||
|
||||
# Настраиваем фильтруемые атрибуты для продуктов
|
||||
client.index(PRODUCT_INDEX).update_filterable_attributes([
|
||||
"category_id", "collection_id", "is_active", "price", "discount_price", "slug", "id"
|
||||
"category_id", "collection_id", "is_active", "price", "discount_price", "slug", "id", "size_ids"
|
||||
])
|
||||
|
||||
# Настраиваем сортируемые атрибуты для продуктов
|
||||
|
||||
55
backend/update_and_sync_meilisearch.py
Normal file
@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
|
||||
from app.core import SessionLocal
|
||||
from app.scripts.sync_meilisearch import sync_products, sync_categories, sync_collections, sync_sizes
|
||||
from app.services.meilisearch_service import initialize_indexes
|
||||
|
||||
# Настройка логгера
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def main():
|
||||
"""
|
||||
Обновляет настройки Meilisearch и синхронизирует данные.
|
||||
"""
|
||||
logger.info("Обновление настроек Meilisearch и синхронизация данных...")
|
||||
|
||||
try:
|
||||
# Инициализируем индексы в Meilisearch с обновленными настройками
|
||||
logger.info("Инициализация индексов Meilisearch...")
|
||||
initialize_indexes()
|
||||
|
||||
# Создаем сессию базы данных
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Синхронизируем все данные
|
||||
logger.info("Синхронизация категорий...")
|
||||
sync_categories(db)
|
||||
|
||||
logger.info("Синхронизация коллекций...")
|
||||
sync_collections(db)
|
||||
|
||||
logger.info("Синхронизация размеров...")
|
||||
sync_sizes(db)
|
||||
|
||||
logger.info("Синхронизация продуктов...")
|
||||
sync_products(db)
|
||||
|
||||
logger.info("Синхронизация данных завершена успешно!")
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обновлении настроек и синхронизации данных: {str(e)}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -13,56 +13,56 @@ def main():
|
||||
Скрипт для обновления настроек индексов Meilisearch.
|
||||
"""
|
||||
print("Обновление настроек индексов Meilisearch...")
|
||||
|
||||
|
||||
try:
|
||||
# Обновляем фильтруемые атрибуты для продуктов
|
||||
print("Обновление фильтруемых атрибутов для продуктов...")
|
||||
client.index(PRODUCT_INDEX).update_filterable_attributes([
|
||||
"category_id", "collection_id", "is_active", "price", "discount_price", "slug", "id"
|
||||
"category_id", "collection_id", "is_active", "price", "discount_price", "slug", "id", "size_ids"
|
||||
])
|
||||
|
||||
|
||||
# Обновляем поисковые атрибуты для продуктов
|
||||
print("Обновление поисковых атрибутов для продуктов...")
|
||||
client.index(PRODUCT_INDEX).update_searchable_attributes([
|
||||
"name", "description", "slug"
|
||||
])
|
||||
|
||||
|
||||
# Обновляем сортируемые атрибуты для продуктов
|
||||
print("Обновление сортируемых атрибутов для продуктов...")
|
||||
client.index(PRODUCT_INDEX).update_sortable_attributes([
|
||||
"price", "discount_price", "created_at", "name"
|
||||
])
|
||||
|
||||
|
||||
# Обновляем фильтруемые атрибуты для категорий
|
||||
print("Обновление фильтруемых атрибутов для категорий...")
|
||||
client.index(CATEGORY_INDEX).update_filterable_attributes([
|
||||
"parent_id", "is_active", "id", "slug"
|
||||
])
|
||||
|
||||
|
||||
# Обновляем поисковые атрибуты для категорий
|
||||
print("Обновление поисковых атрибутов для категорий...")
|
||||
client.index(CATEGORY_INDEX).update_searchable_attributes([
|
||||
"name", "description", "slug"
|
||||
])
|
||||
|
||||
|
||||
# Обновляем фильтруемые атрибуты для коллекций
|
||||
print("Обновление фильтруемых атрибутов для коллекций...")
|
||||
client.index(COLLECTION_INDEX).update_filterable_attributes([
|
||||
"is_active", "id", "slug"
|
||||
])
|
||||
|
||||
|
||||
# Обновляем поисковые атрибуты для коллекций
|
||||
print("Обновление поисковых атрибутов для коллекций...")
|
||||
client.index(COLLECTION_INDEX).update_searchable_attributes([
|
||||
"name", "description", "slug"
|
||||
])
|
||||
|
||||
|
||||
# Обновляем поисковые атрибуты для размеров
|
||||
print("Обновление поисковых атрибутов для размеров...")
|
||||
client.index(SIZE_INDEX).update_searchable_attributes([
|
||||
"name", "code", "description"
|
||||
])
|
||||
|
||||
|
||||
print("Настройки индексов Meilisearch обновлены успешно!")
|
||||
except Exception as e:
|
||||
print(f"Ошибка при обновлении настроек индексов Meilisearch: {str(e)}")
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
40
frontend old/.gitignore
vendored
@ -1,40 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@ -1,66 +0,0 @@
|
||||
# syntax=docker.io/docker/dockerfile:1
|
||||
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
# ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn run build; \
|
||||
elif [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
# ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
|
||||
# server.js is created by next build from the standalone output
|
||||
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
CMD ["node", "server.js"]
|
||||
@ -1,70 +0,0 @@
|
||||
# With Docker
|
||||
|
||||
This examples shows how to use Docker with Next.js based on the [deployment documentation](https://nextjs.org/docs/deployment#docker-image). Additionally, it contains instructions for deploying to Google Cloud Run. However, you can use any container-based deployment host.
|
||||
|
||||
## How to use
|
||||
|
||||
Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), or [pnpm](https://pnpm.io) to bootstrap the example:
|
||||
|
||||
```bash
|
||||
npx create-next-app --example with-docker nextjs-docker
|
||||
```
|
||||
|
||||
```bash
|
||||
yarn create next-app --example with-docker nextjs-docker
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm create next-app --example with-docker nextjs-docker
|
||||
```
|
||||
|
||||
## Using Docker
|
||||
|
||||
1. [Install Docker](https://docs.docker.com/get-docker/) on your machine.
|
||||
1. Build your container: `docker build -t nextjs-docker .`.
|
||||
1. Run your container: `docker run -p 3000:3000 nextjs-docker`.
|
||||
|
||||
You can view your images created with `docker images`.
|
||||
|
||||
### In existing projects
|
||||
|
||||
To add support for Docker to an existing project, just copy the [`Dockerfile`](https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile) into the root of the project and add the following to the `next.config.js` file:
|
||||
|
||||
```js
|
||||
// next.config.js
|
||||
module.exports = {
|
||||
// ... rest of the configuration.
|
||||
output: "standalone",
|
||||
};
|
||||
```
|
||||
|
||||
This will build the project as a standalone app inside the Docker image.
|
||||
|
||||
## Deploying to Google Cloud Run
|
||||
|
||||
1. Install the [Google Cloud SDK](https://cloud.google.com/sdk/docs/install) so you can use `gcloud` on the command line.
|
||||
1. Run `gcloud auth login` to log in to your account.
|
||||
1. [Create a new project](https://cloud.google.com/run/docs/quickstarts/build-and-deploy) in Google Cloud Run (e.g. `nextjs-docker`). Ensure billing is turned on.
|
||||
1. Build your container image using Cloud Build: `gcloud builds submit --tag gcr.io/PROJECT-ID/helloworld --project PROJECT-ID`. This will also enable Cloud Build for your project.
|
||||
1. Deploy to Cloud Run: `gcloud run deploy --image gcr.io/PROJECT-ID/helloworld --project PROJECT-ID --platform managed --allow-unauthenticated`. Choose a region of your choice.
|
||||
|
||||
- You will be prompted for the service name: press Enter to accept the default name, `helloworld`.
|
||||
- You will be prompted for [region](https://cloud.google.com/run/docs/quickstarts/build-and-deploy#follow-cloud-run): select the region of your choice, for example `us-central1`.
|
||||
|
||||
## Running Locally
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
|
||||
|
||||
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`.
|
||||
|
||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
||||
@ -1,10 +0,0 @@
|
||||
{
|
||||
"name": "nextjs",
|
||||
"options": {
|
||||
"allow-unauthenticated": true,
|
||||
"memory": "256Mi",
|
||||
"cpu": "1",
|
||||
"port": 3000,
|
||||
"http2": false
|
||||
}
|
||||
}
|
||||
@ -1,92 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { ShoppingCart, Check, AlertCircle } from 'lucide-react';
|
||||
import cartService from '../services/cart';
|
||||
import authService from '../services/auth';
|
||||
|
||||
interface AddToCartButtonProps {
|
||||
variantId: number;
|
||||
quantity?: number;
|
||||
className?: string;
|
||||
onAddToCart?: () => void;
|
||||
}
|
||||
|
||||
export default function AddToCartButton({ variantId, quantity = 1, className = '', onAddToCart }: AddToCartButtonProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
// Сохраняем текущий URL для редиректа после авторизации
|
||||
const currentPath = router.asPath;
|
||||
router.push(`/login?redirect=${encodeURIComponent(currentPath)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await cartService.addToCart({
|
||||
variant_id: variantId,
|
||||
quantity: quantity
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
// Вызываем колбэк, если он передан
|
||||
if (onAddToCart) {
|
||||
onAddToCart();
|
||||
}
|
||||
|
||||
// Сбрасываем состояние успеха через 2 секунды
|
||||
setTimeout(() => {
|
||||
setSuccess(false);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при добавлении в корзину:', err);
|
||||
setError('Не удалось добавить товар в корзину');
|
||||
|
||||
// Сбрасываем состояние ошибки через 3 секунды
|
||||
setTimeout(() => {
|
||||
setError(null);
|
||||
}, 3000);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={loading || variantId === 0}
|
||||
className={`flex items-center justify-center px-6 py-3 bg-black text-white rounded-md hover:bg-gray-800 transition-colors ${loading ? 'opacity-70 cursor-not-allowed' : ''} ${variantId === 0 ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
|
||||
) : success ? (
|
||||
<>
|
||||
<Check className="w-5 h-5 mr-2" />
|
||||
Добавлено
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShoppingCart className="w-5 h-5 mr-2" />
|
||||
{variantId === 0 ? 'Нет в наличии' : 'В корзину'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-red-100 text-red-700 p-2 rounded-md text-sm flex items-center">
|
||||
<AlertCircle className="w-4 h-4 mr-1 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { Collection } from "../data/collections"
|
||||
|
||||
interface CollectionsProps {
|
||||
collections: Collection[]
|
||||
}
|
||||
|
||||
export default function Collections({ collections }: CollectionsProps) {
|
||||
return (
|
||||
<section className="py-12 px-4 md:px-8 max-w-7xl mx-auto">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-center mb-8 font-['Playfair_Display']">Коллекции</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{collections.map((collection, index) => (
|
||||
<Link href={collection.url} key={collection.id} className="group">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="overflow-hidden rounded-xl"
|
||||
>
|
||||
<div className="relative aspect-[4/5] overflow-hidden rounded-xl">
|
||||
<Image
|
||||
src={collection.image}
|
||||
alt={collection.name}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent"></div>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6 text-white">
|
||||
<h3 className="text-xl font-semibold mb-2">{collection.name}</h3>
|
||||
<p className="text-sm text-white/80 line-clamp-2">{collection.description}</p>
|
||||
<span className="inline-block mt-4 text-sm font-medium border-b border-white pb-1 transition-all group-hover:border-transparent">
|
||||
Смотреть коллекцию
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
export default function CookieNotification() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Проверяем, было ли уже показано уведомление
|
||||
useEffect(() => {
|
||||
const cookieAccepted = localStorage.getItem('cookieAccepted');
|
||||
if (!cookieAccepted) {
|
||||
// Показываем уведомление с небольшой задержкой
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const acceptCookies = () => {
|
||||
localStorage.setItem('cookieAccepted', 'true');
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 bg-white p-4 shadow-lg rounded-lg max-w-sm z-50 animate-fade-in">
|
||||
<h3 className="font-bold mb-2">Уведомление о Cookies</h3>
|
||||
<p className="text-sm mb-4">
|
||||
Наш сайт использует файлы cookie. Продолжая пользоваться сайтом, вы соглашаетесь на использование наших файлов
|
||||
cookie.
|
||||
</p>
|
||||
<button
|
||||
onClick={acceptCookies}
|
||||
className="bg-black text-white px-4 py-2 rounded hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
Хорошо, спасибо
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Facebook, Instagram, Twitter, Youtube, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function Footer() {
|
||||
// Состояния для отображения/скрытия разделов на мобильных устройствах
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [shopOpen, setShopOpen] = useState(false);
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<footer className="bg-[#2B5F47] text-white py-8 md:py-12 border-t border-[#63823B]">
|
||||
<div className="container mx-auto px-4 md:px-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-8">
|
||||
{/* Помощь - с аккордеоном на мобильных */}
|
||||
<div className="border-b border-[#63823B]/30 pb-4 md:border-b-0 md:pb-0">
|
||||
<div
|
||||
className="flex justify-between items-center mb-4 cursor-pointer md:cursor-default"
|
||||
onClick={() => setHelpOpen(!helpOpen)}
|
||||
>
|
||||
<h4 className="text-lg font-medium">Помощь</h4>
|
||||
<div className="md:hidden">
|
||||
{helpOpen ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
</div>
|
||||
</div>
|
||||
<ul className={`space-y-2 overflow-hidden transition-all duration-300 ${helpOpen ? 'max-h-60' : 'max-h-0 md:max-h-60'}`}>
|
||||
<li><Link href="/contact" className="text-sm hover:text-[#E2E2C1] transition-colors">Связаться с нами</Link></li>
|
||||
<li><Link href="/faq" className="text-sm hover:text-[#E2E2C1] transition-colors">Часто задаваемые вопросы</Link></li>
|
||||
<li><Link href="/shipping" className="text-sm hover:text-[#E2E2C1] transition-colors">Доставка и возврат</Link></li>
|
||||
<li><Link href="/track-order" className="text-sm hover:text-[#E2E2C1] transition-colors">Отследить заказ</Link></li>
|
||||
<li><Link href="/size-guide" className="text-sm hover:text-[#E2E2C1] transition-colors">Руководство по размерам</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Магазин - с аккордеоном на мобильных */}
|
||||
<div className="border-b border-[#63823B]/30 pb-4 md:border-b-0 md:pb-0">
|
||||
<div
|
||||
className="flex justify-between items-center mb-4 cursor-pointer md:cursor-default"
|
||||
onClick={() => setShopOpen(!shopOpen)}
|
||||
>
|
||||
<h4 className="text-lg font-medium">Магазин</h4>
|
||||
<div className="md:hidden">
|
||||
{shopOpen ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
</div>
|
||||
</div>
|
||||
<ul className={`space-y-2 overflow-hidden transition-all duration-300 ${shopOpen ? 'max-h-60' : 'max-h-0 md:max-h-60'}`}>
|
||||
<li><Link href="/women" className="text-sm hover:text-[#E2E2C1] transition-colors">Женщинам</Link></li>
|
||||
<li><Link href="/men" className="text-sm hover:text-[#E2E2C1] transition-colors">Мужчинам</Link></li>
|
||||
<li><Link href="/accessories" className="text-sm hover:text-[#E2E2C1] transition-colors">Аксессуары</Link></li>
|
||||
<li><Link href="/collections" className="text-sm hover:text-[#E2E2C1] transition-colors">Коллекции</Link></li>
|
||||
<li><Link href="/sale" className="text-sm hover:text-[#E2E2C1] transition-colors">Распродажа</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* О компании - с аккордеоном на мобильных */}
|
||||
<div className="border-b border-[#63823B]/30 pb-4 md:border-b-0 md:pb-0">
|
||||
<div
|
||||
className="flex justify-between items-center mb-4 cursor-pointer md:cursor-default"
|
||||
onClick={() => setAboutOpen(!aboutOpen)}
|
||||
>
|
||||
<h4 className="text-lg font-medium">О компании</h4>
|
||||
<div className="md:hidden">
|
||||
{aboutOpen ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
</div>
|
||||
</div>
|
||||
<ul className={`space-y-2 overflow-hidden transition-all duration-300 ${aboutOpen ? 'max-h-60' : 'max-h-0 md:max-h-60'}`}>
|
||||
<li><Link href="/about" className="text-sm hover:text-[#E2E2C1] transition-colors">О нас</Link></li>
|
||||
<li><Link href="/careers" className="text-sm hover:text-[#E2E2C1] transition-colors">Карьера</Link></li>
|
||||
<li><Link href="/sustainability" className="text-sm hover:text-[#E2E2C1] transition-colors">Устойчивое развитие</Link></li>
|
||||
<li><Link href="/press" className="text-sm hover:text-[#E2E2C1] transition-colors">Пресса</Link></li>
|
||||
<li><Link href="/affiliates" className="text-sm hover:text-[#E2E2C1] transition-colors">Партнерская программа</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Подписка - всегда видима */}
|
||||
<div className="mt-6 md:mt-0">
|
||||
<h4 className="text-lg font-medium mb-4">Подписаться</h4>
|
||||
<p className="text-sm mb-4">Подпишитесь на нашу рассылку, чтобы получать новости о новых коллекциях и эксклюзивных предложениях.</p>
|
||||
<div className="flex mb-6">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Ваш email"
|
||||
className="bg-white px-4 py-2 text-sm border border-[#63823B] rounded-l focus:outline-none focus:border-[#E2E2C1] text-[#2B5F47] flex-grow"
|
||||
/>
|
||||
<button className="bg-[#63823B] text-white px-4 py-2 text-sm rounded-r hover:bg-[#63823B]/80 transition-colors">
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex space-x-4">
|
||||
<a href="#" className="text-white hover:text-[#E2E2C1] transition-colors">
|
||||
<Facebook size={20} />
|
||||
</a>
|
||||
<a href="#" className="text-white hover:text-[#E2E2C1] transition-colors">
|
||||
<Instagram size={20} />
|
||||
</a>
|
||||
<a href="#" className="text-white hover:text-[#E2E2C1] transition-colors">
|
||||
<Twitter size={20} />
|
||||
</a>
|
||||
<a href="#" className="text-white hover:text-[#E2E2C1] transition-colors">
|
||||
<Youtube size={20} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#63823B] mt-8 pt-8 flex flex-col md:flex-row justify-between items-center">
|
||||
<p className="text-xs text-[#E2E2C1]/80 mb-4 md:mb-0 text-center md:text-left">© {new Date().getFullYear()} Brand Store. Все права защищены.</p>
|
||||
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-4 items-center">
|
||||
<Link href="/privacy" className="text-xs text-[#E2E2C1]/80 hover:text-[#E2E2C1] transition-colors">Политика конфиденциальности</Link>
|
||||
<Link href="/terms" className="text-xs text-[#E2E2C1]/80 hover:text-[#E2E2C1] transition-colors">Условия использования</Link>
|
||||
<Link href="/cookies" className="text-xs text-[#E2E2C1]/80 hover:text-[#E2E2C1] transition-colors">Политика использования файлов cookie</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@ -1,287 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Search, Heart, User, ShoppingCart, ChevronLeft, LogOut, Menu, X } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
import authService from "../services/auth";
|
||||
import cartService from "../services/cart";
|
||||
|
||||
export default function Header() {
|
||||
const router = useRouter();
|
||||
// Состояние для отслеживания прокрутки страницы
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
// Состояние для отслеживания аутентификации пользователя
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
// Состояние для отображения выпадающего меню пользователя
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
// Состояние для отображения мобильного меню
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
// Состояние для хранения количества товаров в корзине
|
||||
const [cartItemsCount, setCartItemsCount] = useState(0);
|
||||
|
||||
// Эффект для проверки аутентификации при загрузке компонента
|
||||
useEffect(() => {
|
||||
setIsAuthenticated(authService.isAuthenticated());
|
||||
}, []);
|
||||
|
||||
// Эффект для загрузки количества товаров в корзине
|
||||
useEffect(() => {
|
||||
const fetchCartItemsCount = async () => {
|
||||
if (authService.isAuthenticated()) {
|
||||
try {
|
||||
const cart = await cartService.getCart();
|
||||
setCartItemsCount(cart.items_count);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке корзины:', error);
|
||||
setCartItemsCount(0);
|
||||
}
|
||||
} else {
|
||||
setCartItemsCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCartItemsCount();
|
||||
|
||||
// Обновляем количество товаров в корзине при изменении маршрута
|
||||
const handleRouteChange = () => {
|
||||
fetchCartItemsCount();
|
||||
};
|
||||
|
||||
router.events.on('routeChangeComplete', handleRouteChange);
|
||||
return () => {
|
||||
router.events.off('routeChangeComplete', handleRouteChange);
|
||||
};
|
||||
}, [isAuthenticated, router.events]);
|
||||
|
||||
// Эффект для отслеживания прокрутки
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const isScrolled = window.scrollY > 50;
|
||||
if (isScrolled !== scrolled) {
|
||||
setScrolled(isScrolled);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [scrolled]);
|
||||
|
||||
// Функция для выхода из системы
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
setIsAuthenticated(false);
|
||||
setShowUserMenu(false);
|
||||
setCartItemsCount(0);
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
// Функция для возврата на предыдущую страницу
|
||||
const goBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
// Проверяем, находимся ли мы на главной странице
|
||||
const isHomePage = router.pathname === "/";
|
||||
// Проверяем, находимся ли мы на странице категорий или коллекций
|
||||
const isDetailPage = router.pathname.includes("[slug]");
|
||||
|
||||
// Функция для переключения отображения меню пользователя
|
||||
const toggleUserMenu = () => {
|
||||
setShowUserMenu(!showUserMenu);
|
||||
};
|
||||
|
||||
// Функция для переключения мобильного меню
|
||||
const toggleMobileMenu = () => {
|
||||
setMobileMenuOpen(!mobileMenuOpen);
|
||||
};
|
||||
|
||||
// Закрыть мобильное меню при переходе на другую страницу
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false);
|
||||
}, [router.pathname]);
|
||||
|
||||
// Закрыть меню пользователя при клике вне его
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (showUserMenu && !target.closest('.user-menu-container')) {
|
||||
setShowUserMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showUserMenu]);
|
||||
|
||||
return (
|
||||
<header className={`fixed w-full z-50 transition-all duration-300 bg-white ${scrolled ? 'shadow-md' : 'shadow-sm'}`}>
|
||||
<nav className="py-4 transition-all duration-300 text-black">
|
||||
<div className="container mx-auto px-4 flex items-center justify-between">
|
||||
{/* Мобильная кнопка меню */}
|
||||
<button
|
||||
className="lg:hidden flex items-center justify-center"
|
||||
onClick={toggleMobileMenu}
|
||||
aria-label="Меню"
|
||||
>
|
||||
{mobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||
</button>
|
||||
|
||||
{/* Десктопное меню */}
|
||||
<div className="hidden lg:flex items-center space-x-6">
|
||||
<Link href="/category" className="text-sm font-medium hover:opacity-70 transition-opacity">
|
||||
Каталог
|
||||
</Link>
|
||||
<Link href="/all-products" className="text-sm font-medium hover:opacity-70 transition-opacity">
|
||||
Все товары
|
||||
</Link>
|
||||
<Link href="/collections" className="text-sm font-medium hover:opacity-70 transition-opacity">
|
||||
Коллекции
|
||||
</Link>
|
||||
<Link href="/new-arrivals" className="text-sm font-medium hover:opacity-70 transition-opacity">
|
||||
Новинки
|
||||
</Link>
|
||||
<Link href="/order-tracking" className="text-sm font-medium hover:opacity-70 transition-opacity">
|
||||
Отследить заказ
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Логотип - центрирован на десктопе, слева на мобильных */}
|
||||
<Link href="/" className="lg:absolute lg:left-1/2 lg:transform lg:-translate-x-1/2">
|
||||
<div className="relative h-8 w-24 md:h-10 md:w-32">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Brand Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Иконки справа */}
|
||||
<div className="flex items-center space-x-3 md:space-x-5">
|
||||
<Link href="/favorites" className="relative hover:opacity-70 transition-opacity">
|
||||
<Heart className="w-5 h-5" />
|
||||
<span className="absolute -top-2 -right-2 bg-black text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
|
||||
0
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="relative user-menu-container">
|
||||
<button
|
||||
onClick={toggleUserMenu}
|
||||
className="hover:opacity-70 transition-opacity focus:outline-none"
|
||||
>
|
||||
<User className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link href="/account" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
Мой профиль
|
||||
</Link>
|
||||
<Link href="/account/orders" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
Мои заказы
|
||||
</Link>
|
||||
{/* Ссылка на админ-панель, если пользователь админ */}
|
||||
<Link href="/admin" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
Админ-панель
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Выйти
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
Войти
|
||||
</Link>
|
||||
<Link href="/register" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
Регистрация
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link href="/cart" className="relative hover:opacity-70 transition-opacity">
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
<span className="absolute -top-2 -right-2 bg-black text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
|
||||
{cartItemsCount}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Мобильное меню */}
|
||||
<AnimatePresence>
|
||||
{mobileMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="lg:hidden bg-white border-t border-gray-100 shadow-md"
|
||||
>
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Link
|
||||
href="/category"
|
||||
className="text-sm font-medium py-2 hover:opacity-70 transition-opacity border-b border-gray-100"
|
||||
>
|
||||
Каталог
|
||||
</Link>
|
||||
<Link
|
||||
href="/all-products"
|
||||
className="text-sm font-medium py-2 hover:opacity-70 transition-opacity border-b border-gray-100"
|
||||
>
|
||||
Все товары
|
||||
</Link>
|
||||
<Link
|
||||
href="/collections"
|
||||
className="text-sm font-medium py-2 hover:opacity-70 transition-opacity border-b border-gray-100"
|
||||
>
|
||||
Коллекции
|
||||
</Link>
|
||||
<Link
|
||||
href="/new-arrivals"
|
||||
className="text-sm font-medium py-2 hover:opacity-70 transition-opacity border-b border-gray-100"
|
||||
>
|
||||
Новинки
|
||||
</Link>
|
||||
<Link
|
||||
href="/order-tracking"
|
||||
className="text-sm font-medium py-2 hover:opacity-70 transition-opacity border-b border-gray-100"
|
||||
>
|
||||
Отследить заказ
|
||||
</Link>
|
||||
<Link
|
||||
href="/search"
|
||||
className="text-sm font-medium py-2 hover:opacity-70 transition-opacity border-b border-gray-100 flex items-center"
|
||||
>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
Поиск
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
// Типы для свойств компонента
|
||||
interface HeroProps {
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
export default function Hero({ images = [] }: HeroProps) {
|
||||
return (
|
||||
<div className="relative h-[80vh] md:h-screen overflow-hidden bg-white">
|
||||
{/* Логотип по центру */}
|
||||
<div className="absolute top-1/3 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-20">
|
||||
<div className="relative w-[200px] h-[200px] md:w-[300px] md:h-[300px]">
|
||||
<Image
|
||||
src="/logotip.png"
|
||||
alt="Brand Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Контент */}
|
||||
<div className="relative inset-0 flex items-center justify-center z-10">
|
||||
<div className="text-center text-black px-4 mt-[300px] md:mt-[400px]">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.8 }}
|
||||
className="text-4xl md:text-6xl font-bold mb-4 font-['Playfair_Display']"
|
||||
>
|
||||
Элегантность в каждой детали
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.8 }}
|
||||
className="text-lg md:text-xl mb-8 max-w-2xl mx-auto"
|
||||
>
|
||||
Откройте для себя новую коллекцию, созданную с любовью к качеству и стилю
|
||||
</motion.p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6, duration: 0.8 }}
|
||||
>
|
||||
<a
|
||||
href="/collections"
|
||||
className="bg-black text-white px-8 py-3 rounded-md font-medium hover:bg-gray-800 transition-colors inline-block"
|
||||
>
|
||||
Смотреть коллекцию
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,256 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import Image from "next/image"
|
||||
import { Heart, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { Product, formatPrice } from "../data/products"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
interface NewArrivalsProps {
|
||||
products: Product[]
|
||||
}
|
||||
|
||||
export default function NewArrivals({ products }: NewArrivalsProps) {
|
||||
const [hoveredProduct, setHoveredProduct] = useState<number | null>(null)
|
||||
const [favorites, setFavorites] = useState<number[]>([])
|
||||
const sliderRef = useRef<HTMLDivElement>(null)
|
||||
const [showLeftArrow, setShowLeftArrow] = useState(false)
|
||||
const [showRightArrow, setShowRightArrow] = useState(true)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [startX, setStartX] = useState(0)
|
||||
const [scrollLeftValue, setScrollLeftValue] = useState(0)
|
||||
const [currentSlide, setCurrentSlide] = useState(0)
|
||||
const [slidesPerView, setSlidesPerView] = useState(4)
|
||||
|
||||
// Функция для добавления/удаления товара из избранного
|
||||
const toggleFavorite = (id: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
setFavorites((prev) => (prev.includes(id) ? prev.filter((itemId) => itemId !== id) : [...prev, id]))
|
||||
}
|
||||
|
||||
// Определение количества слайдов на экране в зависимости от размера экрана
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const width = window.innerWidth;
|
||||
if (width < 640) {
|
||||
setSlidesPerView(1);
|
||||
} else if (width < 768) {
|
||||
setSlidesPerView(2);
|
||||
} else if (width < 1024) {
|
||||
setSlidesPerView(3);
|
||||
} else {
|
||||
setSlidesPerView(4);
|
||||
}
|
||||
};
|
||||
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
// Обновление состояния стрелок при скролле
|
||||
const updateArrowVisibility = () => {
|
||||
if (sliderRef.current) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = sliderRef.current
|
||||
setShowLeftArrow(scrollLeft > 0)
|
||||
setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 5) // 5px buffer
|
||||
|
||||
// Обновление текущего слайда
|
||||
if (clientWidth > 0) {
|
||||
const slideWidth = scrollWidth / products.length;
|
||||
const newCurrentSlide = Math.round(scrollLeft / slideWidth);
|
||||
setCurrentSlide(newCurrentSlide);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализация и обработка изменений размера
|
||||
useEffect(() => {
|
||||
updateArrowVisibility()
|
||||
window.addEventListener("resize", updateArrowVisibility)
|
||||
return () => window.removeEventListener("resize", updateArrowVisibility)
|
||||
}, [])
|
||||
|
||||
// Обработчики скролла
|
||||
const handleScroll = () => {
|
||||
updateArrowVisibility()
|
||||
}
|
||||
|
||||
const scrollLeft = () => {
|
||||
if (sliderRef.current) {
|
||||
const itemWidth = sliderRef.current.scrollWidth / products.length;
|
||||
const newScrollLeft = Math.max(0, sliderRef.current.scrollLeft - (itemWidth * slidesPerView));
|
||||
sliderRef.current.scrollTo({ left: newScrollLeft, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
const scrollRight = () => {
|
||||
if (sliderRef.current) {
|
||||
const itemWidth = sliderRef.current.scrollWidth / products.length;
|
||||
const newScrollLeft = Math.min(
|
||||
sliderRef.current.scrollWidth - sliderRef.current.clientWidth,
|
||||
sliderRef.current.scrollLeft + (itemWidth * slidesPerView)
|
||||
);
|
||||
sliderRef.current.scrollTo({ left: newScrollLeft, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
// Обработчики перетаскивания (для мобильных)
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
setIsDragging(true)
|
||||
setStartX(e.pageX - (sliderRef.current?.offsetLeft || 0))
|
||||
setScrollLeftValue(sliderRef.current?.scrollLeft || 0)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
e.preventDefault()
|
||||
if (sliderRef.current) {
|
||||
const x = e.pageX - (sliderRef.current.offsetLeft || 0)
|
||||
const walk = (x - startX) * 2 // Скорость скролла
|
||||
sliderRef.current.scrollLeft = scrollLeftValue - walk
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
// Обработчики тач-событий
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
if (sliderRef.current) {
|
||||
setStartX(e.touches[0].pageX - (sliderRef.current.offsetLeft || 0))
|
||||
setScrollLeftValue(sliderRef.current.scrollLeft)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (sliderRef.current) {
|
||||
const x = e.touches[0].pageX - (sliderRef.current.offsetLeft || 0)
|
||||
const walk = (x - startX) * 2
|
||||
sliderRef.current.scrollLeft = scrollLeftValue - walk
|
||||
}
|
||||
}
|
||||
|
||||
// Переход к определенному слайду
|
||||
const goToSlide = (index: number) => {
|
||||
if (sliderRef.current) {
|
||||
const itemWidth = sliderRef.current.scrollWidth / products.length;
|
||||
sliderRef.current.scrollTo({ left: itemWidth * index, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="new-arrivals" className="my-12 px-4 md:px-8 max-w-7xl mx-auto relative">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-center mb-12 font-['Playfair_Display']">Новинки</h2>
|
||||
|
||||
{/* Контейнер слайдера с кнопками навигации */}
|
||||
<div className="relative">
|
||||
{/* Кнопка влево */}
|
||||
{showLeftArrow && (
|
||||
<button
|
||||
onClick={scrollLeft}
|
||||
className="absolute -left-4 top-1/2 -translate-y-1/2 z-10 bg-white/80 hover:bg-white rounded-full p-2 shadow-md transition-all"
|
||||
aria-label="Предыдущие товары"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Слайдер продуктов */}
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className="flex overflow-x-auto gap-4 md:gap-6 pb-6 scrollbar-hide snap-x snap-mandatory"
|
||||
onScroll={handleScroll}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleMouseUp}
|
||||
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
|
||||
>
|
||||
{products.map((product) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
className="flex-none w-[280px] sm:w-[320px] md:w-[300px] lg:w-[280px] xl:w-[300px] snap-start"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: product.id * 0.05 }}
|
||||
whileHover={{ y: -5, transition: { duration: 0.2 } }}
|
||||
>
|
||||
<Link
|
||||
href={`/product/${product.slug}`}
|
||||
className="block h-full"
|
||||
onMouseEnter={() => setHoveredProduct(product.id)}
|
||||
onMouseLeave={() => setHoveredProduct(null)}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-xl">
|
||||
<div className="aspect-[3/4] relative overflow-hidden rounded-xl">
|
||||
<Image
|
||||
src={
|
||||
hoveredProduct === product.id && product.images.length > 1 ? product.images[1] : product.images[0]
|
||||
}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(max-width: 640px) 280px, (max-width: 768px) 320px, (max-width: 1024px) 300px, 280px"
|
||||
className="object-cover transition-all duration-500 hover:scale-105"
|
||||
/>
|
||||
{product.isNew && (
|
||||
<span className="absolute top-4 left-4 bg-black text-white text-sm py-1 px-3 rounded">Новинка</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => toggleFavorite(product.id, e)}
|
||||
className="absolute top-4 right-4 bg-white/80 hover:bg-white rounded-full p-2 transition-all"
|
||||
aria-label={favorites.includes(product.id) ? "Удалить из избранного" : "Добавить в избранное"}
|
||||
>
|
||||
<Heart
|
||||
className={`w-5 h-5 ${favorites.includes(product.id) ? "fill-red-500 text-red-500" : "text-gray-700"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-medium">{product.name}</h3>
|
||||
<p className="mt-1 text-lg font-bold">{formatPrice(product.price)} ₽</p>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Кнопка вправо */}
|
||||
{showRightArrow && (
|
||||
<button
|
||||
onClick={scrollRight}
|
||||
className="absolute -right-4 top-1/2 -translate-y-1/2 z-10 bg-white/80 hover:bg-white rounded-full p-2 shadow-md transition-all"
|
||||
aria-label="Следующие товары"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Индикаторы слайдов (точки) */}
|
||||
<div className="flex justify-center mt-6 space-x-2">
|
||||
{Array.from({ length: Math.ceil(products.length / slidesPerView) }).map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => goToSlide(index * slidesPerView)}
|
||||
className={`w-2 h-2 rounded-full transition-all ${
|
||||
Math.floor(currentSlide / slidesPerView) === index ? "bg-black scale-150" : "bg-gray-300"
|
||||
}`}
|
||||
aria-label={`Перейти к слайду ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { Category } from "../data/categories"
|
||||
|
||||
// Типы для свойств компонента
|
||||
interface PopularCategoriesProps {
|
||||
categories: Category[]
|
||||
}
|
||||
|
||||
export default function PopularCategories({ categories }: PopularCategoriesProps) {
|
||||
return (
|
||||
<section className="py-12 px-4 md:px-8 max-w-7xl mx-auto">
|
||||
<h2 className="text-2xl md:text-3xl font-bold mb-8 text-center font-['Playfair_Display']">Популярные категории</h2>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-6">
|
||||
{categories.map((category, index) => (
|
||||
<Link href={category.url} key={category.id} className="group">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="overflow-hidden rounded-xl"
|
||||
>
|
||||
<div className="relative aspect-square overflow-hidden rounded-xl">
|
||||
<Image
|
||||
src={category.image}
|
||||
alt={category.name}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 text-white">
|
||||
<h3 className="text-base md:text-lg font-medium">{category.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -1,102 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
|
||||
const tabs = ["Новинки", "Коллекции", "Популярное"]
|
||||
|
||||
interface TabSelectorProps {
|
||||
onTabChange: (index: number) => void;
|
||||
}
|
||||
|
||||
export default function TabSelector({ onTabChange }: TabSelectorProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [hoverStyle, setHoverStyle] = useState({})
|
||||
const [activeStyle, setActiveStyle] = useState({ left: "0px", width: "0px" })
|
||||
const tabRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (hoveredIndex !== null) {
|
||||
const hoveredElement = tabRefs.current[hoveredIndex]
|
||||
if (hoveredElement) {
|
||||
const { offsetLeft, offsetWidth } = hoveredElement
|
||||
setHoverStyle({
|
||||
left: `${offsetLeft}px`,
|
||||
width: `${offsetWidth}px`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [hoveredIndex])
|
||||
|
||||
useEffect(() => {
|
||||
const activeElement = tabRefs.current[activeIndex]
|
||||
if (activeElement) {
|
||||
const { offsetLeft, offsetWidth } = activeElement
|
||||
setActiveStyle({
|
||||
left: `${offsetLeft}px`,
|
||||
width: `${offsetWidth}px`,
|
||||
})
|
||||
}
|
||||
|
||||
// Вызываем функцию обратного вызова при изменении активного таба
|
||||
onTabChange(activeIndex);
|
||||
}, [activeIndex, onTabChange])
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const firstElement = tabRefs.current[0]
|
||||
if (firstElement) {
|
||||
const { offsetLeft, offsetWidth } = firstElement
|
||||
setActiveStyle({
|
||||
left: `${offsetLeft}px`,
|
||||
width: `${offsetWidth}px`,
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center w-full py-8 bg-white">
|
||||
<div className="w-full max-w-[1200px] h-[60px] relative flex items-center justify-center">
|
||||
<div className="p-0">
|
||||
<div className="relative">
|
||||
{/* Hover Highlight */}
|
||||
<div
|
||||
className="absolute h-[30px] transition-all duration-300 ease-out bg-[#63823B]/20 rounded-[6px] flex items-center"
|
||||
style={{
|
||||
...hoverStyle,
|
||||
opacity: hoveredIndex !== null ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Active Indicator */}
|
||||
<div
|
||||
className="absolute bottom-[-6px] h-[2px] bg-[#2B5F47] transition-all duration-300 ease-out"
|
||||
style={activeStyle}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="relative flex space-x-[24px] items-center">
|
||||
{tabs.map((tab, index) => (
|
||||
<div
|
||||
key={index}
|
||||
ref={(el) => (tabRefs.current[index] = el)}
|
||||
className={`px-3 py-2 cursor-pointer transition-colors duration-300 h-[30px] ${
|
||||
index === activeIndex ? "text-[#2B5F47] font-medium" : "text-[#2B5F47]/70"
|
||||
}`}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
onClick={() => setActiveIndex(index)}
|
||||
>
|
||||
<div className="text-sm leading-5 whitespace-nowrap flex items-center justify-center h-full">
|
||||
{tab}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,298 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
Tag,
|
||||
ShoppingBag,
|
||||
Users,
|
||||
FileText,
|
||||
Settings,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Home,
|
||||
Grid
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import authService from '../../services/auth';
|
||||
import { userService } from '../../services/users';
|
||||
|
||||
// Компонент элемента бокового меню
|
||||
interface SidebarItemProps {
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
href: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const SidebarItem = ({ icon, text, href, active }: SidebarItemProps) => {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`flex items-center px-4 py-3 rounded-lg ${active ? 'bg-indigo-50 text-indigo-600' : 'text-gray-600 hover:bg-gray-50'}`}
|
||||
>
|
||||
<span className={`${active ? 'text-indigo-600' : 'text-gray-500'}`}>{icon}</span>
|
||||
<span className={`ml-3 font-medium ${active ? 'text-indigo-600' : 'text-gray-700'}`}>{text}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function AdminLayout({ children, title }: AdminLayoutProps) {
|
||||
const router = useRouter();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Проверка прав администратора при загрузке компонента
|
||||
useEffect(() => {
|
||||
const checkAdminAccess = async () => {
|
||||
try {
|
||||
if (!authService.isAuthenticated()) {
|
||||
// Если пользователь не авторизован, перенаправляем на страницу входа
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await userService.getCurrentUser();
|
||||
|
||||
if (!user) {
|
||||
// Если не удалось получить данные пользователя, перенаправляем на страницу входа
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.is_admin) {
|
||||
// Если пользователь не администратор, перенаправляем на главную страницу
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при проверке прав администратора:', error);
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
|
||||
checkAdminAccess();
|
||||
}, [router]);
|
||||
|
||||
// Определяем активный пункт меню на основе текущего пути
|
||||
const currentPath = router.pathname;
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
icon: <Home className="w-5 h-5" />,
|
||||
text: 'Панель управления',
|
||||
href: '/admin',
|
||||
active: currentPath === '/admin'
|
||||
},
|
||||
{
|
||||
icon: <Package className="w-5 h-5" />,
|
||||
text: 'Товары',
|
||||
href: '/admin/products',
|
||||
active: currentPath.startsWith('/admin/products')
|
||||
},
|
||||
{
|
||||
icon: <ShoppingBag className="w-5 h-5" />,
|
||||
text: 'Категории',
|
||||
href: '/admin/categories',
|
||||
active: currentPath.startsWith('/admin/categories')
|
||||
},
|
||||
{
|
||||
icon: <ShoppingBag className="w-5 h-5" />,
|
||||
text: 'Заказы',
|
||||
href: '/admin/orders',
|
||||
active: currentPath.startsWith('/admin/orders')
|
||||
},
|
||||
{
|
||||
icon: <Users className="w-5 h-5" />,
|
||||
text: 'Пользователи',
|
||||
href: '/admin/users',
|
||||
active: currentPath.startsWith('/admin/users')
|
||||
},
|
||||
{
|
||||
icon: <Settings className="w-5 h-5" />,
|
||||
text: 'Настройки',
|
||||
href: '/admin/settings',
|
||||
active: currentPath.startsWith('/admin/settings')
|
||||
},
|
||||
{
|
||||
icon: <Grid className="w-5 h-5" />,
|
||||
text: 'Коллекции',
|
||||
href: '/admin/collections',
|
||||
active: currentPath.startsWith('/admin/collections')
|
||||
}
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-100">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<Head>
|
||||
<title>{title} | Админ-панель</title>
|
||||
</Head>
|
||||
|
||||
{/* Мобильная навигация */}
|
||||
<div className="lg:hidden">
|
||||
<div className="fixed top-0 left-0 right-0 z-30 bg-white shadow-sm px-4 py-2 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 rounded-md text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
<div className="flex items-center">
|
||||
<div className="relative h-8 w-24">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Brand Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<span className="ml-2 font-semibold text-gray-800">Админ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Боковое меню (мобильное) */}
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 z-40 lg:hidden">
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" onClick={() => setSidebarOpen(false)}></div>
|
||||
<div className="fixed inset-y-0 left-0 flex flex-col w-64 max-w-xs bg-white shadow-xl">
|
||||
<div className="h-16 flex items-center justify-between px-4 border-b border-gray-200">
|
||||
<div className="flex items-center">
|
||||
<div className="relative h-8 w-24">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Brand Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<span className="ml-2 font-semibold text-gray-800">Админ</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="p-2 rounded-md text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<nav className="space-y-1">
|
||||
{menuItems.map((item, index) => (
|
||||
<SidebarItem
|
||||
key={index}
|
||||
icon={item.icon}
|
||||
text={item.text}
|
||||
href={item.href}
|
||||
active={item.active}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="p-4 border-t border-gray-200">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center px-4 py-3 text-indigo-600 rounded-lg hover:bg-indigo-50 w-full mb-3"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
<span className="ml-3 font-medium">На главную</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
authService.logout();
|
||||
router.push('/login');
|
||||
}}
|
||||
className="flex items-center px-4 py-3 text-red-600 rounded-lg hover:bg-red-50 w-full"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="ml-3 font-medium">Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Боковое меню (десктоп) */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:flex-col lg:w-64 lg:bg-white lg:border-r lg:border-gray-200">
|
||||
<div className="h-16 flex items-center px-6 border-b border-gray-200">
|
||||
<div className="flex items-center">
|
||||
<div className="relative h-8 w-24">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Brand Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<span className="ml-2 font-semibold text-gray-800">Админ</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<nav className="space-y-1">
|
||||
{menuItems.map((item, index) => (
|
||||
<SidebarItem
|
||||
key={index}
|
||||
icon={item.icon}
|
||||
text={item.text}
|
||||
href={item.href}
|
||||
active={item.active}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="p-4 border-t border-gray-200">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center px-4 py-3 text-indigo-600 rounded-lg hover:bg-indigo-50 w-full mb-3"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
<span className="ml-3 font-medium">На главную</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
authService.logout();
|
||||
router.push('/login');
|
||||
}}
|
||||
className="flex items-center px-4 py-3 text-red-600 rounded-lg hover:bg-red-50 w-full"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="ml-3 font-medium">Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основной контент */}
|
||||
<div className="lg:pl-64 flex flex-col min-h-screen">
|
||||
<header className="hidden lg:flex h-16 bg-white shadow-sm px-6 items-center">
|
||||
<h1 className="text-2xl font-semibold text-gray-800">{title}</h1>
|
||||
</header>
|
||||
<main className="flex-1 p-6 pt-20 lg:pt-6">
|
||||
{children}
|
||||
</main>
|
||||
<footer className="bg-white border-t border-gray-200 p-4 text-center text-sm text-gray-600">
|
||||
© {new Date().getFullYear()} Dressed for Success. Все права защищены.
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,106 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ShoppingBag,
|
||||
Tag,
|
||||
FileText,
|
||||
Settings,
|
||||
Users,
|
||||
ShoppingCart,
|
||||
BarChart,
|
||||
MessageSquare
|
||||
} from 'lucide-react';
|
||||
|
||||
// Определение элементов меню
|
||||
const menuItems = [
|
||||
{
|
||||
title: 'Дашборд',
|
||||
icon: <LayoutDashboard className="h-5 w-5" />,
|
||||
href: '/admin/dashboard',
|
||||
active: (path) => path === '/admin/dashboard'
|
||||
},
|
||||
{
|
||||
title: 'Заказы',
|
||||
icon: <ShoppingCart className="h-5 w-5" />,
|
||||
href: '/admin/orders',
|
||||
active: (path) => path.startsWith('/admin/orders')
|
||||
},
|
||||
{
|
||||
title: 'Клиенты',
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
href: '/admin/customers',
|
||||
active: (path) => path.startsWith('/admin/customers')
|
||||
},
|
||||
{
|
||||
title: 'Категории',
|
||||
icon: <Tag className="h-5 w-5" />,
|
||||
href: '/admin/categories',
|
||||
active: (path) => path.startsWith('/admin/categories')
|
||||
},
|
||||
{
|
||||
title: 'Товары',
|
||||
icon: <ShoppingBag className="h-5 w-5" />,
|
||||
href: '/admin/products',
|
||||
active: (path) => path.startsWith('/admin/products')
|
||||
},
|
||||
{
|
||||
title: 'Страницы',
|
||||
icon: <FileText className="h-5 w-5" />,
|
||||
href: '/admin/pages',
|
||||
active: (path) => path.startsWith('/admin/pages')
|
||||
},
|
||||
{
|
||||
title: 'Отзывы',
|
||||
icon: <MessageSquare className="h-5 w-5" />,
|
||||
href: '/admin/reviews',
|
||||
active: (path) => path.startsWith('/admin/reviews')
|
||||
},
|
||||
{
|
||||
title: 'Аналитика',
|
||||
icon: <BarChart className="h-5 w-5" />,
|
||||
href: '/admin/analytics',
|
||||
active: (path) => path.startsWith('/admin/analytics')
|
||||
},
|
||||
{
|
||||
title: 'Настройки',
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
href: '/admin/settings',
|
||||
active: (path) => path.startsWith('/admin/settings')
|
||||
}
|
||||
];
|
||||
|
||||
export default function AdminSidebar() {
|
||||
const router = useRouter();
|
||||
const currentPath = router.pathname;
|
||||
|
||||
return (
|
||||
<div className="h-full bg-white border-r border-gray-200 w-64 flex-shrink-0">
|
||||
<div className="p-6">
|
||||
<Link href="/admin/dashboard" className="flex items-center">
|
||||
<span className="text-xl font-bold text-indigo-600">DressedForSuccess</span>
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="mt-5 px-3 space-y-1">
|
||||
{menuItems.map((item) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
className={`group flex items-center px-3 py-2 text-sm font-medium rounded-md ${
|
||||
item.active(currentPath)
|
||||
? 'bg-indigo-50 text-indigo-600'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<span className={`mr-3 ${
|
||||
item.active(currentPath) ? 'text-indigo-600' : 'text-gray-400 group-hover:text-gray-500'
|
||||
}`}>
|
||||
{item.icon}
|
||||
</span>
|
||||
{item.title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,391 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Save, X } from 'lucide-react';
|
||||
import { Product, ProductVariant, Category, Collection, ProductImage } from '../../services/catalog';
|
||||
import { productService, categoryService, collectionService } from '../../services/catalog';
|
||||
import ProductImageUploader, { ProductImageWithFile } from './ProductImageUploader';
|
||||
import ProductVariantManager from './ProductVariantManager';
|
||||
|
||||
interface ProductFormProps {
|
||||
product?: Product;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const ProductForm: React.FC<ProductFormProps> = ({ product, onSuccess }) => {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [images, setImages] = useState<ProductImageWithFile[]>([]);
|
||||
const [variants, setVariants] = useState<ProductVariant[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
category_id: '',
|
||||
collection_id: '',
|
||||
is_active: true
|
||||
});
|
||||
const [autoGenerateSlug, setAutoGenerateSlug] = useState(true);
|
||||
|
||||
// Загрузка категорий и коллекций при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [categoriesData, collectionsData] = await Promise.all([
|
||||
categoryService.getCategories(),
|
||||
collectionService.getCollections()
|
||||
]);
|
||||
setCategories(categoriesData);
|
||||
setCollections(collectionsData);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных:', err);
|
||||
setError('Не удалось загрузить данные категорий и коллекций.');
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
|
||||
// Если передан продукт, заполняем форму его данными
|
||||
if (product) {
|
||||
setFormData({
|
||||
name: product.name || '',
|
||||
slug: product.slug || '',
|
||||
description: product.description || '',
|
||||
category_id: product.category_id ? String(product.category_id) : '',
|
||||
collection_id: product.collection_id ? String(product.collection_id) : '',
|
||||
is_active: typeof product.is_active === 'boolean' ? product.is_active : true
|
||||
});
|
||||
setAutoGenerateSlug(false);
|
||||
|
||||
// Загружаем изображения
|
||||
if (product.images && Array.isArray(product.images)) {
|
||||
const productImages: ProductImageWithFile[] = product.images.map(img => ({
|
||||
id: img.id,
|
||||
url: img.url,
|
||||
is_primary: img.is_primary,
|
||||
product_id: img.product_id
|
||||
}));
|
||||
setImages(productImages);
|
||||
}
|
||||
|
||||
// Загружаем варианты
|
||||
if (product.variants && Array.isArray(product.variants)) {
|
||||
setVariants(product.variants);
|
||||
}
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
// Автоматическая генерация slug при изменении названия товара
|
||||
useEffect(() => {
|
||||
if (autoGenerateSlug && formData.name) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
slug: generateSlug(formData.name)
|
||||
}));
|
||||
}
|
||||
}, [formData.name, autoGenerateSlug]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
});
|
||||
|
||||
// Если пользователь изменяет slug вручную, отключаем автогенерацию
|
||||
if (name === 'slug') {
|
||||
setAutoGenerateSlug(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name || !formData.category_id || variants.length === 0) {
|
||||
setError('Пожалуйста, заполните все обязательные поля и добавьте хотя бы один вариант товара.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
// Используем введенный slug или генерируем новый, если поле пустое
|
||||
const slug = formData.slug || generateSlug(formData.name);
|
||||
|
||||
// Подготавливаем данные продукта
|
||||
const productData = {
|
||||
name: formData.name,
|
||||
slug,
|
||||
description: formData.description,
|
||||
category_id: parseInt(formData.category_id, 10),
|
||||
collection_id: formData.collection_id ? parseInt(formData.collection_id, 10) : null,
|
||||
is_active: formData.is_active
|
||||
};
|
||||
|
||||
console.log('Отправляемые данные продукта:', productData);
|
||||
|
||||
let productId: number;
|
||||
|
||||
if (product) {
|
||||
// Обновляем существующий продукт
|
||||
console.log(`Обновление продукта с ID: ${product.id}`);
|
||||
const updatedProduct = await productService.updateProduct(product.id, productData);
|
||||
console.log('Обновленный продукт:', updatedProduct);
|
||||
productId = updatedProduct.id;
|
||||
} else {
|
||||
// Создаем новый продукт
|
||||
console.log('Создание нового продукта');
|
||||
const newProduct = await productService.createProduct(productData);
|
||||
console.log('Созданный продукт:', newProduct);
|
||||
productId = newProduct.id;
|
||||
}
|
||||
|
||||
// Загружаем изображения
|
||||
console.log(`Загрузка ${images.length} изображений для продукта ${productId}`);
|
||||
|
||||
// Используем Promise.all для параллельной загрузки изображений
|
||||
const imagePromises = images.map(async (image) => {
|
||||
try {
|
||||
if (image.file) {
|
||||
// Загружаем новое изображение
|
||||
console.log(`Загрузка нового изображения: ${image.file.name}`);
|
||||
const uploadedImage = await productService.uploadProductImage(productId, image.file, image.is_primary);
|
||||
console.log('Загруженное изображение:', uploadedImage);
|
||||
return uploadedImage;
|
||||
} else if (image.id && product) {
|
||||
// Обновляем существующее изображение
|
||||
console.log(`Обновление существующего изображения с ID: ${image.id}`);
|
||||
const updatedImage = await productService.updateProductImage(productId, image.id, { is_primary: image.is_primary });
|
||||
console.log('Обновленное изображение:', updatedImage);
|
||||
return updatedImage;
|
||||
}
|
||||
return null;
|
||||
} catch (imgError) {
|
||||
console.error('Ошибка при обработке изображения:', imgError);
|
||||
if (imgError.response) {
|
||||
console.error('Данные ответа:', imgError.response.data);
|
||||
console.error('Статус ответа:', imgError.response.status);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const uploadedImages = await Promise.all(imagePromises);
|
||||
console.log('Все изображения обработаны:', uploadedImages.filter(Boolean));
|
||||
|
||||
// Обрабатываем варианты товара
|
||||
if (product) {
|
||||
// Для существующего продукта варианты уже обработаны через API в компоненте ProductVariantManager
|
||||
console.log('Варианты для существующего продукта уже обработаны');
|
||||
} else {
|
||||
// Для нового продукта создаем варианты
|
||||
console.log(`Создание ${variants.length} вариантов для нового продукта ${productId}`);
|
||||
|
||||
const variantPromises = variants.map(async (variant) => {
|
||||
try {
|
||||
console.log('Отправка данных варианта:', variant);
|
||||
// Явно указываем все поля, чтобы избежать лишних данных
|
||||
const variantData = {
|
||||
name: variant.name,
|
||||
sku: variant.sku,
|
||||
price: Number(variant.price), // Убедимся, что это число
|
||||
discount_price: variant.discount_price ? Number(variant.discount_price) : null,
|
||||
stock: Number(variant.stock),
|
||||
is_active: Boolean(variant.is_active || true)
|
||||
};
|
||||
|
||||
const newVariant = await productService.addProductVariant(productId, variantData);
|
||||
console.log('Созданный вариант:', newVariant);
|
||||
return newVariant;
|
||||
} catch (varError) {
|
||||
console.error('Ошибка при создании варианта:', varError);
|
||||
if (varError.response) {
|
||||
console.error('Ответ сервера:', varError.response.data);
|
||||
console.error('Статус ответа:', varError.response.status);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const createdVariants = await Promise.all(variantPromises);
|
||||
console.log('Все варианты обработаны:', createdVariants.filter(Boolean));
|
||||
}
|
||||
|
||||
// Вызываем колбэк успешного завершения или перенаправляем на страницу списка товаров
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
} else {
|
||||
router.push('/admin/products');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при сохранении товара:', err);
|
||||
if (err.response) {
|
||||
console.error('Ответ сервера:', err.response.data);
|
||||
console.error('Статус ответа:', err.response.status);
|
||||
}
|
||||
setError('Не удалось сохранить товар. Пожалуйста, проверьте введенные данные и попробуйте снова.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateSlug = (name) => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
|
||||
<strong className="font-bold">Ошибка!</strong>
|
||||
<span className="block sm:inline"> {error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Название товара *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="slug" className="block text-sm font-medium text-gray-700">
|
||||
URL-адрес (slug)
|
||||
<span className="ml-1 text-xs text-gray-500">
|
||||
{autoGenerateSlug ? '(генерируется автоматически)' : ''}
|
||||
</span>
|
||||
</label>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
name="slug"
|
||||
value={formData.slug}
|
||||
onChange={handleChange}
|
||||
placeholder="url-adres-tovara"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<label className="inline-flex items-center text-sm text-gray-500">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoGenerateSlug}
|
||||
onChange={() => setAutoGenerateSlug(!autoGenerateSlug)}
|
||||
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded mr-2"
|
||||
/>
|
||||
Генерировать автоматически
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="category_id" className="block text-sm font-medium text-gray-700">Категория *</label>
|
||||
<select
|
||||
id="category_id"
|
||||
name="category_id"
|
||||
value={formData.category_id}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Выберите категорию</option>
|
||||
{categories.map(category => (
|
||||
<option key={category.id} value={category.id}>{category.name}</option>
|
||||
))}
|
||||
</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}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Выберите коллекцию</option>
|
||||
{collections.map(collection => (
|
||||
<option key={collection.id} value={collection.id}>{collection.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="is_active" className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_active"
|
||||
name="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">Активный товар</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700">Описание</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProductImageUploader
|
||||
images={images}
|
||||
setImages={setImages}
|
||||
productId={product?.id}
|
||||
/>
|
||||
|
||||
<ProductVariantManager
|
||||
variants={variants}
|
||||
setVariants={setVariants}
|
||||
productId={product?.id}
|
||||
/>
|
||||
|
||||
<div className="mt-8 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/admin/products')}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="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"
|
||||
>
|
||||
{loading ? 'Сохранение...' : product ? 'Обновить товар' : 'Создать товар'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductForm;
|
||||
@ -1,230 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { X, Upload, Check } from 'lucide-react';
|
||||
import { ProductImage } from '../../services/catalog';
|
||||
import { productService } from '../../services/catalog';
|
||||
|
||||
export interface ProductImageWithFile extends ProductImage {
|
||||
file?: File;
|
||||
isUploading?: boolean;
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
interface ProductImageUploaderProps {
|
||||
images: ProductImageWithFile[];
|
||||
setImages: (images: ProductImageWithFile[]) => void;
|
||||
productId?: number; // Опционально, если редактируем существующий продукт
|
||||
}
|
||||
|
||||
const ProductImageUploader: React.FC<ProductImageUploaderProps> = ({
|
||||
images,
|
||||
setImages,
|
||||
productId
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
|
||||
if (files.length === 0) return;
|
||||
|
||||
console.log(`Выбрано ${files.length} файлов для загрузки`);
|
||||
|
||||
const newImages = files.map((file: File) => {
|
||||
const isPrimary = images.length === 0; // Первое изображение будет основным
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
console.log(`Создан временный URL для файла ${file.name}: ${objectUrl}`);
|
||||
|
||||
return {
|
||||
id: Math.random(), // Временный ID
|
||||
url: objectUrl,
|
||||
is_primary: isPrimary,
|
||||
product_id: productId || 0,
|
||||
file,
|
||||
isUploading: false // Изменено с true на false, так как загрузка будет происходить при сохранении формы
|
||||
};
|
||||
});
|
||||
|
||||
console.log('Новые изображения для добавления:', newImages);
|
||||
setImages([...images, ...newImages]);
|
||||
|
||||
// Сбрасываем значение input, чтобы можно было загрузить тот же файл повторно
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleRemoveImage = async (id) => {
|
||||
try {
|
||||
console.log(`Удаление изображения с ID: ${id}`);
|
||||
|
||||
const image = images.find(img => img.id === id);
|
||||
|
||||
if (!image) {
|
||||
console.error(`Изображение с ID ${id} не найдено`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (productId && !image.file) {
|
||||
// Если изображение уже загружено на сервер, удаляем его с сервера
|
||||
console.log(`Удаление изображения с сервера: ${id}`);
|
||||
await productService.deleteProductImage(productId, id);
|
||||
} else {
|
||||
console.log(`Удаление локального изображения: ${id}`);
|
||||
// Если это локальное изображение с временным URL, освобождаем ресурсы
|
||||
if (image.url && image.url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(image.url);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedImages = images.filter(image => image.id !== id);
|
||||
|
||||
// Если удалили основное изображение, делаем первое из оставшихся основным
|
||||
if (image.is_primary && updatedImages.length > 0 && !updatedImages.some(img => img.is_primary)) {
|
||||
const firstImage = updatedImages[0];
|
||||
|
||||
if (productId && !firstImage.file) {
|
||||
console.log(`Установка нового основного изображения: ${firstImage.id}`);
|
||||
await productService.updateProductImage(productId, firstImage.id, { is_primary: true });
|
||||
}
|
||||
|
||||
updatedImages[0] = { ...firstImage, is_primary: true };
|
||||
}
|
||||
|
||||
console.log('Обновленный список изображений:', updatedImages);
|
||||
setImages(updatedImages);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении изображения:', err);
|
||||
if (err.response) {
|
||||
console.error('Данные ответа:', err.response.data);
|
||||
console.error('Статус ответа:', err.response.status);
|
||||
}
|
||||
setError('Не удалось удалить изображение. Пожалуйста, попробуйте снова.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetPrimary = async (id) => {
|
||||
try {
|
||||
console.log(`Установка изображения ${id} как основного`);
|
||||
|
||||
// Обновляем на сервере только если продукт уже существует и изображение уже загружено
|
||||
if (productId) {
|
||||
const image = images.find(img => img.id === id);
|
||||
if (image && !image.file) {
|
||||
await productService.updateProductImage(productId, id, { is_primary: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем локальное состояние
|
||||
const updatedImages = images.map(image => ({
|
||||
...image,
|
||||
is_primary: image.id === id
|
||||
}));
|
||||
|
||||
console.log('Обновленный список изображений:', updatedImages);
|
||||
setImages(updatedImages);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при установке основного изображения:', err);
|
||||
if (err.response) {
|
||||
console.error('Данные ответа:', err.response.data);
|
||||
console.error('Статус ответа:', err.response.status);
|
||||
}
|
||||
setError('Не удалось установить основное изображение. Пожалуйста, попробуйте снова.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Изображения товара</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
|
||||
<span className="block sm:inline">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{images.map((image) => {
|
||||
console.log(`Отображение изображения:`, {
|
||||
id: image.id,
|
||||
url: image.url,
|
||||
image_url: image.image_url,
|
||||
is_primary: image.is_primary,
|
||||
isFile: !!image.file
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={image.id} className="relative group">
|
||||
<div className={`aspect-square rounded-md overflow-hidden border-2 ${image.is_primary ? 'border-indigo-500' : 'border-gray-200'}`}>
|
||||
<img
|
||||
src={image.url || image.image_url}
|
||||
alt="Product"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
console.error(`Ошибка загрузки изображения:`, {
|
||||
src: e.currentTarget.src,
|
||||
imageData: {
|
||||
id: image.id,
|
||||
url: image.url,
|
||||
image_url: image.image_url
|
||||
}
|
||||
});
|
||||
e.currentTarget.src = '/placeholder-image.jpg'; // Запасное изображение
|
||||
}}
|
||||
/>
|
||||
{image.isUploading && (
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute top-2 right-2 flex space-x-1">
|
||||
{!image.is_primary && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSetPrimary(image.id)}
|
||||
className="bg-white p-1 rounded-full shadow hover:bg-gray-100 focus:outline-none"
|
||||
title="Сделать основным"
|
||||
>
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveImage(image.id)}
|
||||
className="bg-white p-1 rounded-full shadow hover:bg-gray-100 focus:outline-none"
|
||||
title="Удалить"
|
||||
>
|
||||
<X className="h-4 w-4 text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{image.is_primary && (
|
||||
<div className="absolute bottom-2 left-2 bg-indigo-500 text-white text-xs px-2 py-1 rounded">
|
||||
Основное
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="aspect-square rounded-md border-2 border-dashed border-gray-300 flex flex-col items-center justify-center p-4 hover:border-indigo-500 transition-colors relative">
|
||||
<Upload className="h-8 w-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500 text-center mb-2">Перетащите файлы или нажмите для загрузки</p>
|
||||
<label className="w-full h-full absolute inset-0 cursor-pointer">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductImageUploader;
|
||||
@ -1,336 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Edit, Trash } from 'lucide-react';
|
||||
import { ProductVariant } from '../../services/catalog';
|
||||
import { productService } from '../../services/catalog';
|
||||
|
||||
interface ProductVariantManagerProps {
|
||||
variants: ProductVariant[];
|
||||
setVariants: (variants: ProductVariant[]) => void;
|
||||
productId?: number; // Опционально, если редактируем существующий продукт
|
||||
}
|
||||
|
||||
const ProductVariantManager: React.FC<ProductVariantManagerProps> = ({
|
||||
variants,
|
||||
setVariants,
|
||||
productId
|
||||
}) => {
|
||||
const [newVariant, setNewVariant] = useState({
|
||||
name: '',
|
||||
sku: '',
|
||||
price: '',
|
||||
discount_price: '',
|
||||
stock: ''
|
||||
});
|
||||
const [editingVariant, setEditingVariant] = useState<ProductVariant | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleAddVariant = async () => {
|
||||
if (!newVariant.name || !newVariant.sku || !newVariant.price || !newVariant.stock) {
|
||||
alert('Пожалуйста, заполните все обязательные поля варианта');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const variantData = {
|
||||
name: newVariant.name,
|
||||
sku: newVariant.sku,
|
||||
price: parseFloat(newVariant.price),
|
||||
discount_price: newVariant.discount_price ? parseFloat(newVariant.discount_price) : null,
|
||||
stock: parseInt(newVariant.stock, 10),
|
||||
is_active: true
|
||||
};
|
||||
|
||||
if (productId) {
|
||||
// Если есть ID продукта, добавляем вариант через API
|
||||
const newVariantData = await productService.addProductVariant(productId, variantData);
|
||||
setVariants([...variants, newVariantData]);
|
||||
} else {
|
||||
// Иначе добавляем временный вариант (для новых продуктов)
|
||||
setVariants([...variants, {
|
||||
...variantData,
|
||||
id: Date.now(), // Временный ID
|
||||
product_id: 0
|
||||
}]);
|
||||
}
|
||||
|
||||
// Сбрасываем форму
|
||||
setNewVariant({
|
||||
name: '',
|
||||
sku: '',
|
||||
price: '',
|
||||
discount_price: '',
|
||||
stock: ''
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ошибка при добавлении варианта:', err);
|
||||
setError('Не удалось добавить вариант. Пожалуйста, попробуйте снова.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditVariant = (variant: ProductVariant) => {
|
||||
setEditingVariant(variant);
|
||||
setNewVariant({
|
||||
name: variant.name,
|
||||
sku: variant.sku,
|
||||
price: variant.price.toString(),
|
||||
discount_price: variant.discount_price ? variant.discount_price.toString() : '',
|
||||
stock: variant.stock.toString()
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateVariant = async () => {
|
||||
if (!editingVariant) return;
|
||||
|
||||
if (!newVariant.name || !newVariant.sku || !newVariant.price || !newVariant.stock) {
|
||||
alert('Пожалуйста, заполните все обязательные поля варианта');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const variantData = {
|
||||
name: newVariant.name,
|
||||
sku: newVariant.sku,
|
||||
price: parseFloat(newVariant.price),
|
||||
discount_price: newVariant.discount_price ? parseFloat(newVariant.discount_price) : null,
|
||||
stock: parseInt(newVariant.stock, 10),
|
||||
is_active: editingVariant.is_active
|
||||
};
|
||||
|
||||
if (productId && editingVariant.id) {
|
||||
// Если есть ID продукта и ID варианта, обновляем через API
|
||||
await productService.updateProductVariant(editingVariant.id, variantData);
|
||||
}
|
||||
|
||||
// Обновляем локальное состояние
|
||||
setVariants(variants.map(v =>
|
||||
v.id === editingVariant.id
|
||||
? { ...v, ...variantData }
|
||||
: v
|
||||
));
|
||||
|
||||
// Сбрасываем форму и состояние редактирования
|
||||
setNewVariant({
|
||||
name: '',
|
||||
sku: '',
|
||||
price: '',
|
||||
discount_price: '',
|
||||
stock: ''
|
||||
});
|
||||
setEditingVariant(null);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при обновлении варианта:', err);
|
||||
setError('Не удалось обновить вариант. Пожалуйста, попробуйте снова.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingVariant(null);
|
||||
setNewVariant({
|
||||
name: '',
|
||||
sku: '',
|
||||
price: '',
|
||||
discount_price: '',
|
||||
stock: ''
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveVariant = async (id: number) => {
|
||||
if (!confirm('Вы уверены, что хотите удалить этот вариант?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
if (productId) {
|
||||
// Если есть ID продукта, удаляем через API
|
||||
await productService.deleteProductVariant(productId, id);
|
||||
}
|
||||
|
||||
// Обновляем локальное состояние
|
||||
setVariants(variants.filter(variant => variant.id !== id));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении варианта:', err);
|
||||
setError('Не удалось удалить вариант. Пожалуйста, попробуйте снова.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewVariantChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setNewVariant({
|
||||
...newVariant,
|
||||
[name]: value
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Варианты товара</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
|
||||
<span className="block sm:inline">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">Название</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-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-left text-xs font-medium text-gray-500 uppercase tracking-wider">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{variants.map(variant => (
|
||||
<tr key={variant.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.sku}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{variant.discount_price ? (
|
||||
<>
|
||||
{variant.discount_price}
|
||||
<span className="ml-1 line-through text-gray-400">
|
||||
{variant.price}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
variant.price
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{variant.discount_price ? 'Да' : 'Нет'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.stock}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEditVariant(variant)}
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveVariant(variant.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={newVariant.name}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Название варианта"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="text"
|
||||
name="sku"
|
||||
value={newVariant.sku}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Артикул"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="number"
|
||||
name="price"
|
||||
value={newVariant.price}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Цена"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="number"
|
||||
name="discount_price"
|
||||
value={newVariant.discount_price}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Скидка (необяз.)"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="number"
|
||||
name="stock"
|
||||
value={newVariant.stock}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Наличие"
|
||||
min="0"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{editingVariant ? (
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpdateVariant}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 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"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelEdit}
|
||||
className="inline-flex items-center px-3 py-2 border border-gray-300 text-sm leading-4 font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddVariant}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 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-4 w-4 mr-1" />
|
||||
Добавить
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductVariantManager;
|
||||
@ -1,107 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
Home,
|
||||
ShoppingBag,
|
||||
Package,
|
||||
Layers,
|
||||
Grid,
|
||||
Users,
|
||||
Settings,
|
||||
Menu,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
|
||||
// Массив навигации
|
||||
const navigation = [
|
||||
{ name: 'Панель управления', href: '/admin', icon: Home },
|
||||
{ name: 'Заказы', href: '/admin/orders', icon: ShoppingBag },
|
||||
{ name: 'Товары', href: '/admin/products', icon: Package },
|
||||
{ name: 'Категории', href: '/admin/categories', icon: Layers },
|
||||
{ name: 'Коллекции', href: '/admin/collections', icon: Grid },
|
||||
{ name: 'Пользователи', href: '/admin/users', icon: Users },
|
||||
{ name: 'Настройки', href: '/admin/settings', icon: Settings },
|
||||
];
|
||||
|
||||
export default function Sidebar({ isOpen, setIsOpen }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Мобильная версия */}
|
||||
<div className={`fixed inset-0 bg-gray-600 bg-opacity-75 z-20 transition-opacity ${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`} />
|
||||
|
||||
<div className={`fixed inset-y-0 left-0 flex flex-col z-30 max-w-xs w-full bg-white transform transition-transform ${isOpen ? 'translate-x-0' : '-translate-x-full'}`}>
|
||||
<div className="flex items-center justify-between h-16 flex-shrink-0 px-4 bg-indigo-600">
|
||||
<div className="text-white font-bold text-xl">Админ-панель</div>
|
||||
<button
|
||||
className="text-white focus:outline-none"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<nav className="px-2 py-4 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = router.pathname === item.href || router.pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
|
||||
isActive
|
||||
? 'bg-indigo-100 text-indigo-700'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<item.icon
|
||||
className={`mr-3 flex-shrink-0 h-6 w-6 ${
|
||||
isActive ? 'text-indigo-700' : 'text-gray-400 group-hover:text-gray-500'
|
||||
}`}
|
||||
/>
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Десктопная версия */}
|
||||
<div className="hidden md:flex md:flex-col md:fixed md:inset-y-0 md:w-64 bg-white border-r border-gray-200">
|
||||
<div className="flex items-center h-16 flex-shrink-0 px-4 bg-indigo-600">
|
||||
<div className="text-white font-bold text-xl">Админ-панель</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<nav className="px-2 py-4 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = router.pathname === item.href || router.pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
|
||||
isActive
|
||||
? 'bg-indigo-100 text-indigo-700'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<item.icon
|
||||
className={`mr-3 flex-shrink-0 h-6 w-6 ${
|
||||
isActive ? 'text-indigo-700' : 'text-gray-400 group-hover:text-gray-500'
|
||||
}`}
|
||||
/>
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,87 +0,0 @@
|
||||
// Тип для категории
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
url: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Данные о категориях
|
||||
export const categories: Category[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Женская обувь',
|
||||
image: '/category/shoes.jpg',
|
||||
url: '/category/shoes',
|
||||
slug: 'shoes',
|
||||
description: 'Элегантная и комфортная обувь для любого случая. Наша коллекция включает в себя модели из высококачественных материалов, созданные с вниманием к деталям.'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Шляпы и перчатки',
|
||||
image: '/category/hat.jpg',
|
||||
url: '/category/hats-and-gloves',
|
||||
slug: 'hats-and-gloves',
|
||||
description: 'Стильные аксессуары, которые дополнят ваш образ и защитят от непогоды. Изготовлены из премиальных материалов с использованием традиционных техник.'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Штаны и брюки',
|
||||
image: '/category/pants.jpg',
|
||||
url: '/category/pants',
|
||||
slug: 'pants',
|
||||
description: 'Разнообразные модели брюк для любого случая. От классических деловых моделей до повседневных и спортивных вариантов, созданных для комфорта и стиля.'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Свитеры и кардиганы',
|
||||
image: '/category/sweaters.jpg',
|
||||
url: '/category/sweaters',
|
||||
slug: 'sweaters',
|
||||
description: 'Теплые и уютные свитеры и кардиганы из натуральных материалов. Идеальный выбор для холодного времени года, сочетающий комфорт и элегантность.'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Платья и юбки',
|
||||
image: '/category/dress.jpg',
|
||||
url: '/category/dress',
|
||||
slug: 'dress',
|
||||
description: 'Элегантные платья и юбки для любого случая. От повседневных моделей до вечерних нарядов, подчеркивающих женственность и изысканность.'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Костюмы и пальто',
|
||||
image: '/category/jacket.jpg',
|
||||
url: '/category/jackets',
|
||||
slug: 'jackets',
|
||||
description: 'Стильные костюмы и пальто высокого качества. Идеальный выбор для создания элегантного образа в любое время года.'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Женский шелк',
|
||||
image: '/category/silk.jpg',
|
||||
url: '/category/womens-silk',
|
||||
slug: 'womens-silk',
|
||||
description: 'Роскошные изделия из натурального шелка. Блузки, платья и аксессуары, которые подчеркнут вашу индивидуальность и создадут ощущение роскоши.'
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Аксессуары',
|
||||
image: '/category/scarf.jpg',
|
||||
url: '/category/accessories',
|
||||
slug: 'accessories',
|
||||
description: 'Стильные аксессуары, которые дополнят ваш образ. Сумки, шарфы, ремни и другие детали, которые сделают ваш образ завершенным и уникальным.'
|
||||
}
|
||||
];
|
||||
|
||||
// Функция для получения категории по slug
|
||||
export const getCategoryBySlug = (slug: string): Category | undefined => {
|
||||
return categories.find(category => category.slug === slug);
|
||||
};
|
||||
|
||||
// Функция для получения категории по id
|
||||
export const getCategoryById = (id: number): Category | undefined => {
|
||||
return categories.find(category => category.id === id);
|
||||
};
|
||||
@ -1,63 +0,0 @@
|
||||
// Тип для коллекции
|
||||
export interface Collection {
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
description: string;
|
||||
url: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
// Данные о коллекциях
|
||||
export const collections: Collection[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Весна-Лето 2025',
|
||||
image: '/photos/photo1.jpg',
|
||||
description: 'Легкие ткани и яркие цвета для теплого сезона. Наша новая коллекция воплощает свежесть и элегантность, идеально подходящую для весенних и летних дней.',
|
||||
url: '/collections/spring-summer-2024',
|
||||
slug: 'spring-summer-2024'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Осень-Зима 2024',
|
||||
image: '/photos/autumn_winter.jpg',
|
||||
description: 'Теплые и уютные модели для холодного времени года. Коллекция сочетает в себе комфорт и стиль, предлагая элегантные решения для зимнего гардероба.',
|
||||
url: '/collections/autumn-winter-2023',
|
||||
slug: 'autumn-winter-2023'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Базовый гардероб',
|
||||
image: '/photos/based_outfit.jpg',
|
||||
description: 'Классические модели, которые никогда не выходят из моды. Эта коллекция представляет собой основу любого гардероба, включая вневременные предметы одежды высокого качества.',
|
||||
url: '/collections/basic',
|
||||
slug: 'basic'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Вечерняя коллекция',
|
||||
image: '/photos/night_dress.jpg',
|
||||
description: 'Элегантные наряды для особых случаев. Изысканные ткани и утонченный дизайн создают неповторимые образы для вечерних мероприятий и торжественных событий.',
|
||||
url: '/collections/evening',
|
||||
slug: 'evening'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Деловой стиль',
|
||||
image: '/photos/business_outfit.jpg',
|
||||
description: 'Стильная и функциональная одежда для работы и деловых встреч. Коллекция сочетает в себе профессионализм и элегантность, подчеркивая ваш статус и вкус.',
|
||||
url: '/collections/business',
|
||||
slug: 'business'
|
||||
}
|
||||
];
|
||||
|
||||
// Функция для получения коллекции по slug
|
||||
export const getCollectionBySlug = (slug: string): Collection | undefined => {
|
||||
return collections.find(collection => collection.slug === slug);
|
||||
};
|
||||
|
||||
// Функция для получения коллекции по id
|
||||
export const getCollectionById = (id: number): Collection | undefined => {
|
||||
return collections.find(collection => collection.id === id);
|
||||
};
|
||||
@ -1,201 +0,0 @@
|
||||
// Тип для товара
|
||||
export interface Product {
|
||||
inStock: any;
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
images: string[];
|
||||
description: string;
|
||||
isNew?: boolean;
|
||||
categoryId: number;
|
||||
collectionId?: number; // Опциональное поле для ID коллекции
|
||||
slug: string;
|
||||
}
|
||||
|
||||
// Данные о товарах
|
||||
export const products: Product[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Пальто оверсайз',
|
||||
price: 43800,
|
||||
images: ['/wear/palto1.jpg', '/wear/palto2.jpg'],
|
||||
description: 'Элегантное пальто оверсайз высокого качества. Изготовлено из премиальных материалов, обеспечивающих комфорт и тепло. Идеально подходит для холодного сезона и создания стильного образа.',
|
||||
isNew: true,
|
||||
categoryId: 6,
|
||||
collectionId: 2, // Осень-Зима 2024
|
||||
slug: 'palto-oversaiz',
|
||||
inStock: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Костюм хлопок',
|
||||
price: 12800,
|
||||
images: ['/wear/pidzak2.jpg', '/wear/pidzak1.jpg'],
|
||||
description: 'Стильный костюм для особых случаев. Выполнен из высококачественного хлопка, обеспечивающего комфорт и элегантный внешний вид. Идеально подходит для деловых встреч и официальных мероприятий.',
|
||||
isNew: true,
|
||||
categoryId: 6,
|
||||
collectionId: 5, // Деловой стиль
|
||||
slug: 'kostyum-hlopok',
|
||||
inStock: true
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Блузка',
|
||||
price: 3500,
|
||||
images: ['/wear/sorochka1.jpg', '/wear/sorochka2.jpg'],
|
||||
description: 'Классическая блузка в коричневом цвете. Изготовлена из мягкой и приятной к телу ткани. Универсальная модель, которая подойдет как для офиса, так и для повседневного образа.',
|
||||
isNew: true,
|
||||
categoryId: 7,
|
||||
collectionId: 1, // Весна-Лето 2025
|
||||
slug: 'bluzka',
|
||||
inStock: true
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Платье со сборкой',
|
||||
price: 28800,
|
||||
images: ['/wear/jumpsuit_1.jpg', '/wear/jumpsuit_2.jpg'],
|
||||
description: 'Элегантное платье высокого качества со сборкой. Подчеркивает фигуру и создает женственный силуэт. Идеально подходит для особых случаев и вечерних мероприятий.',
|
||||
isNew: true,
|
||||
categoryId: 5,
|
||||
collectionId: 4, // Вечерняя коллекция
|
||||
slug: 'plate-so-sborkoy',
|
||||
inStock: true
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Кожаные ботинки',
|
||||
price: 15600,
|
||||
images: ['/wear/kozh_boots1.jpg', '/wear/kozh_boots2.jpg'],
|
||||
description: 'Элегантные кожаные ботинки ручной работы. Изготовлены из натуральной кожи высшего качества. Комфортная колодка и стильный дизайн делают эту модель незаменимой в гардеробе.',
|
||||
isNew: true,
|
||||
categoryId: 1,
|
||||
collectionId: 3, // Базовый гардероб
|
||||
slug: 'kozhanye-botinki',
|
||||
inStock: true
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Шелковый шарф',
|
||||
price: 5900,
|
||||
images: ['/wear/silk_scarf1.jpg', '/wear/silk_scarf2.jpg'],
|
||||
description: 'Роскошный шелковый шарф с уникальным принтом. Изготовлен из 100% натурального шелка. Добавит элегантности и шарма любому образу.',
|
||||
isNew: false,
|
||||
categoryId: 8,
|
||||
// Нет коллекции
|
||||
slug: 'shelkovyj-sharf',
|
||||
inStock: true
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Шерстяной свитер',
|
||||
price: 8700,
|
||||
images: ['/wear/sherst_sweater1.jpg', '/wear/sherst_sweater2.jpg'],
|
||||
description: 'Теплый шерстяной свитер крупной вязки. Изготовлен из мягкой шерсти мериноса. Идеально подходит для холодного времени года.',
|
||||
isNew: false,
|
||||
categoryId: 4,
|
||||
collectionId: 2, // Осень-Зима 2024
|
||||
slug: 'sherstyanoj-sviter',
|
||||
inStock: true
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Классические брюки',
|
||||
price: 7500,
|
||||
images: ['/wear/classic_bruk1.jpg', '/wear/classic_bruk2.jpg'],
|
||||
description: 'Классические брюки прямого кроя. Выполнены из высококачественной ткани с добавлением эластана для комфортной посадки. Универсальная модель для офиса и повседневной носки.',
|
||||
isNew: false,
|
||||
categoryId: 3,
|
||||
collectionId: 5, // Деловой стиль
|
||||
slug: 'klassicheskie-bryuki',
|
||||
inStock: true
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: 'Фетровая шляпа',
|
||||
price: 6200,
|
||||
images: ['/wear/hat1.jpg'],
|
||||
description: 'Элегантная фетровая шляпа ручной работы. Изготовлена из высококачественного фетра. Дополнит любой образ и защитит от непогоды.',
|
||||
isNew: false,
|
||||
categoryId: 2,
|
||||
// Нет коллекции
|
||||
slug: 'fetrovaya-shlyapa',
|
||||
inStock: true
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: 'Шелковая блузка',
|
||||
price: 9800,
|
||||
images: ['/wear/silk1.jpg', '/wear/silk2.jpg'],
|
||||
description: 'Роскошная шелковая блузка с элегантным дизайном. Изготовлена из 100% натурального шелка. Идеально подходит для создания изысканного образа.',
|
||||
isNew: true,
|
||||
categoryId: 7,
|
||||
collectionId: 1, // Весна-Лето 2025
|
||||
slug: 'shelkovaya-bluzka',
|
||||
inStock: true
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: 'Кожаная сумка',
|
||||
price: 18500,
|
||||
images: ['/wear/bag1.jpg', '/wear/bag2.jpg'],
|
||||
description: 'Стильная кожаная сумка ручной работы. Изготовлена из натуральной кожи высшего качества. Вместительная и функциональная модель для повседневного использования.',
|
||||
isNew: false,
|
||||
categoryId: 8,
|
||||
collectionId: 3, // Базовый гардероб
|
||||
slug: 'kozhanaya-sumka',
|
||||
inStock: true
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: 'Кашемировое пальто',
|
||||
price: 52000,
|
||||
images: ['/wear/coat1.jpg', '/wear/coat2.jpg'],
|
||||
description: 'Роскошное кашемировое пальто классического кроя. Изготовлено из 100% кашемира высшего качества. Элегантная модель, которая прослужит долгие годы.',
|
||||
isNew: true,
|
||||
categoryId: 6,
|
||||
collectionId: 2, // Осень-Зима 2024
|
||||
slug: 'kashemirovoe-palto',
|
||||
inStock: true
|
||||
}
|
||||
];
|
||||
|
||||
// Функция для получения товаров по категории
|
||||
export const getProductsByCategory = (categoryId: number): Product[] => {
|
||||
return products.filter(product => product.categoryId === categoryId);
|
||||
};
|
||||
|
||||
// Функция для получения новых товаров
|
||||
export const getNewProducts = (): Product[] => {
|
||||
return products.filter(product => product.isNew);
|
||||
};
|
||||
|
||||
// Функция для получения товара по slug
|
||||
export const getProductBySlug = (slug: string): Product | undefined => {
|
||||
return products.find(product => product.slug === slug);
|
||||
};
|
||||
|
||||
// Функция для получения товара по id
|
||||
export const getProductById = (id: number): Product | undefined => {
|
||||
return products.find(product => product.id === id);
|
||||
};
|
||||
|
||||
// Функция для получения похожих товаров
|
||||
export const getSimilarProducts = (productId: number, limit: number = 4): Product[] => {
|
||||
const product = getProductById(productId);
|
||||
if (!product) return [];
|
||||
|
||||
return products
|
||||
.filter(p => p.id !== productId && p.categoryId === product.categoryId)
|
||||
.slice(0, limit);
|
||||
};
|
||||
|
||||
// Функция для форматирования цены
|
||||
export const formatPrice = (price: number): string => {
|
||||
return new Intl.NumberFormat('ru-RU').format(price);
|
||||
};
|
||||
|
||||
// Функция для получения товаров по коллекции
|
||||
export const getProductsByCollection = (collectionId: number): Product[] => {
|
||||
return products.filter(product => product.collectionId === collectionId);
|
||||
};
|
||||
@ -1,8 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
output: "standalone",
|
||||
images: {
|
||||
domains: [],
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
1773
frontend old/package-lock.json
generated
@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "brand-store",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"axios": "^1.8.1",
|
||||
"framer-motion": "^10.16.4",
|
||||
"lucide-react": "^0.476.0",
|
||||
"next": "^13.4.19",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.5.9",
|
||||
"@types/react": "^18.2.21",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"postcss": "^8.4.20",
|
||||
"tailwindcss": "^3.2.4",
|
||||
"typescript": "^5.2.2"
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import type { AppProps } from 'next/app';
|
||||
import '../styles/globals.css';
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />;
|
||||
}
|
||||
@ -1,447 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { User, Package, Heart, LogOut, MapPin, Plus, Edit, Trash, Check, Home } from 'lucide-react';
|
||||
import authService from '../../services/auth';
|
||||
import { userService, Address } from '../../services/users';
|
||||
|
||||
export default function AddressesPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||
const [user, setUser] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editingAddressId, setEditingAddressId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
type: 'shipping',
|
||||
address: '',
|
||||
city: '',
|
||||
postal_code: '',
|
||||
country: 'Россия',
|
||||
is_default: false
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
router.push('/login?redirect=/account/addresses');
|
||||
return;
|
||||
}
|
||||
|
||||
// Загружаем данные пользователя и адреса
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const userData = await userService.getCurrentUser();
|
||||
setUser(userData);
|
||||
|
||||
// Если у пользователя есть адреса, загружаем их
|
||||
if (userData.addresses) {
|
||||
setAddresses(userData.addresses);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных:', err);
|
||||
setError('Не удалось загрузить данные');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [router]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
type: 'shipping',
|
||||
address: '',
|
||||
city: '',
|
||||
postal_code: '',
|
||||
country: 'Россия',
|
||||
is_default: false
|
||||
});
|
||||
setShowAddForm(false);
|
||||
setEditingAddressId(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
if (editingAddressId) {
|
||||
// Обновляем существующий адрес
|
||||
await userService.updateAddress(editingAddressId, formData);
|
||||
setSuccess('Адрес успешно обновлен');
|
||||
} else {
|
||||
// Добавляем новый адрес
|
||||
await userService.addAddress(formData);
|
||||
setSuccess('Адрес успешно добавлен');
|
||||
}
|
||||
|
||||
// Обновляем список адресов
|
||||
const userData = await userService.getCurrentUser();
|
||||
if (userData.addresses) {
|
||||
setAddresses(userData.addresses);
|
||||
}
|
||||
|
||||
// Сбрасываем форму
|
||||
resetForm();
|
||||
} catch (err) {
|
||||
console.error('Ошибка при сохранении адреса:', err);
|
||||
setError('Не удалось сохранить адрес');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (address: Address) => {
|
||||
setFormData({
|
||||
type: address.type,
|
||||
address: address.address,
|
||||
city: address.city,
|
||||
postal_code: address.postal_code,
|
||||
country: address.country,
|
||||
is_default: address.is_default
|
||||
});
|
||||
setEditingAddressId(address.id);
|
||||
setShowAddForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (addressId: number) => {
|
||||
if (!confirm('Вы уверены, что хотите удалить этот адрес?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
await userService.deleteAddress(addressId);
|
||||
setSuccess('Адрес успешно удален');
|
||||
|
||||
// Обновляем список адресов
|
||||
const userData = await userService.getCurrentUser();
|
||||
if (userData.addresses) {
|
||||
setAddresses(userData.addresses);
|
||||
} else {
|
||||
setAddresses([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении адреса:', err);
|
||||
setError('Не удалось удалить адрес');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (addressId: number) => {
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
await userService.updateAddress(addressId, { is_default: true });
|
||||
setSuccess('Адрес по умолчанию изменен');
|
||||
|
||||
// Обновляем список адресов
|
||||
const userData = await userService.getCurrentUser();
|
||||
if (userData.addresses) {
|
||||
setAddresses(userData.addresses);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при установке адреса по умолчанию:', err);
|
||||
setError('Не удалось установить адрес по умолчанию');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-8">Личный кабинет</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-6 bg-green-50 border-l-4 border-green-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-green-700">{success}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Боковая панель навигации */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Link href="/account" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<User className="h-5 w-5" />
|
||||
<span>Профиль</span>
|
||||
</Link>
|
||||
<Link href="/account/orders" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Package className="h-5 w-5" />
|
||||
<span>Мои заказы</span>
|
||||
</Link>
|
||||
<Link href="/account/addresses" className="flex items-center space-x-2 text-indigo-600 font-medium">
|
||||
<MapPin className="h-5 w-5" />
|
||||
<span>Мои адреса</span>
|
||||
</Link>
|
||||
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Heart className="h-5 w-5" />
|
||||
<span>Избранное</span>
|
||||
</Link>
|
||||
<Link href="/" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Home className="h-5 w-5" />
|
||||
<span>На главную</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span>Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основное содержимое */}
|
||||
<div className="md:col-span-3 space-y-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Мои адреса</h2>
|
||||
<p className="text-gray-600 mt-1">Управление адресами доставки</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="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"
|
||||
>
|
||||
{showAddForm ? 'Отменить' : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Добавить адрес
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div className="mb-8 border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium mb-4">
|
||||
{editingAddressId ? 'Редактирование адреса' : 'Добавление нового адреса'}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
|
||||
Тип адреса
|
||||
</label>
|
||||
<select
|
||||
id="type"
|
||||
name="type"
|
||||
value={formData.type}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
|
||||
>
|
||||
<option value="shipping">Адрес доставки</option>
|
||||
<option value="billing">Адрес для выставления счета</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
|
||||
Адрес
|
||||
</label>
|
||||
<textarea
|
||||
id="address"
|
||||
name="address"
|
||||
rows={3}
|
||||
value={formData.address}
|
||||
onChange={handleChange}
|
||||
required
|
||||
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"
|
||||
placeholder="Улица, дом, квартира"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="city" className="block text-sm font-medium text-gray-700">
|
||||
Город
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="city"
|
||||
name="city"
|
||||
value={formData.city}
|
||||
onChange={handleChange}
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="postal_code" className="block text-sm font-medium text-gray-700">
|
||||
Почтовый индекс
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
value={formData.postal_code}
|
||||
onChange={handleChange}
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="country" className="block text-sm font-medium text-gray-700">
|
||||
Страна
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="country"
|
||||
name="country"
|
||||
value={formData.country}
|
||||
onChange={handleChange}
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="is_default"
|
||||
name="is_default"
|
||||
type="checkbox"
|
||||
checked={formData.is_default}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="is_default" className="ml-2 block text-sm text-gray-900">
|
||||
Использовать как адрес по умолчанию
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="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"
|
||||
>
|
||||
{editingAddressId ? 'Сохранить изменения' : 'Добавить адрес'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addresses.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<MapPin className="h-12 w-12 mx-auto text-gray-400" />
|
||||
<p className="mt-2 text-gray-500">У вас пока нет сохраненных адресов</p>
|
||||
{!showAddForm && (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
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"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Добавить адрес
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{addresses.map((address) => (
|
||||
<div key={address.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<h3 className="text-lg font-medium">
|
||||
{address.type === 'shipping' ? 'Адрес доставки' : 'Адрес для выставления счета'}
|
||||
</h3>
|
||||
{address.is_default && (
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
По умолчанию
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 mt-1">{address.address}</p>
|
||||
<p className="text-gray-600">{address.city}, {address.postal_code}</p>
|
||||
<p className="text-gray-600">{address.country}</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
{!address.is_default && (
|
||||
<button
|
||||
onClick={() => handleSetDefault(address.id)}
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
title="Сделать адресом по умолчанию"
|
||||
>
|
||||
<Check className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleEdit(address)}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(address.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,226 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { User, Package, Heart, LogOut, Lock, AlertCircle, CheckCircle, MapPin, Home } from 'lucide-react';
|
||||
import authService from '../../services/auth';
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState({
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
router.push('/login?redirect=/account/change-password');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
// Проверяем совпадение паролей
|
||||
if (formData.new_password !== formData.confirm_password) {
|
||||
setError('Новый пароль и подтверждение не совпадают');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Вызываем метод изменения пароля
|
||||
await authService.changePassword(
|
||||
formData.current_password,
|
||||
formData.new_password
|
||||
);
|
||||
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при изменении пароля:', err);
|
||||
setError('Не удалось изменить пароль. Проверьте правильность текущего пароля.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-8">Личный кабинет</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Боковая панель навигации */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Link href="/account" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<User className="h-5 w-5" />
|
||||
<span>Профиль</span>
|
||||
</Link>
|
||||
<Link href="/account/orders" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Package className="h-5 w-5" />
|
||||
<span>Мои заказы</span>
|
||||
</Link>
|
||||
<Link href="/account/addresses" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<MapPin className="h-5 w-5" />
|
||||
<span>Мои адреса</span>
|
||||
</Link>
|
||||
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Heart className="h-5 w-5" />
|
||||
<span>Избранное</span>
|
||||
</Link>
|
||||
<Link href="/" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Home className="h-5 w-5" />
|
||||
<span>На главную</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span>Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основное содержимое */}
|
||||
<div className="md:col-span-3 bg-white rounded-lg shadow p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold">Изменение пароля</h2>
|
||||
<p className="text-gray-600 mt-1">Обновите свой пароль для повышения безопасности аккаунта</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success ? (
|
||||
<div className="bg-green-50 border-l-4 border-green-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-green-700">Пароль успешно изменен!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="current_password" className="block text-sm font-medium text-gray-700">
|
||||
Текущий пароль
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="current_password"
|
||||
name="current_password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={formData.current_password}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="new_password" className="block text-sm font-medium text-gray-700">
|
||||
Новый пароль
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="new_password"
|
||||
name="new_password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.new_password}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">Минимум 8 символов</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirm_password" className="block text-sm font-medium text-gray-700">
|
||||
Подтверждение нового пароля
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.confirm_password}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/account" className="text-sm font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Вернуться в профиль
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Сохранение...' : 'Изменить пароль'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,253 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { User, Package, Heart, LogOut, Save, X, MapPin, Home } from 'lucide-react';
|
||||
import authService from '../../services/auth';
|
||||
import { userService, UserUpdate } from '../../services/users';
|
||||
|
||||
export default function EditProfilePage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [user, setUser] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: ''
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
router.push('/login?redirect=/account/edit');
|
||||
return;
|
||||
}
|
||||
|
||||
// Загружаем данные пользователя
|
||||
const fetchUserData = async () => {
|
||||
try {
|
||||
const userData = await userService.getCurrentUser();
|
||||
setUser(userData);
|
||||
setFormData({
|
||||
first_name: userData.first_name || '',
|
||||
last_name: userData.last_name || '',
|
||||
email: userData.email || '',
|
||||
phone: userData.phone || ''
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных пользователя:', err);
|
||||
setError('Не удалось загрузить данные пользователя');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserData();
|
||||
}, [router]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
const updateData: UserUpdate = {
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
phone: formData.phone
|
||||
};
|
||||
|
||||
// Email обновляем только если он изменился
|
||||
if (formData.email !== user.email) {
|
||||
updateData.email = formData.email;
|
||||
}
|
||||
|
||||
await userService.updateCurrentUser(updateData);
|
||||
setSuccess(true);
|
||||
|
||||
// Обновляем данные пользователя
|
||||
const updatedUser = await userService.getCurrentUser();
|
||||
setUser(updatedUser);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при обновлении профиля:', err);
|
||||
setError('Не удалось обновить профиль. Пожалуйста, проверьте введенные данные.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-8">Личный кабинет</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Боковая панель навигации */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Link href="/account" className="flex items-center space-x-2 text-indigo-600 font-medium">
|
||||
<User className="h-5 w-5" />
|
||||
<span>Профиль</span>
|
||||
</Link>
|
||||
<Link href="/account/orders" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Package className="h-5 w-5" />
|
||||
<span>Мои заказы</span>
|
||||
</Link>
|
||||
<Link href="/account/addresses" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<MapPin className="h-5 w-5" />
|
||||
<span>Мои адреса</span>
|
||||
</Link>
|
||||
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Heart className="h-5 w-5" />
|
||||
<span>Избранное</span>
|
||||
</Link>
|
||||
<Link href="/" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Home className="h-5 w-5" />
|
||||
<span>На главную</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span>Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основное содержимое */}
|
||||
<div className="md:col-span-3 bg-white rounded-lg shadow p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold">Редактирование профиля</h2>
|
||||
<p className="text-gray-600 mt-1">Обновите свои персональные данные</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-6 bg-green-50 border-l-4 border-green-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-green-700">Профиль успешно обновлен!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700">
|
||||
Имя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="last_name" className="block text-sm font-medium text-gray-700">
|
||||
Фамилия
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
|
||||
Телефон
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="+7 (___) ___-__-__"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/account" className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
<X className="h-5 w-5 mr-2" />
|
||||
Отмена
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save className="h-5 w-5 mr-2" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить изменения'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,149 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { User, Package, Heart, LogOut, Edit, MapPin, Home } from 'lucide-react';
|
||||
import authService from '../../services/auth';
|
||||
import { userService } from '../../services/users';
|
||||
|
||||
export default function AccountPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
router.push('/login?redirect=/account');
|
||||
return;
|
||||
}
|
||||
|
||||
// Загружаем данные пользователя
|
||||
const fetchUserData = async () => {
|
||||
try {
|
||||
const userData = await userService.getCurrentUser();
|
||||
setUser(userData);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных пользователя:', err);
|
||||
setError('Не удалось загрузить данные пользователя');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserData();
|
||||
}, [router]);
|
||||
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-8">Личный кабинет</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Боковая панель навигации */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Link href="/account" className="flex items-center space-x-2 text-indigo-600 font-medium">
|
||||
<User className="h-5 w-5" />
|
||||
<span>Профиль</span>
|
||||
</Link>
|
||||
<Link href="/account/orders" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Package className="h-5 w-5" />
|
||||
<span>Мои заказы</span>
|
||||
</Link>
|
||||
<Link href="/account/addresses" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<MapPin className="h-5 w-5" />
|
||||
<span>Мои адреса</span>
|
||||
</Link>
|
||||
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Heart className="h-5 w-5" />
|
||||
<span>Избранное</span>
|
||||
</Link>
|
||||
<Link href="/" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Home className="h-5 w-5" />
|
||||
<span>На главную</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span>Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основное содержимое */}
|
||||
<div className="md:col-span-3 bg-white rounded-lg shadow p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold">Личные данные</h2>
|
||||
<Link href="/account/edit" className="flex items-center text-sm text-indigo-600 hover:text-indigo-800">
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Редактировать
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-500 mb-1">Имя</p>
|
||||
<p className="font-medium">{user.first_name}</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-500 mb-1">Фамилия</p>
|
||||
<p className="font-medium">{user.last_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-500 mb-1">Email</p>
|
||||
<p className="font-medium">{user.email}</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-500 mb-1">Телефон</p>
|
||||
<p className="font-medium">{user.phone || 'Не указан'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 border-t pt-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Безопасность</h3>
|
||||
<Link href="/account/change-password" className="inline-flex items-center px-4 py-2 border border-indigo-600 text-sm font-medium rounded-md text-indigo-600 bg-white hover:bg-indigo-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Изменить пароль
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,251 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { User, Package, Heart, LogOut, ExternalLink, Clock, CheckCircle, XCircle, Home, MapPin } from 'lucide-react';
|
||||
import authService from '../../services/auth';
|
||||
import { orderService } from '../../services/orders';
|
||||
|
||||
// Вспомогательная функция для форматирования даты
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// Функция для получения статуса заказа
|
||||
const getOrderStatusInfo = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return { label: 'Ожидает оплаты', color: 'text-yellow-600', icon: <Clock className="h-5 w-5" /> };
|
||||
case 'processing':
|
||||
return { label: 'В обработке', color: 'text-blue-600', icon: <Clock className="h-5 w-5" /> };
|
||||
case 'shipped':
|
||||
return { label: 'Отправлен', color: 'text-indigo-600', icon: <Package className="h-5 w-5" /> };
|
||||
case 'delivered':
|
||||
return { label: 'Доставлен', color: 'text-green-600', icon: <CheckCircle className="h-5 w-5" /> };
|
||||
case 'cancelled':
|
||||
return { label: 'Отменен', color: 'text-red-600', icon: <XCircle className="h-5 w-5" /> };
|
||||
default:
|
||||
return { label: 'Неизвестно', color: 'text-gray-600', icon: <Clock className="h-5 w-5" /> };
|
||||
}
|
||||
};
|
||||
|
||||
export default function OrdersPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
router.push('/login?redirect=/account/orders');
|
||||
return;
|
||||
}
|
||||
|
||||
// Загружаем заказы пользователя
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
const ordersData = await orderService.getUserOrders();
|
||||
console.log('Полученные заказы:', ordersData);
|
||||
|
||||
if (Array.isArray(ordersData)) {
|
||||
setOrders(ordersData);
|
||||
} else {
|
||||
console.error('Ошибка: полученные данные не являются массивом:', ordersData);
|
||||
setError('Не удалось загрузить заказы: неверный формат данных');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке заказов:', err);
|
||||
setError('Не удалось загрузить заказы: ' + (err.message || 'неизвестная ошибка'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchOrders();
|
||||
}, [router]);
|
||||
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-8">Личный кабинет</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Боковая панель навигации */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Link href="/account" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<User className="h-5 w-5" />
|
||||
<span>Профиль</span>
|
||||
</Link>
|
||||
<Link href="/account/orders" className="flex items-center space-x-2 text-indigo-600 font-medium">
|
||||
<Package className="h-5 w-5" />
|
||||
<span>Мои заказы</span>
|
||||
</Link>
|
||||
<Link href="/account/addresses" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<MapPin className="h-5 w-5" />
|
||||
<span>Мои адреса</span>
|
||||
</Link>
|
||||
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Heart className="h-5 w-5" />
|
||||
<span>Избранное</span>
|
||||
</Link>
|
||||
<Link href="/" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Home className="h-5 w-5" />
|
||||
<span>На главную</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span>Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основное содержимое */}
|
||||
<div className="md:col-span-3 bg-white rounded-lg shadow p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold">Мои заказы</h2>
|
||||
<p className="text-gray-600 mt-1">История ваших заказов</p>
|
||||
</div>
|
||||
|
||||
{orders.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Package className="h-12 w-12 mx-auto text-gray-400" />
|
||||
<p className="mt-2 text-gray-500">У вас пока нет заказов</p>
|
||||
<Link href="/" 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">
|
||||
Перейти к покупкам
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{orders.map((order) => {
|
||||
const statusInfo = getOrderStatusInfo(order.status || 'pending');
|
||||
const orderId = order.id || 'unknown';
|
||||
|
||||
return (
|
||||
<div key={orderId} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200 flex justify-between items-center">
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">Заказ №</span>
|
||||
<span className="font-medium ml-1">{orderId}</span>
|
||||
<span className="text-sm text-gray-500 ml-4">от</span>
|
||||
<span className="ml-1">
|
||||
{order.created_at ? formatDate(order.created_at) : 'Дата не указана'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`flex items-center ${statusInfo.color}`}>
|
||||
{statusInfo.icon}
|
||||
<span className="ml-1 text-sm font-medium">{statusInfo.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="space-y-3">
|
||||
{order.items && Array.isArray(order.items) ? (
|
||||
order.items.map((item, index) => (
|
||||
<div key={item.id || `item-${index}`} className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="w-16 h-16 flex-shrink-0 bg-gray-100 rounded-md overflow-hidden">
|
||||
{item.product && item.product.image ? (
|
||||
<img
|
||||
src={item.product.image}
|
||||
alt={item.product.name || 'Товар'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-200">
|
||||
<Package className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h3 className="text-sm font-medium">{item.product ? item.product.name : 'Товар'}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{item.variant_name && `Вариант: ${item.variant_name}`}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Количество: {item.quantity}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">
|
||||
{typeof item.price === 'number'
|
||||
? `${item.price.toLocaleString('ru-RU')} ₽`
|
||||
: 'Цена не указана'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-3">
|
||||
<p className="text-gray-500">Информация о товарах недоступна</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-gray-200 pt-4 flex justify-between items-center">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Итого:</p>
|
||||
<p className="text-lg font-bold">
|
||||
{typeof order.total_amount === 'number'
|
||||
? `${order.total_amount.toLocaleString('ru-RU')} ₽`
|
||||
: 'Сумма не указана'}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/account/orders/${orderId}`}
|
||||
className="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Подробнее
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,285 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { User, Package, Heart, LogOut, Home, MapPin, ArrowLeft, ExternalLink, Clock, CheckCircle, XCircle, Truck, CreditCard } from 'lucide-react';
|
||||
import authService from '../../../services/auth';
|
||||
import { orderService, Order, Address } from '../../../services/orders';
|
||||
|
||||
// Вспомогательная функция для форматирования даты
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// Функция для получения статуса заказа
|
||||
const getOrderStatusInfo = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return { label: 'Ожидает оплаты', color: 'text-yellow-600 bg-yellow-50', icon: <Clock className="h-5 w-5" /> };
|
||||
case 'processing':
|
||||
return { label: 'В обработке', color: 'text-blue-600 bg-blue-50', icon: <Clock className="h-5 w-5" /> };
|
||||
case 'shipped':
|
||||
return { label: 'Отправлен', color: 'text-indigo-600 bg-indigo-50', icon: <Truck className="h-5 w-5" /> };
|
||||
case 'delivered':
|
||||
return { label: 'Доставлен', color: 'text-green-600 bg-green-50', icon: <CheckCircle className="h-5 w-5" /> };
|
||||
case 'cancelled':
|
||||
return { label: 'Отменен', color: 'text-red-600 bg-red-50', icon: <XCircle className="h-5 w-5" /> };
|
||||
default:
|
||||
return { label: 'Неизвестно', color: 'text-gray-600 bg-gray-50', icon: <Clock className="h-5 w-5" /> };
|
||||
}
|
||||
};
|
||||
|
||||
export default function OrderDetailsPage() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
router.push('/login?redirect=' + router.asPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Загружаем данные заказа
|
||||
const fetchOrderDetails = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const orderData = await orderService.getOrderById(Number(id));
|
||||
setOrder(orderData);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных заказа:', err);
|
||||
setError('Не удалось загрузить данные заказа');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (id) {
|
||||
fetchOrderDetails();
|
||||
}
|
||||
}, [router, id]);
|
||||
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
const handleCancelOrder = async () => {
|
||||
if (!order) return;
|
||||
|
||||
try {
|
||||
await orderService.cancelOrder(order.id);
|
||||
// Обновляем данные заказа после отмены
|
||||
const updatedOrder = await orderService.getOrderById(order.id);
|
||||
setOrder(updatedOrder);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при отмене заказа:', err);
|
||||
setError('Не удалось отменить заказ');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">Заказ не найден</h1>
|
||||
<p className="text-gray-600 mb-4">Запрашиваемый заказ не существует или у вас нет к нему доступа.</p>
|
||||
<Link href="/account/orders" className="inline-flex items-center text-indigo-600 hover:text-indigo-500">
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Вернуться к списку заказов
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusInfo = getOrderStatusInfo(order.status);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<Link href="/account/orders" className="inline-flex items-center text-indigo-600 hover:text-indigo-500">
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Вернуться к списку заказов
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold mb-6">Заказ №{order.id}</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Боковая панель навигации */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Link href="/account" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<User className="h-5 w-5" />
|
||||
<span>Профиль</span>
|
||||
</Link>
|
||||
<Link href="/account/orders" className="flex items-center space-x-2 text-indigo-600 font-medium">
|
||||
<Package className="h-5 w-5" />
|
||||
<span>Мои заказы</span>
|
||||
</Link>
|
||||
<Link href="/account/addresses" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<MapPin className="h-5 w-5" />
|
||||
<span>Мои адреса</span>
|
||||
</Link>
|
||||
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Heart className="h-5 w-5" />
|
||||
<span>Избранное</span>
|
||||
</Link>
|
||||
<Link href="/" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Home className="h-5 w-5" />
|
||||
<span>На главную</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span>Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основное содержимое */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
{/* Информация о заказе */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Информация о заказе</h2>
|
||||
<p className="text-gray-500 mt-1">Создан: {formatDate(order.created_at)}</p>
|
||||
</div>
|
||||
<div className={`px-3 py-1 rounded-full ${statusInfo.color} flex items-center`}>
|
||||
{statusInfo.icon}
|
||||
<span className="ml-1 text-sm font-medium">{statusInfo.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="border border-gray-200 rounded-md p-4">
|
||||
<div className="flex items-center mb-2">
|
||||
<MapPin className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<h3 className="font-medium">Адрес доставки</h3>
|
||||
</div>
|
||||
{typeof order.shipping_address === 'string' ? (
|
||||
<p className="text-gray-600 whitespace-pre-line">{order.shipping_address}</p>
|
||||
) : (
|
||||
<p className="text-gray-600 whitespace-pre-line">
|
||||
{order.shipping_address && typeof order.shipping_address === 'object' ? (
|
||||
<>
|
||||
{(order.shipping_address as Address).address_line1}<br />
|
||||
{(order.shipping_address as Address).address_line2 && <>{(order.shipping_address as Address).address_line2}<br /></>}
|
||||
{(order.shipping_address as Address).city}, {(order.shipping_address as Address).state}, {(order.shipping_address as Address).postal_code}<br />
|
||||
{(order.shipping_address as Address).country}
|
||||
</>
|
||||
) : (
|
||||
'Адрес не указан'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-md p-4">
|
||||
<div className="flex items-center mb-2">
|
||||
<CreditCard className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<h3 className="font-medium">Способ оплаты</h3>
|
||||
</div>
|
||||
<p className="text-gray-600">{order.payment_method}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{order.status === 'pending' && (
|
||||
<button
|
||||
onClick={handleCancelOrder}
|
||||
className="w-full md:w-auto px-4 py-2 border border-red-300 text-red-700 rounded-md hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
Отменить заказ
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Товары в заказе */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-xl font-semibold">Товары в заказе</h2>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-200">
|
||||
{order.items && order.items.map((item) => (
|
||||
<div key={item.id || `item-${Math.random()}`} className="p-6 flex items-start">
|
||||
<div className="w-20 h-20 flex-shrink-0 bg-gray-100 rounded-md overflow-hidden">
|
||||
{item.product && item.product.image ? (
|
||||
<img
|
||||
src={item.product.image}
|
||||
alt={item.product.name || 'Товар'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-200">
|
||||
<Package className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4 flex-1">
|
||||
<h3 className="text-lg font-medium">{item.product ? item.product.name : 'Товар'}</h3>
|
||||
{item.variant_name && (
|
||||
<p className="text-gray-500">Вариант: {item.variant_name}</p>
|
||||
)}
|
||||
<div className="mt-1 flex justify-between">
|
||||
<p className="text-gray-500">Количество: {item.quantity || 1}</p>
|
||||
<p className="font-medium">{(item.price || 0).toLocaleString('ru-RU')} ₽</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">Итого:</span>
|
||||
<span className="text-xl font-bold">{(order.total_amount || 0).toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,309 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Save, X, ArrowLeft } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import { categoryService, Category } from '../../../services/catalog';
|
||||
|
||||
export default function EditCategoryPage() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [category, setCategory] = useState<Category | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
parent_id: null as number | null,
|
||||
is_active: true
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Загрузка категории и списка категорий при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Загружаем список всех категорий для выбора родительской
|
||||
const categoriesData = await categoryService.getCategories();
|
||||
setCategories(categoriesData);
|
||||
|
||||
// Находим текущую категорию по ID
|
||||
const currentCategory = categoriesData.find(cat => cat.id === Number(id));
|
||||
|
||||
if (currentCategory) {
|
||||
setCategory(currentCategory);
|
||||
setFormData({
|
||||
name: currentCategory.name,
|
||||
slug: currentCategory.slug,
|
||||
parent_id: currentCategory.parent_id,
|
||||
is_active: currentCategory.is_active
|
||||
});
|
||||
} else {
|
||||
setError('Категория не найдена');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных:', err);
|
||||
setError('Не удалось загрузить данные категории. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [id]);
|
||||
|
||||
// Автоматическое создание slug из названия
|
||||
const generateSlug = (name: string) => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\sа-яё-]/g, '') // Удаляем специальные символы, но оставляем кириллицу
|
||||
.replace(/\s+/g, '-') // Заменяем пробелы на дефисы
|
||||
.replace(/[а-яё]/g, char => { // Транслитерация кириллицы
|
||||
const translitMap = {
|
||||
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
|
||||
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
|
||||
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
|
||||
'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch', 'ъ': '',
|
||||
'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya'
|
||||
};
|
||||
return translitMap[char] || char;
|
||||
})
|
||||
.replace(/--+/g, '-') // Заменяем множественные дефисы на один
|
||||
.replace(/^-|-$/g, ''); // Удаляем дефисы в начале и конце
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
|
||||
// Обновляем форму
|
||||
setFormData(prev => {
|
||||
const newData = {
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
};
|
||||
|
||||
// Если изменилось название, автоматически обновляем slug
|
||||
if (name === 'name') {
|
||||
newData.slug = generateSlug(value);
|
||||
}
|
||||
|
||||
// Если выбрана родительская категория, преобразуем строку в число или null
|
||||
if (name === 'parent_id') {
|
||||
newData.parent_id = value === '' ? null : Number(value);
|
||||
}
|
||||
|
||||
return newData;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (!category) return;
|
||||
|
||||
// Проверяем, не выбрана ли текущая категория в качестве родительской
|
||||
if (formData.parent_id === category.id) {
|
||||
setError('Категория не может быть родительской для самой себя');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем, не выбран ли потомок в качестве родительской категории
|
||||
const isDescendant = (parentId: number | null, childId: number): boolean => {
|
||||
if (parentId === childId) return true;
|
||||
|
||||
const children = categories.filter(cat => cat.parent_id === childId);
|
||||
return children.some(child => isDescendant(parentId, child.id));
|
||||
};
|
||||
|
||||
if (formData.parent_id !== null && isDescendant(formData.parent_id, category.id)) {
|
||||
setError('Нельзя выбрать потомка в качестве родительской категории');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Обновляем категорию через API
|
||||
await categoryService.updateCategory(category.id, formData);
|
||||
|
||||
// Перенаправляем на страницу категорий
|
||||
router.push('/admin/categories');
|
||||
} catch (err) {
|
||||
console.error('Ошибка при обновлении категории:', err);
|
||||
setError('Не удалось обновить категорию. Пожалуйста, проверьте введенные данные и попробуйте снова.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout title="Редактирование категории">
|
||||
<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>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!category && !loading) {
|
||||
return (
|
||||
<AdminLayout title="Категория не найдена">
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">Категория не найдена</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/admin/categories')}
|
||||
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"
|
||||
>
|
||||
Вернуться к списку категорий
|
||||
</button>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title={`Редактирование: ${category?.name}`}>
|
||||
<div className="mb-6 flex items-center">
|
||||
<button
|
||||
onClick={() => router.push('/admin/categories')}
|
||||
className="mr-4 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<h2 className="text-xl font-semibold text-gray-800">Редактирование категории</h2>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Название категории *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="slug" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Slug *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
name="slug"
|
||||
required
|
||||
value={formData.slug}
|
||||
onChange={handleChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
URL-совместимый идентификатор категории. Генерируется автоматически из названия.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="parent_id" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Родительская категория
|
||||
</label>
|
||||
<select
|
||||
id="parent_id"
|
||||
name="parent_id"
|
||||
value={formData.parent_id === null ? '' : formData.parent_id}
|
||||
onChange={handleChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Нет (корневая категория)</option>
|
||||
{categories
|
||||
.filter(cat => cat.id !== category?.id) // Исключаем текущую категорию
|
||||
.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_active"
|
||||
name="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="is_active" className="ml-2 block text-sm text-gray-700">
|
||||
Активна
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Неактивные категории не будут отображаться на сайте.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/admin/categories')}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 mr-3"
|
||||
>
|
||||
<X className="h-5 w-5 mr-2" />
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
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"
|
||||
>
|
||||
<Save className="h-5 w-5 mr-2" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@ -1,236 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Save, X, ArrowLeft } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import { categoryService, Category } from '../../../services/catalog';
|
||||
|
||||
export default function CreateCategoryPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
parent_id: null as number | null,
|
||||
order: 0,
|
||||
is_active: true
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Загрузка списка категорий для выбора родительской категории
|
||||
useEffect(() => {
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const data = await categoryService.getCategories();
|
||||
setCategories(data);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке категорий:', err);
|
||||
setError('Не удалось загрузить список категорий');
|
||||
}
|
||||
};
|
||||
|
||||
fetchCategories();
|
||||
}, []);
|
||||
|
||||
// Автоматическое создание slug из названия
|
||||
const generateSlug = (name: string) => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\sа-яё-]/g, '') // Удаляем специальные символы, но оставляем кириллицу
|
||||
.replace(/\s+/g, '-') // Заменяем пробелы на дефисы
|
||||
.replace(/[а-яё]/g, char => { // Транслитерация кириллицы
|
||||
const translitMap = {
|
||||
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
|
||||
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
|
||||
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
|
||||
'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch', 'ъ': '',
|
||||
'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya'
|
||||
};
|
||||
return translitMap[char] || char;
|
||||
})
|
||||
.replace(/--+/g, '-') // Заменяем множественные дефисы на один
|
||||
.replace(/^-|-$/g, ''); // Удаляем дефисы в начале и конце
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
|
||||
// Обновляем форму
|
||||
setFormData(prev => {
|
||||
const newData = {
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
};
|
||||
|
||||
// Если изменилось название, автоматически обновляем slug
|
||||
if (name === 'name') {
|
||||
newData.slug = generateSlug(value);
|
||||
}
|
||||
|
||||
// Если выбрана родительская категория, преобразуем строку в число или null
|
||||
if (name === 'parent_id') {
|
||||
newData.parent_id = value === '' ? null : Number(value);
|
||||
}
|
||||
|
||||
return newData;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// Определяем порядок для новой категории
|
||||
const categoriesWithSameParent = categories.filter(
|
||||
cat => cat.parent_id === formData.parent_id
|
||||
);
|
||||
const newOrder = categoriesWithSameParent.length > 0
|
||||
? Math.max(...categoriesWithSameParent.map(cat => cat.order)) + 1
|
||||
: 1;
|
||||
|
||||
// Создаем категорию через API
|
||||
await categoryService.createCategory({
|
||||
...formData,
|
||||
order: newOrder
|
||||
});
|
||||
|
||||
// Перенаправляем на страницу категорий
|
||||
router.push('/admin/categories');
|
||||
} catch (err) {
|
||||
console.error('Ошибка при создании категории:', err);
|
||||
setError('Не удалось создать категорию. Пожалуйста, проверьте введенные данные и попробуйте снова.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title="Создание категории">
|
||||
<div className="mb-6 flex items-center">
|
||||
<button
|
||||
onClick={() => router.push('/admin/categories')}
|
||||
className="mr-4 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<h2 className="text-xl font-semibold text-gray-800">Создание новой категории</h2>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Название категории *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="slug" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Slug *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
name="slug"
|
||||
required
|
||||
value={formData.slug}
|
||||
onChange={handleChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
URL-совместимый идентификатор категории. Генерируется автоматически из названия.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="parent_id" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Родительская категория
|
||||
</label>
|
||||
<select
|
||||
id="parent_id"
|
||||
name="parent_id"
|
||||
value={formData.parent_id === null ? '' : formData.parent_id}
|
||||
onChange={handleChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Нет (корневая категория)</option>
|
||||
{categories.map(category => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_active"
|
||||
name="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="is_active" className="ml-2 block text-sm text-gray-700">
|
||||
Активна
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Неактивные категории не будут отображаться на сайте.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/admin/categories')}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 mr-3"
|
||||
>
|
||||
<X className="h-5 w-5 mr-2" />
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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"
|
||||
>
|
||||
<Save className="h-5 w-5 mr-2" />
|
||||
{loading ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@ -1,242 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Plus, Edit, Trash, ChevronLeft, ChevronRight, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import { categoryService, Category } from '../../../services/catalog';
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Загрузка категорий при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await categoryService.getCategories();
|
||||
setCategories(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке категорий:', err);
|
||||
setError('Не удалось загрузить категории. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCategories();
|
||||
}, []);
|
||||
|
||||
// Функция для изменения порядка категорий
|
||||
const handleReorder = async (id: number, direction: 'up' | 'down') => {
|
||||
const index = categories.findIndex(cat => cat.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
// Если двигаем вверх и это не первый элемент
|
||||
if (direction === 'up' && index > 0) {
|
||||
try {
|
||||
const currentCategory = categories[index];
|
||||
const prevCategory = categories[index - 1];
|
||||
|
||||
// Обновляем порядок в API
|
||||
await categoryService.updateCategory(currentCategory.id, { order: prevCategory.order });
|
||||
await categoryService.updateCategory(prevCategory.id, { order: currentCategory.order });
|
||||
|
||||
// Обновляем локальное состояние
|
||||
const newCategories = [...categories];
|
||||
newCategories[index] = { ...newCategories[index], order: prevCategory.order };
|
||||
newCategories[index - 1] = { ...newCategories[index - 1], order: currentCategory.order };
|
||||
|
||||
// Сортируем по порядку
|
||||
newCategories.sort((a, b) => a.order - b.order);
|
||||
|
||||
setCategories(newCategories);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при изменении порядка:', err);
|
||||
setError('Не удалось изменить порядок категории.');
|
||||
}
|
||||
}
|
||||
// Если двигаем вниз и это не последний элемент
|
||||
else if (direction === 'down' && index < categories.length - 1) {
|
||||
try {
|
||||
const currentCategory = categories[index];
|
||||
const nextCategory = categories[index + 1];
|
||||
|
||||
// Обновляем порядок в API
|
||||
await categoryService.updateCategory(currentCategory.id, { order: nextCategory.order });
|
||||
await categoryService.updateCategory(nextCategory.id, { order: currentCategory.order });
|
||||
|
||||
// Обновляем локальное состояние
|
||||
const newCategories = [...categories];
|
||||
newCategories[index] = { ...newCategories[index], order: nextCategory.order };
|
||||
newCategories[index + 1] = { ...newCategories[index + 1], order: currentCategory.order };
|
||||
|
||||
// Сортируем по порядку
|
||||
newCategories.sort((a, b) => a.order - b.order);
|
||||
|
||||
setCategories(newCategories);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при изменении порядка:', err);
|
||||
setError('Не удалось изменить порядок категории.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для удаления категории
|
||||
const handleDelete = async (id: number) => {
|
||||
if (window.confirm('Вы уверены, что хотите удалить эту категорию?')) {
|
||||
try {
|
||||
await categoryService.deleteCategory(id);
|
||||
setCategories(categories.filter(cat => cat.id !== id));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении категории:', err);
|
||||
setError('Не удалось удалить категорию.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для изменения статуса активности
|
||||
const handleToggleActive = async (id: number, currentStatus: boolean) => {
|
||||
try {
|
||||
await categoryService.updateCategory(id, { is_active: !currentStatus });
|
||||
setCategories(categories.map(cat =>
|
||||
cat.id === id ? { ...cat, is_active: !cat.is_active } : cat
|
||||
));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при изменении статуса категории:', err);
|
||||
setError('Не удалось изменить статус категории.');
|
||||
}
|
||||
};
|
||||
|
||||
// Получаем корневые категории и подкатегории
|
||||
const rootCategories = categories.filter(cat => cat.parent_id === null);
|
||||
|
||||
// Рекурсивная функция для отображения категорий и их подкатегорий
|
||||
const renderCategoryRow = (category: Category, level: number = 0) => {
|
||||
const indent = level * 12; // Отступ для подкатегорий
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr key={category.id} className={level === 0 ? "bg-gray-50" : ""}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900" style={{ paddingLeft: `${6 + indent}px` }}>
|
||||
{level > 0 && "— "}{category.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{category.slug}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{category.parent_id ? categories.find(c => c.id === category.parent_id)?.name || '-' : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{category.products_count || 0}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => handleToggleActive(category.id, category.is_active)}
|
||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${category.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}
|
||||
>
|
||||
{category.is_active ? 'Активна' : 'Неактивна'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleReorder(category.id, 'up')}
|
||||
disabled={category.order === 1}
|
||||
className={`p-1 rounded-full ${category.order === 1 ? 'text-gray-300' : 'text-gray-600 hover:bg-gray-100'}`}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReorder(category.id, 'down')}
|
||||
disabled={category.order === (level === 0 ? rootCategories.length : categories.filter(c => c.parent_id === category.parent_id).length)}
|
||||
className={`p-1 rounded-full ${category.order === (level === 0 ? rootCategories.length : categories.filter(c => c.parent_id === category.parent_id).length) ? 'text-gray-300' : 'text-gray-600 hover:bg-gray-100'}`}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</button>
|
||||
<span>{category.order}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Link href={`/admin/categories/${category.id}`} className="text-indigo-600 hover:text-indigo-900">
|
||||
<Edit className="h-5 w-5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(category.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/* Рекурсивно отображаем подкатегории */}
|
||||
{category.subcategories?.map(subcat => renderCategoryRow(subcat, level + 1))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout title="Управление категориями">
|
||||
<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>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<AdminLayout title="Управление категориями">
|
||||
<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>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
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"
|
||||
>
|
||||
Попробовать снова
|
||||
</button>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title="Управление категориями">
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Список категорий</h2>
|
||||
<Link href="/admin/categories/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>
|
||||
|
||||
<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">Название</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-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">
|
||||
{rootCategories.map(category => renderCategoryRow(category))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Показано {categories.length} категорий
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@ -1,348 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Edit, Trash, Plus, Check, X } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import { collectionService, Collection } from '../../../services/catalog';
|
||||
|
||||
export default function CollectionsPage() {
|
||||
const router = useRouter();
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [newCollection, setNewCollection] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
is_active: true
|
||||
});
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
// Загрузка коллекций при монтировании компонента
|
||||
useEffect(() => {
|
||||
fetchCollections();
|
||||
}, []);
|
||||
|
||||
const fetchCollections = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const data = await collectionService.getCollections();
|
||||
setCollections(data);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке коллекций:', err);
|
||||
setError('Не удалось загрузить коллекции. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCollection = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!newCollection.name) {
|
||||
setError('Название коллекции обязательно');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
// Создаем slug из названия
|
||||
const slug = generateSlug(newCollection.name);
|
||||
|
||||
const createdCollection = await collectionService.createCollection({
|
||||
name: newCollection.name,
|
||||
slug,
|
||||
description: newCollection.description,
|
||||
is_active: newCollection.is_active
|
||||
});
|
||||
|
||||
setCollections([...collections, createdCollection]);
|
||||
setNewCollection({
|
||||
name: '',
|
||||
description: '',
|
||||
is_active: true
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ошибка при создании коллекции:', err);
|
||||
setError('Не удалось создать коллекцию. Пожалуйста, проверьте введенные данные и попробуйте снова.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditCollection = (collection: Collection) => {
|
||||
setEditingId(collection.id);
|
||||
setEditForm({
|
||||
name: collection.name,
|
||||
description: collection.description || '',
|
||||
is_active: collection.is_active
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateCollection = async (id: number) => {
|
||||
if (!editForm.name) {
|
||||
setError('Название коллекции обязательно');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
// Создаем slug из названия
|
||||
const slug = generateSlug(editForm.name);
|
||||
|
||||
const updatedCollection = await collectionService.updateCollection(id, {
|
||||
name: editForm.name,
|
||||
slug,
|
||||
description: editForm.description,
|
||||
is_active: editForm.is_active
|
||||
});
|
||||
|
||||
setCollections(collections.map(collection =>
|
||||
collection.id === id ? updatedCollection : collection
|
||||
));
|
||||
setEditingId(null);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при обновлении коллекции:', err);
|
||||
setError('Не удалось обновить коллекцию. Пожалуйста, проверьте введенные данные и попробуйте снова.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCollection = async (id: number) => {
|
||||
if (!confirm('Вы уверены, что хотите удалить эту коллекцию?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
await collectionService.deleteCollection(id);
|
||||
|
||||
setCollections(collections.filter(collection => collection.id !== id));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении коллекции:', err);
|
||||
setError('Не удалось удалить коллекцию. Возможно, она используется в товарах.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleNewCollectionChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setNewCollection({
|
||||
...newCollection,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditFormChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setEditForm({
|
||||
...editForm,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
});
|
||||
};
|
||||
|
||||
const generateSlug = (name) => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title="Управление коллекциями">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
|
||||
<strong className="font-bold">Ошибка!</strong>
|
||||
<span className="block sm:inline"> {error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Добавить новую коллекцию</h2>
|
||||
<form onSubmit={handleCreateCollection} className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Название *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={newCollection.name}
|
||||
onChange={handleNewCollectionChange}
|
||||
required
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700">Описание</label>
|
||||
<input
|
||||
type="text"
|
||||
id="description"
|
||||
name="description"
|
||||
value={newCollection.description}
|
||||
onChange={handleNewCollectionChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<div className="mr-4">
|
||||
<label htmlFor="is_active" className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_active"
|
||||
name="is_active"
|
||||
checked={newCollection.is_active}
|
||||
onChange={handleNewCollectionChange}
|
||||
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">Активна</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm 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"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Список коллекций</h2>
|
||||
|
||||
{loading && collections.length === 0 ? (
|
||||
<div className="flex justify-center items-center h-32">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
) : collections.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
Коллекции не найдены
|
||||
</div>
|
||||
) : (
|
||||
<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">Название</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-left text-xs font-medium text-gray-500 uppercase tracking-wider">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{collections.map(collection => (
|
||||
<tr key={collection.id}>
|
||||
{editingId === collection.id ? (
|
||||
<>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={editForm.name}
|
||||
onChange={handleEditFormChange}
|
||||
required
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="text"
|
||||
name="description"
|
||||
value={editForm.description}
|
||||
onChange={handleEditFormChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_active"
|
||||
checked={editForm.is_active}
|
||||
onChange={handleEditFormChange}
|
||||
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">Активна</span>
|
||||
</label>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleUpdateCollection(collection.id)}
|
||||
disabled={loading}
|
||||
className="text-green-600 hover:text-green-900 mr-3"
|
||||
>
|
||||
<Check className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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.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">
|
||||
<button
|
||||
onClick={() => handleEditCollection(collection)}
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||
>
|
||||
<Edit className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteCollection(collection.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@ -1,330 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Mail, Phone, MapPin, Calendar, ShoppingBag, CreditCard, Clock, Check, X } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import { userService, User, Address } from '../../../services/users';
|
||||
import { orderService, Order } from '../../../services/orders';
|
||||
|
||||
// Компонент для отображения статуса заказа
|
||||
const OrderStatus = ({ status }) => {
|
||||
let bgColor = 'bg-gray-100';
|
||||
let textColor = 'text-gray-800';
|
||||
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
bgColor = 'bg-yellow-100';
|
||||
textColor = 'text-yellow-800';
|
||||
break;
|
||||
case 'paid':
|
||||
bgColor = 'bg-blue-100';
|
||||
textColor = 'text-blue-800';
|
||||
break;
|
||||
case 'processing':
|
||||
bgColor = 'bg-purple-100';
|
||||
textColor = 'text-purple-800';
|
||||
break;
|
||||
case 'shipped':
|
||||
bgColor = 'bg-indigo-100';
|
||||
textColor = 'text-indigo-800';
|
||||
break;
|
||||
case 'delivered':
|
||||
bgColor = 'bg-green-100';
|
||||
textColor = 'text-green-800';
|
||||
break;
|
||||
case 'cancelled':
|
||||
bgColor = 'bg-red-100';
|
||||
textColor = 'text-red-800';
|
||||
break;
|
||||
}
|
||||
|
||||
const statusText = {
|
||||
pending: 'Ожидает оплаты',
|
||||
paid: 'Оплачен',
|
||||
processing: 'В обработке',
|
||||
shipped: 'Отправлен',
|
||||
delivered: 'Доставлен',
|
||||
cancelled: 'Отменен'
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${bgColor} ${textColor}`}>
|
||||
{statusText[status] || status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default function CustomerDetailPage() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [stats, setStats] = useState({
|
||||
ordersCount: 0,
|
||||
totalSpent: 0,
|
||||
lastOrderDate: ''
|
||||
});
|
||||
|
||||
// Загрузка данных пользователя
|
||||
useEffect(() => {
|
||||
const fetchUserData = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const userData = await userService.getUserById(Number(id));
|
||||
setUser(userData);
|
||||
|
||||
// Загрузка заказов пользователя
|
||||
try {
|
||||
// Предполагаем, что в API есть эндпоинт для получения заказов пользователя
|
||||
const response = await fetch(`/api/users/${id}/orders`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке заказов');
|
||||
}
|
||||
const ordersData = await response.json();
|
||||
setOrders(ordersData);
|
||||
|
||||
// Расчет статистики
|
||||
if (ordersData.length > 0) {
|
||||
const totalSpent = ordersData.reduce((sum, order) => sum + order.total, 0);
|
||||
const lastOrder = ordersData.sort((a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
)[0];
|
||||
|
||||
setStats({
|
||||
ordersCount: ordersData.length,
|
||||
totalSpent: totalSpent,
|
||||
lastOrderDate: lastOrder.created_at
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке заказов:', err);
|
||||
// Продолжаем работу даже если не удалось загрузить заказы
|
||||
}
|
||||
|
||||
setError('');
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных пользователя:', err);
|
||||
setError('Не удалось загрузить данные клиента. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserData();
|
||||
}, [id]);
|
||||
|
||||
// Форматирование даты для отображения
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
// Форматирование суммы для отображения
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Получение полного имени пользователя
|
||||
const getFullName = (user: User) => {
|
||||
return `${user.first_name} ${user.last_name}`.trim() || 'Нет имени';
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title={loading ? 'Загрузка...' : `Клиент: ${user ? getFullName(user) : ''}`}>
|
||||
<div className="mb-6 flex items-center">
|
||||
<Link href="/admin/customers" className="inline-flex items-center mr-4 text-indigo-600 hover:text-indigo-800">
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Назад к списку
|
||||
</Link>
|
||||
<h2 className="text-xl font-semibold text-gray-800">
|
||||
{loading ? 'Загрузка...' : `Информация о клиенте: ${user ? getFullName(user) : ''}`}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<X className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<svg className="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
) : user ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
{/* Основная информация */}
|
||||
<div className="bg-white rounded-lg shadow p-6 col-span-2">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Основная информация</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Имя</h4>
|
||||
<p className="mt-1 text-sm text-gray-900">{getFullName(user)}</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Mail className="h-4 w-4 text-gray-400 mr-2" />
|
||||
<h4 className="text-sm font-medium text-gray-500 mr-2">Email:</h4>
|
||||
<a href={`mailto:${user.email}`} className="text-sm text-indigo-600 hover:text-indigo-800">
|
||||
{user.email}
|
||||
</a>
|
||||
</div>
|
||||
{user.phone && (
|
||||
<div className="flex items-center">
|
||||
<Phone className="h-4 w-4 text-gray-400 mr-2" />
|
||||
<h4 className="text-sm font-medium text-gray-500 mr-2">Телефон:</h4>
|
||||
<a href={`tel:${user.phone}`} className="text-sm text-indigo-600 hover:text-indigo-800">
|
||||
{user.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<Calendar className="h-4 w-4 text-gray-400 mr-2" />
|
||||
<h4 className="text-sm font-medium text-gray-500 mr-2">Дата регистрации:</h4>
|
||||
<p className="text-sm text-gray-900">{formatDate(user.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Статистика */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Статистика</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Всего заказов</h4>
|
||||
<p className="mt-1 text-lg font-semibold text-gray-900">{stats.ordersCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Общая сумма покупок</h4>
|
||||
<p className="mt-1 text-lg font-semibold text-indigo-600">{formatCurrency(stats.totalSpent)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Последний заказ</h4>
|
||||
<p className="mt-1 text-sm text-gray-900">{formatDate(stats.lastOrderDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Адреса */}
|
||||
{user.addresses && user.addresses.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-8">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Адреса</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{user.addresses.map((address) => (
|
||||
<div key={address.id} className="border rounded-lg p-4 relative">
|
||||
{address.is_default && (
|
||||
<span className="absolute top-2 right-2 bg-green-100 text-green-800 text-xs px-2 py-1 rounded">
|
||||
По умолчанию
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-start mb-2">
|
||||
<MapPin className="h-5 w-5 text-gray-400 mr-2 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900">{address.type}</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
{address.address}, {address.city}, {address.postal_code}, {address.country}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* История заказов */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">История заказов</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
{orders.length === 0 ? (
|
||||
<div className="p-6 text-center text-gray-500">
|
||||
У клиента пока нет заказов
|
||||
</div>
|
||||
) : (
|
||||
<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">Дата</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-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">
|
||||
{orders.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="flex items-center">
|
||||
<Clock className="h-4 w-4 text-gray-400 mr-1" />
|
||||
{formatDate(order.created_at)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<OrderStatus status={order.status} />
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{formatCurrency(order.total)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="flex items-center">
|
||||
<CreditCard className="h-4 w-4 text-gray-400 mr-1" />
|
||||
{order.payment_method}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="flex items-center">
|
||||
<ShoppingBag className="h-4 w-4 text-gray-400 mr-1" />
|
||||
{order.items.length}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link href={`/admin/orders/${order.id}`} className="text-indigo-600 hover:text-indigo-900">
|
||||
Подробнее
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-6 text-center">
|
||||
<p className="text-gray-500">Клиент не найден</p>
|
||||
</div>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@ -1,236 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Search, ChevronLeft, ChevronRight, Mail, Phone } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import { userService, User } from '../../../services/users';
|
||||
|
||||
// Компонент для фильтрации и поиска
|
||||
const FilterBar = ({ onSearch }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
onSearch(searchTerm);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<form onSubmit={handleSearch} 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>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по имени, email или телефону..."
|
||||
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>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
Поиск
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function CustomersPage() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [pagination, setPagination] = useState({
|
||||
skip: 0,
|
||||
limit: 10,
|
||||
total: 0
|
||||
});
|
||||
|
||||
// Загрузка пользователей при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Используем метод из userService для получения списка пользователей
|
||||
const data = await userService.getUsers({
|
||||
skip: pagination.skip,
|
||||
limit: pagination.limit,
|
||||
search: searchTerm
|
||||
});
|
||||
setUsers(data.users || []);
|
||||
setPagination(prev => ({ ...prev, total: data.total || 0 }));
|
||||
setError('');
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке пользователей:', err);
|
||||
setError('Не удалось загрузить список клиентов. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUsers();
|
||||
}, [pagination.skip, pagination.limit, searchTerm]);
|
||||
|
||||
const handleSearch = (term) => {
|
||||
setSearchTerm(term);
|
||||
// Сбрасываем пагинацию при новом поиске
|
||||
setPagination(prev => ({ ...prev, skip: 0 }));
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (pagination.skip + pagination.limit < pagination.total) {
|
||||
setPagination(prev => ({ ...prev, skip: prev.skip + prev.limit }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (pagination.skip > 0) {
|
||||
setPagination(prev => ({ ...prev, skip: Math.max(0, prev.skip - prev.limit) }));
|
||||
}
|
||||
};
|
||||
|
||||
// Форматирование даты для отображения
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ru-RU');
|
||||
};
|
||||
|
||||
// Получение полного имени пользователя
|
||||
const getFullName = (user: User) => {
|
||||
return `${user.first_name} ${user.last_name}`.trim() || 'Нет имени';
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title="Управление клиентами">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Список клиентов</h2>
|
||||
</div>
|
||||
|
||||
<FilterBar onSearch={handleSearch} />
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</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">Клиент</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-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-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">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-4 text-center">
|
||||
<div className="flex justify-center">
|
||||
<svg className="animate-spin h-5 w-5 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-4 text-center text-sm text-gray-500">
|
||||
Клиенты не найдены
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{getFullName(user)}</div>
|
||||
<div className="text-sm text-gray-500">ID: {user.id}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center text-sm text-gray-500 mb-1">
|
||||
<Mail className="h-4 w-4 mr-1" />
|
||||
<a href={`mailto:${user.email}`} className="hover:text-indigo-600">{user.email}</a>
|
||||
</div>
|
||||
{user.phone && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Phone className="h-4 w-4 mr-1" />
|
||||
<a href={`tel:${user.phone}`} className="hover:text-indigo-600">{user.phone}</a>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{/* Эти данные должны приходить с бекенда */}
|
||||
-
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{/* Эти данные должны приходить с бекенда */}
|
||||
-
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{formatDate(user.created_at)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{/* Эти данные должны приходить с бекенда */}
|
||||
-
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link href={`/admin/customers/${user.id}`} className="text-indigo-600 hover:text-indigo-900">
|
||||
Подробнее
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Показано {pagination.skip + 1}-{Math.min(pagination.skip + pagination.limit, pagination.total)} из {pagination.total} клиентов
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={pagination.skip === 0}
|
||||
className={`inline-flex items-center px-3 py-1 border border-gray-300 text-sm font-medium rounded-md ${
|
||||
pagination.skip === 0 ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Назад
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={pagination.skip + pagination.limit >= pagination.total}
|
||||
className={`inline-flex items-center px-3 py-1 border border-gray-300 text-sm font-medium rounded-md ${
|
||||
pagination.skip + pagination.limit >= pagination.total ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Вперед
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@ -1,320 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { BarChart3, Package, Tag, Users, ShoppingBag, FileText, Settings } from 'lucide-react';
|
||||
import AdminLayout from '../../components/admin/AdminLayout';
|
||||
import { analyticsService } from '../../services/analytics';
|
||||
import { orderService, Order } from '../../services/orders';
|
||||
import { productService, Product } from '../../services/catalog';
|
||||
|
||||
// Компонент статистической карточки
|
||||
const StatCard = ({ title, value, icon, color }) => {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6 flex items-center">
|
||||
<div className={`rounded-full p-3 mr-4 ${color}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-gray-500 text-sm font-medium">{title}</h3>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Компонент последних заказов
|
||||
const RecentOrders = ({ orders, loading, error }) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="animate-pulse flex space-x-4">
|
||||
<div className="flex-1 space-y-4 py-1">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-red-500">Ошибка при загрузке заказов: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-medium">Последние заказы</h2>
|
||||
</div>
|
||||
<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">ID</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-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">
|
||||
{orders.map((order) => (
|
||||
<tr key={order.id}>
|
||||
<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">{order.user_name || `Пользователь #${order.user_id}`}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(order.created_at).toLocaleDateString('ru-RU')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${order.status === 'delivered' ? 'bg-green-100 text-green-800' :
|
||||
order.status === 'processing' ? 'bg-yellow-100 text-yellow-800' :
|
||||
order.status === 'shipped' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-purple-100 text-purple-800'}`}>
|
||||
{order.status === 'delivered' ? 'Доставлен' :
|
||||
order.status === 'processing' ? 'В обработке' :
|
||||
order.status === 'shipped' ? 'Отправлен' :
|
||||
order.status === 'paid' ? 'Оплачен' :
|
||||
order.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{order.total.toLocaleString('ru-RU')} ₽
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link href={`/admin/orders/${order.id}`} className="text-indigo-600 hover:text-indigo-900">
|
||||
Подробнее
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{orders.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-4 text-center text-sm text-gray-500">
|
||||
Заказы не найдены
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200">
|
||||
<Link href="/admin/orders" className="text-sm font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Посмотреть все заказы →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Компонент популярных товаров
|
||||
const PopularProducts = ({ products, loading, error }) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="animate-pulse flex space-x-4">
|
||||
<div className="flex-1 space-y-4 py-1">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-red-500">Ошибка при загрузке товаров: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-medium">Популярные товары</h2>
|
||||
</div>
|
||||
<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">Товар</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-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">
|
||||
{products.map((product) => (
|
||||
<tr key={product.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{product.name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{product.category?.name || 'Без категории'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{product.sales || 0}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${product.stock > 20 ? 'bg-green-100 text-green-800' :
|
||||
product.stock > 10 ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'}`}>
|
||||
{product.stock}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link href={`/admin/products/${product.id}`} className="text-indigo-600 hover:text-indigo-900">
|
||||
Редактировать
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{products.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-4 text-center text-sm text-gray-500">
|
||||
Товары не найдены
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200">
|
||||
<Link href="/admin/products" className="text-sm font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Посмотреть все товары →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [stats, setStats] = useState({
|
||||
ordersCount: 0,
|
||||
totalSales: 0,
|
||||
customersCount: 0,
|
||||
productsCount: 0
|
||||
});
|
||||
const [recentOrders, setRecentOrders] = useState<Order[]>([]);
|
||||
const [popularProducts, setPopularProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState({
|
||||
stats: true,
|
||||
orders: true,
|
||||
products: true
|
||||
});
|
||||
const [error, setError] = useState({
|
||||
stats: null,
|
||||
orders: null,
|
||||
products: null
|
||||
});
|
||||
|
||||
// Загрузка данных при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(prev => ({ ...prev, stats: true }));
|
||||
// В реальном приложении здесь будет запрос к API для получения статистики
|
||||
// Например: const statsData = await analyticsService.getReport('dashboard');
|
||||
|
||||
// Для демонстрации используем моковые данные
|
||||
setTimeout(() => {
|
||||
setStats({
|
||||
ordersCount: 1248,
|
||||
totalSales: 2456789,
|
||||
customersCount: 3456,
|
||||
productsCount: 867
|
||||
});
|
||||
setLoading(prev => ({ ...prev, stats: false }));
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке статистики:', err);
|
||||
setError(prev => ({ ...prev, stats: 'Не удалось загрузить статистику' }));
|
||||
setLoading(prev => ({ ...prev, stats: false }));
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(prev => ({ ...prev, orders: true }));
|
||||
// Загружаем последние заказы
|
||||
const orders = await orderService.getOrders({ limit: 4 });
|
||||
setRecentOrders(orders);
|
||||
setError(prev => ({ ...prev, orders: null }));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке заказов:', err);
|
||||
setError(prev => ({ ...prev, orders: 'Не удалось загрузить заказы' }));
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, orders: false }));
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(prev => ({ ...prev, products: true }));
|
||||
// Загружаем популярные товары
|
||||
// В реальном API должен быть эндпоинт для получения популярных товаров
|
||||
// Здесь просто получаем первые 4 товара
|
||||
const products = await productService.getProducts({ limit: 4 });
|
||||
setPopularProducts(products);
|
||||
setError(prev => ({ ...prev, products: null }));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке товаров:', err);
|
||||
setError(prev => ({ ...prev, products: 'Не удалось загрузить товары' }));
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, products: false }));
|
||||
}
|
||||
};
|
||||
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AdminLayout title="Панель управления">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<StatCard
|
||||
title="Всего заказов"
|
||||
value={loading.stats ? "..." : stats.ordersCount.toLocaleString('ru-RU')}
|
||||
icon={<ShoppingBag className="h-6 w-6 text-white" />}
|
||||
color="bg-blue-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Всего продаж"
|
||||
value={loading.stats ? "..." : `₽${stats.totalSales.toLocaleString('ru-RU')}`}
|
||||
icon={<BarChart3 className="h-6 w-6 text-white" />}
|
||||
color="bg-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Клиентов"
|
||||
value={loading.stats ? "..." : stats.customersCount.toLocaleString('ru-RU')}
|
||||
icon={<Users className="h-6 w-6 text-white" />}
|
||||
color="bg-purple-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Товаров"
|
||||
value={loading.stats ? "..." : stats.productsCount.toLocaleString('ru-RU')}
|
||||
icon={<Package className="h-6 w-6 text-white" />}
|
||||
color="bg-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<RecentOrders
|
||||
orders={recentOrders}
|
||||
loading={loading.orders}
|
||||
error={error.orders}
|
||||
/>
|
||||
<PopularProducts
|
||||
products={popularProducts}
|
||||
loading={loading.products}
|
||||
error={error.products}
|
||||
/>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@ -1,564 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Package, Truck, CreditCard, Calendar, User, MapPin, Check, X, Edit, Save } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import { orderService, Order, OrderUpdate, Address } from '../../../services/orders';
|
||||
|
||||
// Компонент для отображения статуса заказа
|
||||
const OrderStatus = ({ status }) => {
|
||||
let bgColor = 'bg-gray-100';
|
||||
let textColor = 'text-gray-800';
|
||||
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
bgColor = 'bg-yellow-100';
|
||||
textColor = 'text-yellow-800';
|
||||
break;
|
||||
case 'paid':
|
||||
bgColor = 'bg-blue-100';
|
||||
textColor = 'text-blue-800';
|
||||
break;
|
||||
case 'processing':
|
||||
bgColor = 'bg-purple-100';
|
||||
textColor = 'text-purple-800';
|
||||
break;
|
||||
case 'shipped':
|
||||
bgColor = 'bg-indigo-100';
|
||||
textColor = 'text-indigo-800';
|
||||
break;
|
||||
case 'delivered':
|
||||
bgColor = 'bg-green-100';
|
||||
textColor = 'text-green-800';
|
||||
break;
|
||||
case 'cancelled':
|
||||
bgColor = 'bg-red-100';
|
||||
textColor = 'text-red-800';
|
||||
break;
|
||||
}
|
||||
|
||||
const statusText = {
|
||||
pending: 'Ожидает оплаты',
|
||||
paid: 'Оплачен',
|
||||
processing: 'В обработке',
|
||||
shipped: 'Отправлен',
|
||||
delivered: 'Доставлен',
|
||||
cancelled: 'Отменен'
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full ${bgColor} ${textColor}`}>
|
||||
{statusText[status] || status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default function OrderDetailsPage() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [updateStatus, setUpdateStatus] = useState('');
|
||||
const [editingTracking, setEditingTracking] = useState(false);
|
||||
const [trackingNumber, setTrackingNumber] = useState('');
|
||||
const [editingShipping, setEditingShipping] = useState(false);
|
||||
const [shippingMethod, setShippingMethod] = useState('');
|
||||
|
||||
// Загрузка данных заказа
|
||||
useEffect(() => {
|
||||
const fetchOrder = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await orderService.getOrderById(Number(id));
|
||||
setOrder(data);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке заказа:', err);
|
||||
setError('Не удалось загрузить данные заказа. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchOrder();
|
||||
}, [id]);
|
||||
|
||||
// Обработчик изменения статуса заказа
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
if (!order) return;
|
||||
|
||||
try {
|
||||
setUpdating(true);
|
||||
setUpdateStatus('');
|
||||
|
||||
const updatedOrder = await orderService.updateOrder(order.id, { status: newStatus });
|
||||
setOrder(updatedOrder);
|
||||
setUpdateStatus('Статус заказа успешно обновлен');
|
||||
|
||||
// Скрываем сообщение об успешном обновлении через 3 секунды
|
||||
setTimeout(() => {
|
||||
setUpdateStatus('');
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при обновлении статуса заказа:', err);
|
||||
setUpdateStatus('Не удалось обновить статус заказа. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик отмены заказа
|
||||
const handleCancelOrder = async () => {
|
||||
if (!order) return;
|
||||
|
||||
if (!confirm('Вы уверены, что хотите отменить заказ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUpdating(true);
|
||||
setUpdateStatus('');
|
||||
|
||||
const updatedOrder = await orderService.cancelOrder(order.id);
|
||||
setOrder(updatedOrder);
|
||||
setUpdateStatus('Заказ успешно отменен');
|
||||
|
||||
// Скрываем сообщение об успешном обновлении через 3 секунды
|
||||
setTimeout(() => {
|
||||
setUpdateStatus('');
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при отмене заказа:', err);
|
||||
setUpdateStatus('Не удалось отменить заказ. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик сохранения трекинг-номера
|
||||
const handleSaveTracking = async () => {
|
||||
if (!order) return;
|
||||
|
||||
try {
|
||||
setUpdating(true);
|
||||
setUpdateStatus('');
|
||||
|
||||
const updatedOrder = await orderService.updateOrder(order.id, {
|
||||
tracking_number: trackingNumber
|
||||
});
|
||||
setOrder(updatedOrder);
|
||||
setEditingTracking(false);
|
||||
setUpdateStatus('Трекинг-номер успешно обновлен');
|
||||
|
||||
// Скрываем сообщение об успешном обновлении через 3 секунды
|
||||
setTimeout(() => {
|
||||
setUpdateStatus('');
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при обновлении трекинг-номера:', err);
|
||||
setUpdateStatus('Не удалось обновить трекинг-номер. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик сохранения способа доставки
|
||||
const handleSaveShipping = async () => {
|
||||
if (!order) return;
|
||||
|
||||
try {
|
||||
setUpdating(true);
|
||||
setUpdateStatus('');
|
||||
|
||||
const updatedOrder = await orderService.updateOrder(order.id, {
|
||||
shipping_method: shippingMethod
|
||||
});
|
||||
setOrder(updatedOrder);
|
||||
setEditingShipping(false);
|
||||
setUpdateStatus('Способ доставки успешно обновлен');
|
||||
|
||||
// Скрываем сообщение об успешном обновлении через 3 секунды
|
||||
setTimeout(() => {
|
||||
setUpdateStatus('');
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при обновлении способа доставки:', err);
|
||||
setUpdateStatus('Не удалось обновить способ доставки. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Форматирование даты для отображения
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
// Форматирование суммы для отображения
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title={`Заказ #${id || ''}`}>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Link href="/admin/orders" className="mr-4 text-gray-500 hover:text-gray-700">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h2 className="text-xl font-semibold text-gray-800">
|
||||
{loading ? 'Загрузка...' : `Заказ #${order?.id || ''}`}
|
||||
</h2>
|
||||
</div>
|
||||
{order && order.status !== 'cancelled' && (
|
||||
<button
|
||||
onClick={handleCancelOrder}
|
||||
disabled={updating}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Отменить заказ
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateStatus && (
|
||||
<div className={`p-4 mb-6 rounded-md ${updateStatus.includes('успешно') ? 'bg-green-50 border-l-4 border-green-500' : 'bg-red-50 border-l-4 border-red-500'}`}>
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
{updateStatus.includes('успешно') ? (
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<X className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className={`text-sm ${updateStatus.includes('успешно') ? 'text-green-700' : 'text-red-700'}`}>{updateStatus}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<svg className="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
) : order ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Основная информация о заказе */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Информация о заказе</h3>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Calendar className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Дата заказа</p>
|
||||
<p className="text-sm font-medium">{formatDate(order.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mb-4">
|
||||
<User className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Клиент</p>
|
||||
{order.user_email || order.user_name ? (
|
||||
<div>
|
||||
{order.user_name && <p className="text-sm font-medium">{order.user_name}</p>}
|
||||
{order.user_email && <p className="text-sm text-gray-500">{order.user_email}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm font-medium">ID: {order.user_id}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<MapPin className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Адрес доставки</p>
|
||||
{typeof order.shipping_address === 'string' ? (
|
||||
<p className="text-sm font-medium">{order.shipping_address}</p>
|
||||
) : (
|
||||
<p className="text-sm font-medium">
|
||||
{order.shipping_address && typeof order.shipping_address === 'object' ? (
|
||||
<>
|
||||
{(order.shipping_address as Address).address_line1}<br />
|
||||
{(order.shipping_address as Address).address_line2 && <>{(order.shipping_address as Address).address_line2}<br /></>}
|
||||
{(order.shipping_address as Address).city}, {(order.shipping_address as Address).state}, {(order.shipping_address as Address).postal_code}<br />
|
||||
{(order.shipping_address as Address).country}
|
||||
</>
|
||||
) : (
|
||||
'Адрес не указан'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Package className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Статус</p>
|
||||
<div className="mt-1">
|
||||
<OrderStatus status={order.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Truck className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Способ доставки</p>
|
||||
{editingShipping ? (
|
||||
<div className="mt-1 flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={shippingMethod}
|
||||
onChange={(e) => setShippingMethod(e.target.value)}
|
||||
className="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Введите способ доставки"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveShipping}
|
||||
disabled={updating}
|
||||
className="ml-2 p-1 text-indigo-600 hover:text-indigo-900"
|
||||
title="Сохранить"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingShipping(false)}
|
||||
className="ml-1 p-1 text-gray-500 hover:text-gray-700"
|
||||
title="Отменить"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<p className="text-sm font-medium">{order.shipping_method || 'Не указан'}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShippingMethod(order.shipping_method || '');
|
||||
setEditingShipping(true);
|
||||
}}
|
||||
className="ml-2 p-1 text-gray-400 hover:text-gray-600"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CreditCard className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Способ оплаты</p>
|
||||
<p className="text-sm font-medium">{order.payment_method}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Товары в заказе */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden mt-6">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Товары в заказе</h3>
|
||||
</div>
|
||||
<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">Товар</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-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">
|
||||
{order.items.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{item.product_name || (item.product && item.product.name) || 'Товар'}
|
||||
</div>
|
||||
{(item.variant_size || item.variant_color || item.variant_name) && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{item.variant_name && `Вариант: ${item.variant_name}`}
|
||||
{item.variant_name && (item.variant_size || item.variant_color) && ', '}
|
||||
{item.variant_size && `Размер: ${item.variant_size}`}
|
||||
{item.variant_size && item.variant_color && ', '}
|
||||
{item.variant_color && `Цвет: ${item.variant_color}`}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{item.product_sku || (item.product && item.product.sku) || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{formatCurrency(item.price)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{item.quantity}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 text-right font-medium">
|
||||
{formatCurrency(item.price * item.quantity)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Боковая панель с действиями и итогами */}
|
||||
<div>
|
||||
{/* Изменение статуса заказа */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Изменить статус</h3>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<div className="space-y-3">
|
||||
{['pending', 'paid', 'processing', 'shipped', 'delivered'].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => handleStatusChange(status)}
|
||||
disabled={updating || order.status === status || order.status === 'cancelled'}
|
||||
className={`w-full py-2 px-4 rounded-md text-sm font-medium ${
|
||||
order.status === status
|
||||
? 'bg-indigo-100 text-indigo-800 cursor-default'
|
||||
: order.status === 'cancelled'
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{status === 'pending' && 'Ожидает оплаты'}
|
||||
{status === 'paid' && 'Оплачен'}
|
||||
{status === 'processing' && 'В обработке'}
|
||||
{status === 'shipped' && 'Отправлен'}
|
||||
{status === 'delivered' && 'Доставлен'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Итоги заказа */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden mt-6">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Итого</h3>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Сумма товаров</span>
|
||||
<span className="text-sm font-medium">{formatCurrency(order.total_amount)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Доставка</span>
|
||||
<span className="text-sm font-medium">0 ₽</span>
|
||||
</div>
|
||||
<div className="pt-3 border-t border-gray-200 flex justify-between">
|
||||
<span className="text-base font-medium">Итого</span>
|
||||
<span className="text-base font-bold">{formatCurrency(order.total_amount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Трекинг-номер */}
|
||||
<div className="flex items-center mt-4">
|
||||
<Truck className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Трекинг-номер</p>
|
||||
{editingTracking ? (
|
||||
<div className="mt-1 flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={trackingNumber}
|
||||
onChange={(e) => setTrackingNumber(e.target.value)}
|
||||
className="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Введите трекинг-номер"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveTracking}
|
||||
disabled={updating}
|
||||
className="ml-2 p-1 text-indigo-600 hover:text-indigo-900"
|
||||
title="Сохранить"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingTracking(false)}
|
||||
className="ml-1 p-1 text-gray-500 hover:text-gray-700"
|
||||
title="Отменить"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<p className="text-sm font-medium">{order.tracking_number || 'Не указан'}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTrackingNumber(order.tracking_number || '');
|
||||
setEditingTracking(true);
|
||||
}}
|
||||
className="ml-2 p-1 text-gray-400 hover:text-gray-600"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-6 text-center">
|
||||
<p className="text-gray-500">Заказ не найден</p>
|
||||
</div>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@ -1,367 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Search, Filter, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import { orderService, Order } from '../../../services/orders';
|
||||
|
||||
// Компонент для фильтрации и поиска
|
||||
const FilterBar = ({ onSearch, onFilter }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filters, setFilters] = useState({
|
||||
status: '',
|
||||
dateFrom: '',
|
||||
dateTo: ''
|
||||
});
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
onSearch(searchTerm);
|
||||
};
|
||||
|
||||
const handleFilterChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFilters({
|
||||
...filters,
|
||||
[name]: value
|
||||
});
|
||||
};
|
||||
|
||||
const handleFilter = (e) => {
|
||||
e.preventDefault();
|
||||
onFilter(filters);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4 mb-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>
|
||||
<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>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
Поиск
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-3 flex items-center">
|
||||
<Filter className="h-4 w-4 mr-1" />
|
||||
Фильтры
|
||||
</h3>
|
||||
<form onSubmit={handleFilter} className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">Статус</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
value={filters.status}
|
||||
onChange={handleFilterChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Все статусы</option>
|
||||
<option value="pending">Ожидает оплаты</option>
|
||||
<option value="paid">Оплачен</option>
|
||||
<option value="processing">В обработке</option>
|
||||
<option value="shipped">Отправлен</option>
|
||||
<option value="delivered">Доставлен</option>
|
||||
<option value="cancelled">Отменен</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dateFrom" className="block text-sm font-medium text-gray-700 mb-1">Дата с</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateFrom"
|
||||
name="dateFrom"
|
||||
value={filters.dateFrom}
|
||||
onChange={handleFilterChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dateTo" className="block text-sm font-medium text-gray-700 mb-1">Дата по</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateTo"
|
||||
name="dateTo"
|
||||
value={filters.dateTo}
|
||||
onChange={handleFilterChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
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 w-full"
|
||||
>
|
||||
Применить фильтры
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Компонент для отображения статуса заказа
|
||||
const OrderStatus = ({ status }) => {
|
||||
let bgColor = 'bg-gray-100';
|
||||
let textColor = 'text-gray-800';
|
||||
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
bgColor = 'bg-yellow-100';
|
||||
textColor = 'text-yellow-800';
|
||||
break;
|
||||
case 'paid':
|
||||
bgColor = 'bg-blue-100';
|
||||
textColor = 'text-blue-800';
|
||||
break;
|
||||
case 'processing':
|
||||
bgColor = 'bg-purple-100';
|
||||
textColor = 'text-purple-800';
|
||||
break;
|
||||
case 'shipped':
|
||||
bgColor = 'bg-indigo-100';
|
||||
textColor = 'text-indigo-800';
|
||||
break;
|
||||
case 'delivered':
|
||||
bgColor = 'bg-green-100';
|
||||
textColor = 'text-green-800';
|
||||
break;
|
||||
case 'cancelled':
|
||||
bgColor = 'bg-red-100';
|
||||
textColor = 'text-red-800';
|
||||
break;
|
||||
}
|
||||
|
||||
const statusText = {
|
||||
pending: 'Ожидает оплаты',
|
||||
paid: 'Оплачен',
|
||||
processing: 'В обработке',
|
||||
shipped: 'Отправлен',
|
||||
delivered: 'Доставлен',
|
||||
cancelled: 'Отменен'
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${bgColor} ${textColor}`}>
|
||||
{statusText[status] || status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default function OrdersPage() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeFilters, setActiveFilters] = useState<{
|
||||
status?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}>({});
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [pagination, setPagination] = useState({
|
||||
skip: 0,
|
||||
limit: 10,
|
||||
total: 0
|
||||
});
|
||||
|
||||
// Загрузка заказов при монтировании компонента и при изменении фильтров
|
||||
useEffect(() => {
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = {
|
||||
skip: pagination.skip,
|
||||
limit: pagination.limit,
|
||||
...activeFilters
|
||||
};
|
||||
const data = await orderService.getOrders(params);
|
||||
setOrders(data);
|
||||
// Предполагаем, что общее количество заказов будет возвращаться с бекенда
|
||||
// В реальном API это может быть в заголовках или в отдельном поле ответа
|
||||
setPagination(prev => ({ ...prev, total: data.length > 0 ? 50 : 0 })); // Временное решение
|
||||
setError('');
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке заказов:', err);
|
||||
setError('Не удалось загрузить заказы. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchOrders();
|
||||
}, [activeFilters, pagination.skip, pagination.limit]);
|
||||
|
||||
const handleSearch = (term) => {
|
||||
setSearchTerm(term);
|
||||
// Сбрасываем пагинацию при новом поиске
|
||||
setPagination(prev => ({ ...prev, skip: 0 }));
|
||||
// Здесь можно добавить логику для поиска по заказам
|
||||
// Например, отправить запрос к API с параметром поиска
|
||||
};
|
||||
|
||||
const handleFilter = (filters) => {
|
||||
setActiveFilters(filters);
|
||||
// Сбрасываем пагинацию при новых фильтрах
|
||||
setPagination(prev => ({ ...prev, skip: 0 }));
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (pagination.skip + pagination.limit < pagination.total) {
|
||||
setPagination(prev => ({ ...prev, skip: prev.skip + prev.limit }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (pagination.skip > 0) {
|
||||
setPagination(prev => ({ ...prev, skip: Math.max(0, prev.skip - prev.limit) }));
|
||||
}
|
||||
};
|
||||
|
||||
// Форматирование даты для отображения
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ru-RU');
|
||||
};
|
||||
|
||||
// Форматирование суммы для отображения
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title="Управление заказами">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Список заказов</h2>
|
||||
</div>
|
||||
|
||||
<FilterBar onSearch={handleSearch} onFilter={handleFilter} />
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</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">ID заказа</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-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-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">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-6 py-4 text-center">
|
||||
<div className="flex justify-center">
|
||||
<svg className="animate-spin h-5 w-5 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : orders.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-6 py-4 text-center text-sm text-gray-500">
|
||||
Заказы не найдены
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
orders.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">
|
||||
{order.user_email || order.user_name ? (
|
||||
<div>
|
||||
{order.user_name && <div className="font-medium">{order.user_name}</div>}
|
||||
{order.user_email && <div className="text-xs text-gray-500">{order.user_email}</div>}
|
||||
</div>
|
||||
) : (
|
||||
`Клиент #${order.user_id}`
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{formatDate(order.created_at)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<OrderStatus status={order.status} />
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{formatCurrency(order.total_amount)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{order.payment_method}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{order.items.length}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link href={`/admin/orders/${order.id}`} className="text-indigo-600 hover:text-indigo-900">
|
||||
Подробнее
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Показано {pagination.skip + 1}-{Math.min(pagination.skip + pagination.limit, pagination.total)} из {pagination.total} заказов
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={pagination.skip === 0}
|
||||
className={`inline-flex items-center px-3 py-1 border border-gray-300 text-sm font-medium rounded-md ${
|
||||
pagination.skip === 0 ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Назад
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={pagination.skip + pagination.limit >= pagination.total}
|
||||
className={`inline-flex items-center px-3 py-1 border border-gray-300 text-sm font-medium rounded-md ${
|
||||
pagination.skip + pagination.limit >= pagination.total ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Вперед
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@ -1,560 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Save, X, Plus, Trash, ArrowLeft } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import { productService, categoryService, Category, Product, ProductVariant, ProductImage } from '../../../services/catalog';
|
||||
import ProductForm from '../../../components/admin/ProductForm';
|
||||
|
||||
// Расширяем интерфейс ProductImage для поддержки загрузки файлов
|
||||
interface ProductImageWithFile extends ProductImage {
|
||||
file?: File;
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
// Расширяем интерфейс для формы
|
||||
interface ProductFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
category_id: string;
|
||||
collection_id: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
// Компонент для загрузки изображений
|
||||
const ImageUploader = ({ images, setImages, productId }) => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const handleImageUpload = async (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
if (!(file instanceof File)) {
|
||||
console.error('Объект не является файлом:', file);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Создаем временный объект для предпросмотра
|
||||
const tempImage = {
|
||||
id: Date.now() + Math.random().toString(36).substring(2, 9),
|
||||
url: URL.createObjectURL(file),
|
||||
is_primary: images.length === 0, // Первое изображение будет основным
|
||||
product_id: productId,
|
||||
isUploading: true
|
||||
};
|
||||
|
||||
setImages(prev => [...prev, tempImage]);
|
||||
|
||||
// Загружаем изображение на сервер
|
||||
const uploadedImage = await productService.uploadProductImage(
|
||||
productId,
|
||||
file,
|
||||
tempImage.is_primary
|
||||
);
|
||||
|
||||
// Обновляем список изображений, заменяя временное на загруженное
|
||||
setImages(prev => prev.map(img =>
|
||||
img.id === tempImage.id ? { ...uploadedImage, url: uploadedImage.url } : img
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке изображений:', error);
|
||||
alert('Не удалось загрузить одно или несколько изображений');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = async (id) => {
|
||||
try {
|
||||
// Проверяем, является ли изображение временным или уже загруженным
|
||||
const image = images.find(img => img.id === id);
|
||||
|
||||
if (!image.isUploading) {
|
||||
// Если изображение уже загружено, удаляем его с сервера
|
||||
await productService.deleteProductImage(productId, id);
|
||||
}
|
||||
|
||||
const updatedImages = images.filter(image => image.id !== id);
|
||||
|
||||
// Если удалили основное изображение, делаем первое в списке основным
|
||||
if (updatedImages.length > 0 && !updatedImages.some(img => img.is_primary)) {
|
||||
const firstImage = updatedImages[0];
|
||||
await productService.updateProductImage(productId, firstImage.id, { is_primary: true });
|
||||
updatedImages[0] = { ...firstImage, is_primary: true };
|
||||
}
|
||||
|
||||
setImages(updatedImages);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при удалении изображения:', error);
|
||||
alert('Не удалось удалить изображение');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetPrimary = async (id) => {
|
||||
try {
|
||||
// Обновляем на сервере
|
||||
await productService.updateProductImage(productId, id, { is_primary: true });
|
||||
|
||||
// Обновляем локальное состояние
|
||||
const updatedImages = images.map(image => ({
|
||||
...image,
|
||||
is_primary: image.id === id
|
||||
}));
|
||||
|
||||
setImages(updatedImages);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при установке основного изображения:', error);
|
||||
alert('Не удалось установить основное изображение');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Изображения товара</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{images.map(image => (
|
||||
<div key={image.id} className="relative border rounded-lg overflow-hidden group">
|
||||
<img src={image.url} alt="Product" className="w-full h-32 object-cover" />
|
||||
{image.isUploading && (
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<button
|
||||
onClick={() => handleSetPrimary(image.id)}
|
||||
disabled={image.is_primary || image.isUploading}
|
||||
className={`p-1 rounded-full ${image.is_primary ? 'bg-green-500' : 'bg-white'} text-black mr-2 ${image.isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title={image.is_primary ? "Основное изображение" : "Сделать основным"}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveImage(image.id)}
|
||||
disabled={image.isUploading}
|
||||
className={`p-1 rounded-full bg-red-500 text-white ${image.isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{image.is_primary && (
|
||||
<div className="absolute top-0 left-0 bg-green-500 text-white text-xs px-2 py-1">
|
||||
Основное
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<label className="border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center h-32 cursor-pointer hover:border-indigo-500 transition-colors">
|
||||
{uploading ? (
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-8 w-8 text-gray-400" />
|
||||
<span className="mt-2 text-sm text-gray-500">Добавить изображение</span>
|
||||
<input type="file" className="hidden" accept="image/*" multiple onChange={handleImageUpload} />
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">Загрузите до 8 изображений товара. Первое изображение будет использоваться как основное.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Компонент для вариантов товара
|
||||
const VariantManager = ({ variants, setVariants, productId }) => {
|
||||
const [newVariant, setNewVariant] = useState({
|
||||
name: '',
|
||||
sku: '',
|
||||
price: '',
|
||||
discount_price: '',
|
||||
stock: ''
|
||||
});
|
||||
const [editingVariant, setEditingVariant] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleAddVariant = async () => {
|
||||
if (!newVariant.name || !newVariant.sku || !newVariant.price || !newVariant.stock) {
|
||||
alert('Пожалуйста, заполните все обязательные поля варианта');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const variantData = {
|
||||
name: newVariant.name,
|
||||
sku: newVariant.sku,
|
||||
price: parseFloat(newVariant.price),
|
||||
discount_price: newVariant.discount_price ? parseFloat(newVariant.discount_price) : null,
|
||||
stock: parseInt(newVariant.stock, 10),
|
||||
is_active: true
|
||||
};
|
||||
|
||||
if (productId) {
|
||||
// Если есть ID продукта, добавляем вариант через API
|
||||
const newVariantData = await productService.addProductVariant(productId, variantData);
|
||||
setVariants([...variants, newVariantData]);
|
||||
} else {
|
||||
// Иначе добавляем временный вариант (для новых продуктов)
|
||||
const variant = {
|
||||
id: Date.now(),
|
||||
...variantData,
|
||||
product_id: 0 // Будет заполнено при сохранении
|
||||
};
|
||||
setVariants([...variants, variant]);
|
||||
}
|
||||
|
||||
setNewVariant({
|
||||
name: '',
|
||||
sku: '',
|
||||
price: '',
|
||||
discount_price: '',
|
||||
stock: ''
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ошибка при добавлении варианта:', err);
|
||||
setError('Не удалось добавить вариант. Пожалуйста, попробуйте снова.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditVariant = (variant) => {
|
||||
setEditingVariant(variant);
|
||||
setNewVariant({
|
||||
name: variant.name,
|
||||
sku: variant.sku,
|
||||
price: variant.price.toString(),
|
||||
discount_price: variant.discount_price ? variant.discount_price.toString() : '',
|
||||
stock: variant.stock.toString()
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateVariant = async () => {
|
||||
if (!editingVariant) return;
|
||||
|
||||
if (!newVariant.name || !newVariant.sku || !newVariant.price || !newVariant.stock) {
|
||||
alert('Пожалуйста, заполните все обязательные поля варианта');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const variantData = {
|
||||
name: newVariant.name,
|
||||
sku: newVariant.sku,
|
||||
price: parseFloat(newVariant.price),
|
||||
discount_price: newVariant.discount_price ? parseFloat(newVariant.discount_price) : null,
|
||||
stock: parseInt(newVariant.stock, 10),
|
||||
is_active: editingVariant.is_active
|
||||
};
|
||||
|
||||
if (productId && editingVariant.id) {
|
||||
// Если есть ID продукта и ID варианта, обновляем через API
|
||||
const updatedVariant = await productService.updateProductVariant(editingVariant.id, variantData);
|
||||
setVariants(variants.map(v => v.id === editingVariant.id ? updatedVariant : v));
|
||||
} else {
|
||||
// Иначе обновляем локально
|
||||
setVariants(variants.map(v => v.id === editingVariant.id ? { ...v, ...variantData } : v));
|
||||
}
|
||||
|
||||
setNewVariant({
|
||||
name: '',
|
||||
sku: '',
|
||||
price: '',
|
||||
discount_price: '',
|
||||
stock: ''
|
||||
});
|
||||
setEditingVariant(null);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при обновлении варианта:', err);
|
||||
setError('Не удалось обновить вариант. Пожалуйста, попробуйте снова.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingVariant(null);
|
||||
setNewVariant({
|
||||
name: '',
|
||||
sku: '',
|
||||
price: '',
|
||||
discount_price: '',
|
||||
stock: ''
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveVariant = async (id) => {
|
||||
if (!confirm('Вы уверены, что хотите удалить этот вариант?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
if (productId) {
|
||||
// Если есть ID продукта, удаляем через API
|
||||
await productService.deleteProductVariant(productId, id);
|
||||
}
|
||||
|
||||
setVariants(variants.filter(variant => variant.id !== id));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении варианта:', err);
|
||||
setError('Не удалось удалить вариант. Пожалуйста, попробуйте снова.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewVariantChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setNewVariant({
|
||||
...newVariant,
|
||||
[name]: value
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Варианты товара</label>
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
|
||||
<span className="block sm:inline">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
<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">Название</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-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-left text-xs font-medium text-gray-500 uppercase tracking-wider">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{variants.map(variant => (
|
||||
<tr key={variant.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.sku}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{variant.discount_price ? (
|
||||
<>
|
||||
{variant.discount_price}
|
||||
<span className="ml-1 line-through text-gray-400">
|
||||
{variant.price}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
variant.price
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{variant.discount_price ? 'Да' : 'Нет'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.stock}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEditVariant(variant)}
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
disabled={loading}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveVariant(variant.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
disabled={loading}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={newVariant.name}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Название варианта"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="text"
|
||||
name="sku"
|
||||
value={newVariant.sku}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Артикул"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="number"
|
||||
name="price"
|
||||
value={newVariant.price}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Цена"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="number"
|
||||
name="discount_price"
|
||||
value={newVariant.discount_price}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Скидка (необяз.)"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="number"
|
||||
name="stock"
|
||||
value={newVariant.stock}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Наличие"
|
||||
min="0"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{editingVariant ? (
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpdateVariant}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 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"
|
||||
>
|
||||
{loading ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelEdit}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center px-3 py-2 border border-gray-300 text-sm leading-4 font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddVariant}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 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"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-1 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Добавление...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Добавить
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function EditProductPage() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchProduct(Number(id));
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchProduct = async (productId: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await productService.getProductById(productId);
|
||||
setProduct(data);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке товара:', err);
|
||||
setError('Не удалось загрузить данные товара.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout title="Редактирование товара">
|
||||
<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>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<AdminLayout title="Редактирование товара">
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
|
||||
<strong className="font-bold">Ошибка!</strong>
|
||||
<span className="block sm:inline"> {error}</span>
|
||||
<button
|
||||
onClick={() => router.push('/admin/products')}
|
||||
className="mt-4 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"
|
||||
>
|
||||
Вернуться к списку товаров
|
||||
</button>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title={product ? `Редактирование: ${product.name}` : "Редактирование товара"}>
|
||||
{product && <ProductForm product={product} />}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import ProductForm from '../../../components/admin/ProductForm';
|
||||
|
||||
export default function CreateProductPage() {
|
||||
return (
|
||||
<AdminLayout title="Создание товара">
|
||||
<ProductForm />
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@ -1,423 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Plus, Search, Edit, Trash, ChevronLeft, ChevronRight, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import Image from 'next/image';
|
||||
import { productService, categoryService, Product, Category } from '../../../services/catalog';
|
||||
|
||||
// Компонент для фильтрации и поиска
|
||||
const FilterBar = ({ onSearch, categories, onFilter }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [categoryId, setCategoryId] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
onSearch(searchTerm);
|
||||
};
|
||||
|
||||
const handleFilter = () => {
|
||||
onFilter({
|
||||
category_id: categoryId ? parseInt(categoryId) : undefined,
|
||||
is_active: status === 'active' ? true : status === 'inactive' ? false : undefined
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<form onSubmit={handleSearch} 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>
|
||||
<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">
|
||||
<select
|
||||
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
>
|
||||
<option value="">Все категории</option>
|
||||
{categories.map(category => (
|
||||
<option key={category.id} value={category.id}>{category.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="">Статус</option>
|
||||
<option value="active">Активные</option>
|
||||
<option value="inactive">Неактивные</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFilter}
|
||||
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"
|
||||
>
|
||||
Применить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const [expandedProducts, setExpandedProducts] = useState<number[]>([]);
|
||||
const [filters, setFilters] = useState<{
|
||||
category_id?: number;
|
||||
is_active?: boolean;
|
||||
}>({});
|
||||
const [pagination, setPagination] = useState({
|
||||
skip: 0,
|
||||
limit: 10,
|
||||
total: 0
|
||||
});
|
||||
|
||||
// Загрузка товаров и категорий при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Загружаем категории
|
||||
const categoriesData = await categoryService.getCategories();
|
||||
setCategories(categoriesData);
|
||||
|
||||
// Загружаем товары с учетом фильтров и пагинации
|
||||
const productsData = await productService.getProducts({
|
||||
skip: pagination.skip,
|
||||
limit: pagination.limit,
|
||||
search: searchTerm,
|
||||
...filters,
|
||||
include_variants: true // Явно запрашиваем варианты
|
||||
});
|
||||
|
||||
console.log('Загруженные продукты:', productsData);
|
||||
|
||||
// Проверяем наличие вариантов у продуктов
|
||||
productsData.forEach(product => {
|
||||
console.log(`Продукт ${product.name} (ID: ${product.id}):`, product);
|
||||
console.log(`Варианты:`, product.variants);
|
||||
});
|
||||
|
||||
setProducts(productsData);
|
||||
// В реальном API должно возвращаться общее количество товаров
|
||||
// Здесь просто устанавливаем примерное значение
|
||||
setPagination(prev => ({ ...prev, total: 50 }));
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных:', err);
|
||||
setError('Не удалось загрузить данные. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [searchTerm, filters, pagination.skip, pagination.limit]);
|
||||
|
||||
// Обработчик поиска
|
||||
const handleSearch = (term: string) => {
|
||||
setSearchTerm(term);
|
||||
setPagination(prev => ({ ...prev, skip: 0 })); // Сбрасываем пагинацию при поиске
|
||||
};
|
||||
|
||||
// Обработчик фильтрации
|
||||
const handleFilter = (newFilters: { category_id?: number; is_active?: boolean }) => {
|
||||
setFilters(newFilters);
|
||||
setPagination(prev => ({ ...prev, skip: 0 })); // Сбрасываем пагинацию при фильтрации
|
||||
};
|
||||
|
||||
// Обработчик удаления товара
|
||||
const handleDeleteProduct = async (id: number) => {
|
||||
if (window.confirm('Вы уверены, что хотите удалить этот товар?')) {
|
||||
try {
|
||||
await productService.deleteProduct(id);
|
||||
setProducts(products.filter(product => product.id !== id));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении товара:', err);
|
||||
setError('Не удалось удалить товар.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик изменения статуса товара
|
||||
const handleToggleStatus = async (id: number, currentStatus: boolean) => {
|
||||
try {
|
||||
await productService.updateProduct(id, { is_active: !currentStatus });
|
||||
setProducts(products.map(product =>
|
||||
product.id === id ? { ...product, is_active: !product.is_active } : product
|
||||
));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при изменении статуса товара:', err);
|
||||
setError('Не удалось изменить статус товара.');
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчики пагинации
|
||||
const handlePrevPage = () => {
|
||||
if (pagination.skip > 0) {
|
||||
setPagination(prev => ({ ...prev, skip: Math.max(0, prev.skip - prev.limit) }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (pagination.skip + pagination.limit < pagination.total) {
|
||||
setPagination(prev => ({ ...prev, skip: prev.skip + prev.limit }));
|
||||
}
|
||||
};
|
||||
|
||||
// Вычисляем текущую страницу и общее количество страниц
|
||||
const currentPage = Math.floor(pagination.skip / pagination.limit) + 1;
|
||||
const totalPages = Math.ceil(pagination.total / pagination.limit);
|
||||
|
||||
// Функция для разворачивания/сворачивания вариантов продукта
|
||||
const toggleProductVariants = (productId: number) => {
|
||||
setExpandedProducts(prev =>
|
||||
prev.includes(productId)
|
||||
? prev.filter(id => id !== productId)
|
||||
: [...prev, productId]
|
||||
);
|
||||
};
|
||||
|
||||
if (loading && products.length === 0) {
|
||||
return (
|
||||
<AdminLayout title="Управление товарами">
|
||||
<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>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<AdminLayout title="Управление товарами">
|
||||
<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>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
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"
|
||||
>
|
||||
Попробовать снова
|
||||
</button>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title="Управление товарами">
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Список товаров</h2>
|
||||
<Link href="/admin/products/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>
|
||||
|
||||
<FilterBar
|
||||
onSearch={handleSearch}
|
||||
categories={categories}
|
||||
onFilter={handleFilter}
|
||||
/>
|
||||
|
||||
<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">Товар</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-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-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">
|
||||
{products.map((product) => (
|
||||
<>
|
||||
<tr key={product.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10 relative">
|
||||
<Image
|
||||
src={product.images && product.images.length > 0
|
||||
? product.images.find(img => img.is_primary)?.url || product.images[0].url
|
||||
: '/placeholder-image.jpg'}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover rounded-md"
|
||||
onError={(e) => {
|
||||
console.error(`Ошибка загрузки изображения в списке:`, {
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
imageSrc: e.currentTarget.src
|
||||
});
|
||||
e.currentTarget.src = '/placeholder-image.jpg';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-4 flex items-center">
|
||||
<div className="text-sm font-medium text-gray-900">{product.name}</div>
|
||||
{product.variants && product.variants.length > 0 && (
|
||||
<button
|
||||
onClick={() => toggleProductVariants(product.id)}
|
||||
className="ml-2 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{expandedProducts.includes(product.id) ?
|
||||
<ChevronUp className="h-4 w-4" /> :
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{product.variants && product.variants.length > 0
|
||||
? product.variants[0].sku
|
||||
: 'Нет артикула'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{product.variants && product.variants.length > 0
|
||||
? `${product.variants[0].price.toLocaleString('ru-RU')} ₽${product.variants.length > 1 && !expandedProducts.includes(product.id) ? ' (и другие)' : ''}`
|
||||
: 'Нет цены'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{categories.find(c => c.id === product.category_id)?.name || 'Без категории'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${product.variants && product.variants.reduce((total, variant) => total + variant.stock, 0) > 20 ? 'bg-green-100 text-green-800' :
|
||||
product.variants && product.variants.reduce((total, variant) => total + variant.stock, 0) > 10 ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'}`}>
|
||||
{product.variants ? product.variants.reduce((total, variant) => total + variant.stock, 0) : 0}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => handleToggleStatus(product.id, product.is_active)}
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${product.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}
|
||||
>
|
||||
{product.is_active ? 'Активен' : 'Неактивен'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Link href={`/admin/products/${product.id}`} className="text-indigo-600 hover:text-indigo-900">
|
||||
<Edit className="h-5 w-5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDeleteProduct(product.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Варианты продукта */}
|
||||
{expandedProducts.includes(product.id) && product.variants && product.variants.map((variant) => (
|
||||
<tr key={`variant-${variant.id}`} className="bg-gray-50">
|
||||
<td className="px-6 py-2 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="ml-14">
|
||||
<div className="text-xs text-gray-500">
|
||||
Вариант: <span className="font-medium text-gray-700">{variant.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-xs text-gray-500">
|
||||
{variant.sku}
|
||||
</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-xs text-gray-500">
|
||||
{variant.discount_price ? (
|
||||
<>
|
||||
{`${variant.discount_price.toLocaleString('ru-RU')} ₽`}
|
||||
<span className="ml-1 line-through text-gray-400">
|
||||
{`${variant.price.toLocaleString('ru-RU')} ₽`}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
`${variant.price.toLocaleString('ru-RU')} ₽`
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-xs text-gray-500">-</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${variant.stock > 10 ? 'bg-green-100 text-green-800' :
|
||||
variant.stock > 5 ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'}`}>
|
||||
{variant.stock}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${variant.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}
|
||||
>
|
||||
{variant.is_active ? 'Активен' : 'Неактивен'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-right text-xs font-medium">-</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
{products.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-4 text-center text-sm text-gray-500">
|
||||
Товары не найдены
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Показано {pagination.skip + 1}-{Math.min(pagination.skip + products.length, pagination.total)} из {pagination.total} товаров
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
className="inline-flex items-center px-3 py-1 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={handlePrevPage}
|
||||
disabled={pagination.skip === 0}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Назад
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center px-3 py-1 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={handleNextPage}
|
||||
disabled={pagination.skip + pagination.limit >= pagination.total}
|
||||
>
|
||||
Вперед
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@ -1,328 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Heart, Search, Filter, ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||
import Header from '../components/Header';
|
||||
import Footer from '../components/Footer';
|
||||
import { productService, categoryService, Product, Category } from '../services/catalog';
|
||||
|
||||
export default function AllProductsPage() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [favorites, setFavorites] = useState<number[]>([]);
|
||||
const [hoveredProduct, setHoveredProduct] = useState<number | null>(null);
|
||||
|
||||
// Состояние для фильтров
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<number | null>(null);
|
||||
const [priceRange, setPriceRange] = useState<{ min: number | null; max: number | null }>({ min: null, max: null });
|
||||
const [showFilters, setShowFilters] = useState<boolean>(false);
|
||||
|
||||
// Загрузка продуктов и категорий при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Загружаем категории
|
||||
const categoriesData = await categoryService.getCategories();
|
||||
setCategories(categoriesData);
|
||||
|
||||
// Загружаем продукты с учетом фильтров
|
||||
const filters: any = {
|
||||
include_variants: true
|
||||
};
|
||||
|
||||
if (searchTerm) {
|
||||
filters.search = searchTerm;
|
||||
}
|
||||
|
||||
if (selectedCategory) {
|
||||
filters.category_id = selectedCategory;
|
||||
}
|
||||
|
||||
if (priceRange.min !== null) {
|
||||
filters.min_price = priceRange.min;
|
||||
}
|
||||
|
||||
if (priceRange.max !== null) {
|
||||
filters.max_price = priceRange.max;
|
||||
}
|
||||
|
||||
const productsData = await productService.getProducts(filters);
|
||||
setProducts(productsData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных:', err);
|
||||
setError('Не удалось загрузить данные. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [searchTerm, selectedCategory, priceRange]);
|
||||
|
||||
// Функция для добавления/удаления товара из избранного
|
||||
const toggleFavorite = (id: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setFavorites(prev =>
|
||||
prev.includes(id)
|
||||
? prev.filter(itemId => itemId !== id)
|
||||
: [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
// Функция для сброса всех фильтров
|
||||
const resetFilters = () => {
|
||||
setSearchTerm('');
|
||||
setSelectedCategory(null);
|
||||
setPriceRange({ min: null, max: null });
|
||||
};
|
||||
|
||||
// Функция для форматирования цены
|
||||
const formatPrice = (price: number): string => {
|
||||
return price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
};
|
||||
|
||||
// Получаем плоский список категорий для фильтра
|
||||
const flattenCategories = (categories: Category[], result: Category[] = []): Category[] => {
|
||||
categories.forEach(category => {
|
||||
result.push(category);
|
||||
if (category.subcategories && category.subcategories.length > 0) {
|
||||
flattenCategories(category.subcategories, result);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const allCategories = flattenCategories(categories);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white font-['Arimo']">
|
||||
<Head>
|
||||
<title>Все товары | Brand Store</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-12 md:px-8">
|
||||
<h1 className="text-3xl font-bold mb-8 font-['Playfair_Display']">Все товары</h1>
|
||||
|
||||
{/* Фильтры и поиск */}
|
||||
<div className="mb-8">
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-4">
|
||||
<div className="relative flex-grow">
|
||||
<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>
|
||||
<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>
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<Filter className="h-5 w-5 mr-2" />
|
||||
Фильтры
|
||||
{showFilters ? <ChevronUp className="ml-2 h-4 w-4" /> : <ChevronDown className="ml-2 h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Расширенные фильтры */}
|
||||
{showFilters && (
|
||||
<div className="bg-white p-4 rounded-md shadow-md mb-4 border border-gray-200">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Категория</label>
|
||||
<select
|
||||
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
value={selectedCategory || ''}
|
||||
onChange={(e) => setSelectedCategory(e.target.value ? parseInt(e.target.value) : null)}
|
||||
>
|
||||
<option value="">Все категории</option>
|
||||
{allCategories.map(category => (
|
||||
<option key={category.id} value={category.id}>{category.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Минимальная цена</label>
|
||||
<input
|
||||
type="number"
|
||||
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
value={priceRange.min || ''}
|
||||
onChange={(e) => setPriceRange({ ...priceRange, min: e.target.value ? parseInt(e.target.value) : null })}
|
||||
placeholder="От"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Максимальная цена</label>
|
||||
<input
|
||||
type="number"
|
||||
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
value={priceRange.max || ''}
|
||||
onChange={(e) => setPriceRange({ ...priceRange, max: e.target.value ? parseInt(e.target.value) : null })}
|
||||
placeholder="До"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Сбросить фильтры
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Активные фильтры */}
|
||||
{(searchTerm || selectedCategory || priceRange.min || priceRange.max) && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{searchTerm && (
|
||||
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-gray-100">
|
||||
Поиск: {searchTerm}
|
||||
<button onClick={() => setSearchTerm('')} className="ml-2">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{selectedCategory && (
|
||||
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-gray-100">
|
||||
Категория: {allCategories.find(c => c.id === selectedCategory)?.name}
|
||||
<button onClick={() => setSelectedCategory(null)} className="ml-2">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{priceRange.min && (
|
||||
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-gray-100">
|
||||
От: {priceRange.min} ₽
|
||||
<button onClick={() => setPriceRange({ ...priceRange, min: null })} className="ml-2">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{priceRange.max && (
|
||||
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-gray-100">
|
||||
До: {priceRange.max} ₽
|
||||
<button onClick={() => setPriceRange({ ...priceRange, max: null })} className="ml-2">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Отображение товаров */}
|
||||
{loading ? (
|
||||
<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>
|
||||
) : error ? (
|
||||
<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>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 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"
|
||||
>
|
||||
Попробовать снова
|
||||
</button>
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-lg text-gray-600">Товары не найдены. Попробуйте изменить параметры поиска.</p>
|
||||
{(searchTerm || selectedCategory || priceRange.min || priceRange.max) && (
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="mt-4 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"
|
||||
>
|
||||
Сбросить все фильтры
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{products.map((product) => (
|
||||
<div key={product.id} className="group">
|
||||
<Link
|
||||
href={`/product/${product.slug}`}
|
||||
className="block"
|
||||
onMouseEnter={() => setHoveredProduct(product.id)}
|
||||
onMouseLeave={() => setHoveredProduct(null)}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-xl">
|
||||
<div className="aspect-[3/4] relative overflow-hidden rounded-xl">
|
||||
{product.images && product.images.length > 0 ? (
|
||||
<Image
|
||||
src={
|
||||
hoveredProduct === product.id && product.images.length > 1
|
||||
? product.images[1]?.url || product.images[0]?.url
|
||||
: product.images[0]?.url
|
||||
}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
||||
className="object-cover transition-all duration-500 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-500">Нет изображения</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => toggleFavorite(product.id, e)}
|
||||
className="absolute top-4 right-4 bg-white/80 hover:bg-white rounded-full p-2 transition-all"
|
||||
aria-label={favorites.includes(product.id) ? "Удалить из избранного" : "Добавить в избранное"}
|
||||
>
|
||||
<Heart
|
||||
className={`w-5 h-5 ${favorites.includes(product.id) ? "fill-red-500 text-red-500" : "text-gray-700"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-medium">{product.name}</h3>
|
||||
{product.variants && product.variants.length > 0 ? (
|
||||
<p className="mt-1 text-lg font-bold">
|
||||
{product.variants[0].discount_price ? (
|
||||
<>
|
||||
{formatPrice(product.variants[0].discount_price)} ₽
|
||||
<span className="ml-2 text-sm line-through text-gray-500">
|
||||
{formatPrice(product.variants[0].price)} ₽
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>{formatPrice(product.variants[0].price)} ₽</>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-lg font-bold">Цена по запросу</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
|
||||
export default function hello(req, res) {
|
||||
res.status(200).json({ name: "John Doe" });
|
||||
}
|
||||
@ -1,301 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Trash2, Plus, Minus, ShoppingBag } from 'lucide-react';
|
||||
import Header from '../components/Header';
|
||||
import Footer from '../components/Footer';
|
||||
import cartService, { Cart, CartItem } from '../services/cart';
|
||||
import authService from '../services/auth';
|
||||
|
||||
export default function CartPage() {
|
||||
const router = useRouter();
|
||||
const [cart, setCart] = useState<Cart | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Загрузка корзины при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchCart = async () => {
|
||||
try {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
router.push('/login?redirect=/cart');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const cartData = await cartService.getCart();
|
||||
setCart(cartData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке корзины:', err);
|
||||
setError('Не удалось загрузить корзину. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCart();
|
||||
}, [router]);
|
||||
|
||||
// Обработчик изменения количества товара
|
||||
const handleQuantityChange = async (itemId: number, newQuantity: number) => {
|
||||
if (newQuantity < 1) return;
|
||||
|
||||
try {
|
||||
// Сохраняем текущий порядок элементов
|
||||
const currentItems = cart?.items || [];
|
||||
|
||||
await cartService.updateCartItem(itemId, { quantity: newQuantity });
|
||||
|
||||
// Обновляем корзину после изменения, но сохраняем порядок
|
||||
const updatedCart = await cartService.getCart();
|
||||
|
||||
// Сортируем элементы в том же порядке, что и были
|
||||
if (updatedCart && currentItems.length > 0) {
|
||||
const itemMap = new Map(currentItems.map(item => [item.id, item]));
|
||||
const sortedItems = updatedCart.items.sort((a, b) => {
|
||||
const indexA = currentItems.findIndex(item => item.id === a.id);
|
||||
const indexB = currentItems.findIndex(item => item.id === b.id);
|
||||
return indexA - indexB;
|
||||
});
|
||||
|
||||
updatedCart.items = sortedItems;
|
||||
}
|
||||
|
||||
setCart(updatedCart);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при обновлении количества:', err);
|
||||
setError('Не удалось обновить количество товара. Пожалуйста, попробуйте позже.');
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик удаления товара из корзины
|
||||
const handleRemoveItem = async (itemId: number) => {
|
||||
try {
|
||||
// Сохраняем текущий порядок элементов
|
||||
const currentItems = cart?.items.filter(item => item.id !== itemId) || [];
|
||||
|
||||
await cartService.removeFromCart(itemId);
|
||||
|
||||
// Обновляем корзину после удаления, но сохраняем порядок
|
||||
const updatedCart = await cartService.getCart();
|
||||
|
||||
// Сортируем элементы в том же порядке, что и были
|
||||
if (updatedCart && currentItems.length > 0) {
|
||||
const sortedItems = updatedCart.items.sort((a, b) => {
|
||||
const indexA = currentItems.findIndex(item => item.id === a.id);
|
||||
const indexB = currentItems.findIndex(item => item.id === b.id);
|
||||
return indexA - indexB;
|
||||
});
|
||||
|
||||
updatedCart.items = sortedItems;
|
||||
}
|
||||
|
||||
setCart(updatedCart);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении товара:', err);
|
||||
setError('Не удалось удалить товар из корзины. Пожалуйста, попробуйте позже.');
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик очистки корзины
|
||||
const handleClearCart = async () => {
|
||||
try {
|
||||
await cartService.clearCart();
|
||||
// Обновляем корзину после очистки
|
||||
const updatedCart = await cartService.getCart();
|
||||
setCart(updatedCart);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при очистке корзины:', err);
|
||||
setError('Не удалось очистить корзину. Пожалуйста, попробуйте позже.');
|
||||
}
|
||||
};
|
||||
|
||||
// Переход к оформлению заказа
|
||||
const handleCheckout = () => {
|
||||
router.push('/checkout');
|
||||
};
|
||||
|
||||
// Функция для корректного отображения URL изображения
|
||||
const getImageUrl = (imageUrl: string | undefined): string => {
|
||||
if (!imageUrl) return '/placeholder-image.jpg';
|
||||
|
||||
// Проверяем, начинается ли URL с http или https
|
||||
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
// Формируем базовый URL для изображений
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8000/api';
|
||||
const apiBaseUrl = baseUrl.replace(/\/api$/, ''); // Убираем '/api' в конце, если есть
|
||||
|
||||
// Если URL начинается с /, добавляем базовый URL API
|
||||
if (imageUrl.startsWith('/')) {
|
||||
return `${apiBaseUrl}${imageUrl}`;
|
||||
}
|
||||
|
||||
// В остальных случаях добавляем базовый URL API и /
|
||||
return `${apiBaseUrl}/${imageUrl}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
|
||||
<main className="flex-grow pt-24 pb-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h1 className="text-2xl md:text-3xl font-bold mb-8 text-center">Корзина</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
) : cart && cart.items.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Список товаров в корзине */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-semibold">Товары в корзине ({cart.items_count})</h2>
|
||||
<button
|
||||
onClick={handleClearCart}
|
||||
className="text-sm text-red-600 hover:text-red-800 transition-colors"
|
||||
>
|
||||
Очистить корзину
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-200">
|
||||
{cart.items.map((item) => (
|
||||
<div key={item.id} className="py-6 flex flex-col md:flex-row">
|
||||
{/* Изображение товара */}
|
||||
<div className="flex-shrink-0 w-full md:w-24 h-24 mb-4 md:mb-0 relative">
|
||||
{item.product_image ? (
|
||||
<Image
|
||||
src={getImageUrl(item.product_image)}
|
||||
alt={item.product_name}
|
||||
fill
|
||||
className="object-cover rounded-md"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200 rounded-md flex items-center justify-center">
|
||||
<ShoppingBag className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Информация о товаре */}
|
||||
<div className="md:ml-6 flex-grow">
|
||||
<div className="flex flex-col md:flex-row md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-medium">
|
||||
<Link href={`/product/${item.slug}`} className="hover:text-gray-600 transition-colors">
|
||||
{item.product_name}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Вариант: {item.variant_name}</p>
|
||||
</div>
|
||||
<div className="mt-2 md:mt-0 text-right">
|
||||
<p className="text-base font-medium">
|
||||
{item.total_price.toLocaleString('ru-RU')} ₽
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{item.product_price.toLocaleString('ru-RU')} ₽ за шт.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Управление количеством и удаление */}
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<div className="flex items-center border border-gray-300 rounded-md">
|
||||
<button
|
||||
onClick={() => handleQuantityChange(item.id, item.quantity - 1)}
|
||||
className="px-3 py-1 text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
disabled={item.quantity <= 1}
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="px-3 py-1 text-center w-10">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => handleQuantityChange(item.id, item.quantity + 1)}
|
||||
className="px-3 py-1 text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
className="text-red-600 hover:text-red-800 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Сводка заказа */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-lg shadow-md p-6 sticky top-24">
|
||||
<h2 className="text-lg font-semibold mb-6">Сводка заказа</h2>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Товары ({cart.items_count})</span>
|
||||
<span>{cart.total_amount.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Доставка</span>
|
||||
<span>Бесплатно</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-4 flex justify-between font-semibold">
|
||||
<span>Итого</span>
|
||||
<span>{cart.total_amount.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCheckout}
|
||||
className="w-full bg-black text-white py-3 rounded-md hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
Оформить заказ
|
||||
</button>
|
||||
|
||||
<div className="mt-6">
|
||||
<Link href="/all-products" className="text-center block text-gray-600 hover:text-gray-800 transition-colors">
|
||||
Продолжить покупки
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<div className="flex justify-center mb-6">
|
||||
<ShoppingBag className="w-16 h-16 text-gray-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Ваша корзина пуста</h2>
|
||||
<p className="text-gray-600 mb-8">Добавьте товары в корзину, чтобы оформить заказ</p>
|
||||
<Link href="/all-products" className="bg-black text-white px-6 py-3 rounded-md hover:bg-gray-800 transition-colors">
|
||||
Перейти к покупкам
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,187 +0,0 @@
|
||||
import { GetStaticPaths, GetStaticProps } from "next"
|
||||
import Head from "next/head"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { useState } from "react"
|
||||
import { Heart } from "lucide-react"
|
||||
import Header from "../../components/Header"
|
||||
import Footer from "../../components/Footer"
|
||||
import { categories, getCategoryBySlug } from "../../data/categories"
|
||||
import { Product, getProductsByCategory, formatPrice } from "../../data/products"
|
||||
import { useRouter } from "next/router"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
interface CategoryPageProps {
|
||||
category: {
|
||||
id: number
|
||||
name: string
|
||||
image: string
|
||||
url: string
|
||||
slug: string
|
||||
description: string
|
||||
}
|
||||
products: Product[]
|
||||
}
|
||||
|
||||
export default function CategoryPage({ category, products }: CategoryPageProps) {
|
||||
const router = useRouter()
|
||||
const [hoveredProduct, setHoveredProduct] = useState<number | null>(null)
|
||||
const [favorites, setFavorites] = useState<number[]>([])
|
||||
|
||||
// Если страница еще загружается, показываем заглушку
|
||||
if (router.isFallback) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#E2E2C1] font-['Arimo'] flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-[#2B5F47]"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Функция для добавления/удаления товара из избранного
|
||||
const toggleFavorite = (id: number, e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setFavorites((prev) => (prev.includes(id) ? prev.filter((itemId) => itemId !== id) : [...prev, id]))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white font-['Arimo']">
|
||||
<Head>
|
||||
<title>{category.name} | Brand Store</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Arimo:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</Head>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="pt-24 pb-16 px-4 md:px-8">
|
||||
<div className="container mx-auto">
|
||||
<div className="relative mb-12 rounded-lg overflow-hidden h-64 md:h-80">
|
||||
<Image
|
||||
src={category.image}
|
||||
alt={category.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[#2B5F47]/50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-3xl md:text-5xl font-bold mb-4 text-white"
|
||||
>
|
||||
{category.name}
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="text-white/90 max-w-2xl mx-auto px-4"
|
||||
>
|
||||
{category.description}
|
||||
</motion.p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{products.length > 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||
>
|
||||
{products.map((product, index) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 * index }}
|
||||
className="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-lg transition-shadow"
|
||||
onMouseEnter={() => setHoveredProduct(product.id)}
|
||||
onMouseLeave={() => setHoveredProduct(null)}
|
||||
>
|
||||
<Link href={`/product/${product.slug}`}>
|
||||
<div className="relative h-64 w-full overflow-hidden">
|
||||
<Image
|
||||
src={product.images[0]}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 ease-in-out"
|
||||
style={{
|
||||
transform: hoveredProduct === product.id && product.images.length > 1
|
||||
? 'scale(1.1)'
|
||||
: 'scale(1)'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => toggleFavorite(product.id, e)}
|
||||
className="absolute top-3 right-3 p-2 rounded-full bg-white/80 hover:bg-white transition-colors z-10"
|
||||
>
|
||||
<Heart
|
||||
className={`w-5 h-5 ${favorites.includes(product.id) ? 'fill-[#63823B] text-[#63823B]' : 'text-gray-600'}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-medium text-[#2B5F47] mb-1">{product.name}</h3>
|
||||
<p className="text-sm text-gray-600 mb-2 line-clamp-2">{product.description}</p>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-bold text-[#2B5F47]">{formatPrice(product.price)} ₽</span>
|
||||
<span className="text-sm text-[#63823B]">
|
||||
{product.inStock ? 'В наличии' : 'Нет в наличии'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-xl text-[#2B5F47] mb-4">В этой категории пока нет товаров</p>
|
||||
<Link href="/category" className="inline-block px-6 py-3 bg-[#63823B] text-white rounded-md hover:bg-[#2B5F47] transition-colors">
|
||||
Вернуться к категориям
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const paths = categories.map((category) => ({
|
||||
params: { slug: category.slug },
|
||||
}))
|
||||
|
||||
return {
|
||||
paths,
|
||||
fallback: 'blocking',
|
||||
}
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ({ params }) => {
|
||||
const slug = params?.slug as string
|
||||
const category = getCategoryBySlug(slug)
|
||||
|
||||
if (!category) {
|
||||
return {
|
||||
notFound: true,
|
||||
}
|
||||
}
|
||||
|
||||
const products = getProductsByCategory(category.id)
|
||||
|
||||
return {
|
||||
props: {
|
||||
category,
|
||||
products,
|
||||
},
|
||||
revalidate: 600, // Перегенерация страницы каждые 10 минут
|
||||
}
|
||||
}
|
||||
@ -1,81 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { motion } from 'framer-motion';
|
||||
import Header from '../../components/Header';
|
||||
import Footer from '../../components/Footer';
|
||||
import { Category, categories } from '../../data/categories';
|
||||
|
||||
export default function Categories() {
|
||||
const [hoveredCategory, setHoveredCategory] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#2B5F47] font-['Arimo']">
|
||||
<Head>
|
||||
<title>Каталог | Brand Store</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Arimo:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</Head>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="pt-24 pb-16 px-4 md:px-8">
|
||||
<div className="container mx-auto">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-3xl md:text-4xl font-bold mb-8 text-white text-center"
|
||||
>
|
||||
Каталог
|
||||
</motion.h1>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
>
|
||||
{categories.map((category, index) => (
|
||||
<motion.div
|
||||
key={category.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 * index }}
|
||||
className="relative overflow-hidden rounded-lg shadow-md bg-white"
|
||||
onMouseEnter={() => setHoveredCategory(category.id)}
|
||||
onMouseLeave={() => setHoveredCategory(null)}
|
||||
>
|
||||
<Link href={`/category/${category.slug}`}>
|
||||
<div className="relative h-80 w-full overflow-hidden">
|
||||
<Image
|
||||
src={category.image}
|
||||
alt={category.name}
|
||||
fill
|
||||
className={`object-cover transition-transform duration-500 ${
|
||||
hoveredCategory === category.id ? 'scale-110' : 'scale-100'
|
||||
}`}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#2B5F47]/70 to-transparent"></div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">{category.name}</h2>
|
||||
<p className="text-white/80 text-sm mb-4 line-clamp-2">{category.description}</p>
|
||||
<div className={`inline-block px-4 py-2 bg-[#63823B] text-white rounded-md transition-transform duration-300 ${
|
||||
hoveredCategory === category.id ? 'translate-y-0' : 'translate-y-8 opacity-0'
|
||||
}`}>
|
||||
Смотреть товары
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,822 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { ShoppingBag, CreditCard, Truck, Check, Plus, X } from 'lucide-react';
|
||||
import Header from '../components/Header';
|
||||
import Footer from '../components/Footer';
|
||||
import cartService, { Cart } from '../services/cart';
|
||||
import { orderService } from '../services/orders';
|
||||
import authService from '../services/auth';
|
||||
import { userService, Address, AddressCreate } from '../services/users';
|
||||
|
||||
// Типы для формы оформления заказа
|
||||
interface CheckoutForm {
|
||||
shipping_address_id: number;
|
||||
payment_method: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
// Типы для формы нового адреса
|
||||
interface AddressForm {
|
||||
address_line1: string;
|
||||
address_line2?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const router = useRouter();
|
||||
const [cart, setCart] = useState<Cart | null>(null);
|
||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [orderId, setOrderId] = useState<number | null>(null);
|
||||
const [showAddressForm, setShowAddressForm] = useState(false);
|
||||
const [addressFormSubmitting, setAddressFormSubmitting] = useState(false);
|
||||
const [addressFormError, setAddressFormError] = useState<string | null>(null);
|
||||
const [formValidated, setFormValidated] = useState(false);
|
||||
|
||||
// Состояние формы заказа
|
||||
const [form, setForm] = useState<CheckoutForm>({
|
||||
shipping_address_id: 0,
|
||||
payment_method: 'credit_card',
|
||||
notes: ''
|
||||
});
|
||||
|
||||
// Состояние формы нового адреса
|
||||
const [addressForm, setAddressForm] = useState<AddressForm>({
|
||||
address_line1: '',
|
||||
address_line2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postal_code: '',
|
||||
country: 'Россия',
|
||||
is_default: false
|
||||
});
|
||||
|
||||
// Проверка валидности формы
|
||||
useEffect(() => {
|
||||
// Форма валидна, если выбран адрес доставки и способ оплаты
|
||||
const isValid = form.shipping_address_id > 0 && !!form.payment_method;
|
||||
setFormValidated(isValid);
|
||||
}, [form.shipping_address_id, form.payment_method]);
|
||||
|
||||
// Загрузка корзины и адресов при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
router.push('/login?redirect=/checkout');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// Загружаем корзину
|
||||
const cartData = await cartService.getCart();
|
||||
setCart(cartData);
|
||||
|
||||
// Если корзина пуста, перенаправляем на страницу корзины
|
||||
if (cartData.items.length === 0) {
|
||||
router.push('/cart');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Загружаем адреса пользователя
|
||||
const userData = await userService.getCurrentUser();
|
||||
if (userData.addresses && userData.addresses.length > 0) {
|
||||
setAddresses(userData.addresses);
|
||||
|
||||
// Устанавливаем адрес по умолчанию, если он есть
|
||||
const defaultAddress = userData.addresses.find(addr => addr.is_default);
|
||||
if (defaultAddress) {
|
||||
setForm(prev => ({ ...prev, shipping_address_id: defaultAddress.id }));
|
||||
} else {
|
||||
setForm(prev => ({ ...prev, shipping_address_id: userData.addresses[0].id }));
|
||||
}
|
||||
} else {
|
||||
// Если у пользователя нет адресов, показываем форму добавления адреса
|
||||
setShowAddressForm(true);
|
||||
}
|
||||
} catch (addressErr) {
|
||||
console.error('Ошибка при загрузке адресов:', addressErr);
|
||||
// Не показываем ошибку пользователю, просто предлагаем добавить адрес
|
||||
setShowAddressForm(true);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных:', err);
|
||||
setError('Не удалось загрузить данные. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [router]);
|
||||
|
||||
// Обработчик изменения полей формы заказа
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Обработчик изменения полей формы адреса
|
||||
const handleAddressFormChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
// Для чекбокса используем checked, для остальных полей - value
|
||||
if (e.target instanceof HTMLInputElement && e.target.type === 'checkbox') {
|
||||
const checked = e.target.checked;
|
||||
setAddressForm(prev => ({
|
||||
...prev,
|
||||
[name]: checked
|
||||
}));
|
||||
} else {
|
||||
setAddressForm(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик отправки формы заказа
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formValidated) {
|
||||
setError('Пожалуйста, заполните все обязательные поля');
|
||||
// Если нет адресов, показываем форму добавления адреса
|
||||
if (addresses.length === 0) {
|
||||
setShowAddressForm(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем, что в корзине есть товары
|
||||
if (!cart || cart.items.length === 0) {
|
||||
setError('Ваша корзина пуста. Добавьте товары в корзину, чтобы оформить заказ.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
console.log('Отправка заказа на сервер:', {
|
||||
shipping_address_id: form.shipping_address_id,
|
||||
payment_method: form.payment_method,
|
||||
notes: form.notes
|
||||
});
|
||||
|
||||
// Создаем заказ
|
||||
const orderResponse = await orderService.createOrder({
|
||||
shipping_address_id: form.shipping_address_id,
|
||||
payment_method: form.payment_method,
|
||||
notes: form.notes
|
||||
});
|
||||
|
||||
console.log('Получен ответ от сервера:', orderResponse);
|
||||
|
||||
// Извлекаем ID заказа из ответа
|
||||
let orderId = null;
|
||||
|
||||
// Проверяем разные форматы ответа
|
||||
const responseObj = orderResponse as any;
|
||||
if (responseObj && responseObj.id) {
|
||||
orderId = responseObj.id;
|
||||
} else if (responseObj && responseObj.order && responseObj.order.id) {
|
||||
orderId = responseObj.order.id;
|
||||
}
|
||||
|
||||
// Устанавливаем флаг успешного создания заказа
|
||||
setSuccess(true);
|
||||
setOrderId(orderId);
|
||||
|
||||
// Очищаем корзину после успешного оформления заказа
|
||||
try {
|
||||
await cartService.clearCart();
|
||||
} catch (clearErr) {
|
||||
console.error('Ошибка при очистке корзины:', clearErr);
|
||||
// Не показываем эту ошибку пользователю, так как заказ уже создан
|
||||
}
|
||||
|
||||
// Проверяем, что ID заказа существует и является числом
|
||||
if (orderId && !isNaN(orderId)) {
|
||||
// Перенаправляем на страницу успешного оформления заказа
|
||||
router.push(`/order-success?id=${orderId}`);
|
||||
} else {
|
||||
console.error('Ошибка: ID заказа отсутствует или некорректен', orderResponse);
|
||||
// Перенаправляем на страницу заказов без указания ID
|
||||
router.push('/account/orders');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при оформлении заказа:', err);
|
||||
|
||||
// Выводим подробную информацию об ошибке в консоль
|
||||
if (err.response) {
|
||||
console.error('Статус ответа:', err.response.status);
|
||||
console.error('Данные ответа:', err.response.data);
|
||||
}
|
||||
|
||||
// Проверяем, есть ли в ответе сервера сообщение об ошибке
|
||||
let errorMessage = 'Не удалось оформить заказ. Пожалуйста, попробуйте позже.';
|
||||
if (err.response && err.response.data) {
|
||||
if (typeof err.response.data === 'string') {
|
||||
errorMessage = err.response.data;
|
||||
} else if (err.response.data.detail) {
|
||||
if (Array.isArray(err.response.data.detail)) {
|
||||
// Если detail - это массив ошибок валидации
|
||||
const validationErrors = err.response.data.detail.map(error =>
|
||||
`${error.loc.join('.')}: ${error.msg}`
|
||||
).join('; ');
|
||||
errorMessage = `Ошибка валидации: ${validationErrors}`;
|
||||
} else {
|
||||
errorMessage = err.response.data.detail;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
|
||||
// Прокручиваем страницу вверх, чтобы показать сообщение об ошибке
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик отправки формы нового адреса
|
||||
const handleAddressSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); // Предотвращаем стандартное поведение формы
|
||||
|
||||
// Валидация формы
|
||||
if (!addressForm.address_line1 || !addressForm.city || !addressForm.state || !addressForm.postal_code) {
|
||||
setAddressFormError('Пожалуйста, заполните все обязательные поля');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setAddressFormSubmitting(true);
|
||||
setAddressFormError(null);
|
||||
|
||||
console.log('Отправка адреса на сервер:', addressForm);
|
||||
|
||||
// Создаем новый адрес
|
||||
const newAddress = await userService.addAddress(addressForm);
|
||||
|
||||
console.log('Получен ответ от сервера:', newAddress);
|
||||
|
||||
// Добавляем новый адрес в список и выбираем его
|
||||
setAddresses(prev => [...prev, newAddress]);
|
||||
setForm(prev => ({ ...prev, shipping_address_id: newAddress.id }));
|
||||
|
||||
// Скрываем форму добавления адреса
|
||||
setShowAddressForm(false);
|
||||
|
||||
// Сбрасываем форму
|
||||
setAddressForm({
|
||||
address_line1: '',
|
||||
address_line2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postal_code: '',
|
||||
country: 'Россия',
|
||||
is_default: false
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ошибка при добавлении адреса:', err);
|
||||
|
||||
// Выводим подробную информацию об ошибке в консоль
|
||||
if (err.response) {
|
||||
console.error('Ответ сервера:', err.response.status, err.response.data);
|
||||
}
|
||||
|
||||
// Проверяем, есть ли в ответе сервера сообщение об ошибке
|
||||
let errorMessage = 'Не удалось добавить адрес. Пожалуйста, попробуйте позже.';
|
||||
if (err.response && err.response.data) {
|
||||
if (typeof err.response.data === 'string') {
|
||||
errorMessage = err.response.data;
|
||||
} else if (err.response.data.detail) {
|
||||
if (Array.isArray(err.response.data.detail)) {
|
||||
// Если detail - это массив ошибок валидации
|
||||
const validationErrors = err.response.data.detail.map(error =>
|
||||
`${error.loc.join('.')}: ${error.msg}`
|
||||
).join('; ');
|
||||
errorMessage = `Ошибка валидации: ${validationErrors}`;
|
||||
} else {
|
||||
errorMessage = err.response.data.detail;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAddressFormError(errorMessage);
|
||||
} finally {
|
||||
setAddressFormSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Форматирование цены
|
||||
const formatPrice = (price: number): string => {
|
||||
return price.toLocaleString('ru-RU') + ' ₽';
|
||||
};
|
||||
|
||||
// Функция для корректного отображения URL изображения
|
||||
const getImageUrl = (imageUrl: string | undefined): string => {
|
||||
if (!imageUrl) return '/placeholder-image.jpg';
|
||||
|
||||
// Проверяем, начинается ли URL с http или https
|
||||
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
// Формируем базовый URL для изображений
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8000/api';
|
||||
const apiBaseUrl = baseUrl.replace(/\/api$/, ''); // Убираем '/api' в конце, если есть
|
||||
|
||||
// Если URL начинается с /, добавляем базовый URL API
|
||||
if (imageUrl.startsWith('/')) {
|
||||
return `${apiBaseUrl}${imageUrl}`;
|
||||
}
|
||||
|
||||
// В остальных случаях добавляем базовый URL API и /
|
||||
return `${apiBaseUrl}/${imageUrl}`;
|
||||
};
|
||||
|
||||
// Форматирование адреса для отображения
|
||||
const formatAddress = (address: Address): string => {
|
||||
let formattedAddress = address.address_line1;
|
||||
if (address.address_line2) {
|
||||
formattedAddress += `, ${address.address_line2}`;
|
||||
}
|
||||
return `${formattedAddress}, ${address.city}, ${address.state}, ${address.postal_code}, ${address.country}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
|
||||
<main className="flex-grow pt-24 pb-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h1 className="text-2xl md:text-3xl font-bold mb-8 text-center">Оформление заказа</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
) : error && !success ? (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
) : success ? (
|
||||
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-8 rounded mb-4 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="bg-green-500 rounded-full p-2">
|
||||
<Check className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Заказ успешно оформлен!</h2>
|
||||
<p className="mb-4">Номер вашего заказа: {orderId}</p>
|
||||
<p>Вы будете перенаправлены на страницу заказа через несколько секунд...</p>
|
||||
</div>
|
||||
) : cart ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Форма оформления заказа */}
|
||||
<div className="lg:col-span-2">
|
||||
<form id="checkout-form" onSubmit={handleSubmit}>
|
||||
{/* Адрес доставки */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden mb-6">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold flex items-center">
|
||||
<Truck className="w-5 h-5 mr-2" />
|
||||
Адрес доставки
|
||||
</h2>
|
||||
|
||||
{!showAddressForm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddressForm(true)}
|
||||
className="text-sm flex items-center text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Добавить новый адрес
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddressForm ? (
|
||||
<div className="border rounded-md p-4 mb-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium">Новый адрес доставки</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddressForm(false)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{addressFormError && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-3 py-2 rounded mb-3 text-sm">
|
||||
{addressFormError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAddressSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="address_line1" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Адрес (строка 1)*
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="address_line1"
|
||||
name="address_line1"
|
||||
value={addressForm.address_line1}
|
||||
onChange={handleAddressFormChange}
|
||||
placeholder="Улица, дом"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="address_line2" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Адрес (строка 2)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="address_line2"
|
||||
name="address_line2"
|
||||
value={addressForm.address_line2}
|
||||
onChange={handleAddressFormChange}
|
||||
placeholder="Квартира, офис (необязательно)"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="city" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Город*
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="city"
|
||||
name="city"
|
||||
value={addressForm.city}
|
||||
onChange={handleAddressFormChange}
|
||||
placeholder="Город"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="state" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Область/Регион*
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="state"
|
||||
name="state"
|
||||
value={addressForm.state}
|
||||
onChange={handleAddressFormChange}
|
||||
placeholder="Область или регион"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="postal_code" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Почтовый индекс*
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
value={addressForm.postal_code}
|
||||
onChange={handleAddressFormChange}
|
||||
placeholder="Индекс"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="country" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Страна*
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="country"
|
||||
name="country"
|
||||
value={addressForm.country}
|
||||
onChange={handleAddressFormChange}
|
||||
placeholder="Страна"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_default"
|
||||
name="is_default"
|
||||
checked={addressForm.is_default}
|
||||
onChange={handleAddressFormChange}
|
||||
className="h-4 w-4 text-black focus:ring-black border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="is_default" className="ml-2 block text-sm text-gray-700">
|
||||
Использовать как адрес по умолчанию
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addressFormSubmitting}
|
||||
className="bg-black text-white px-4 py-2 rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{addressFormSubmitting ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-white mr-2"></div>
|
||||
Сохранение...
|
||||
</div>
|
||||
) : (
|
||||
'Сохранить адрес'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{addresses.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{addresses.map(address => (
|
||||
<div key={address.id} className="border rounded-md p-4">
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
type="radio"
|
||||
id={`address-${address.id}`}
|
||||
name="shipping_address_id"
|
||||
value={address.id}
|
||||
checked={form.shipping_address_id === address.id}
|
||||
onChange={handleChange}
|
||||
className="mt-1 mr-3"
|
||||
/>
|
||||
<label htmlFor={`address-${address.id}`} className="flex-grow cursor-pointer">
|
||||
<div className="font-medium">Адрес доставки</div>
|
||||
<div className="text-sm text-gray-600 mt-1">{formatAddress(address)}</div>
|
||||
{address.is_default && (
|
||||
<div className="text-sm text-green-600 mt-1">Адрес по умолчанию</div>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !showAddressForm ? (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-gray-600 mb-4">У вас еще нет сохраненных адресов</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddressForm(true)}
|
||||
className="text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
Добавить новый адрес
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Способ оплаты */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden mb-6">
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<CreditCard className="w-5 h-5 mr-2" />
|
||||
Способ оплаты
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="border rounded-md p-4">
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
type="radio"
|
||||
id="payment-card"
|
||||
name="payment_method"
|
||||
value="credit_card"
|
||||
checked={form.payment_method === 'credit_card'}
|
||||
onChange={handleChange}
|
||||
className="mt-1 mr-3"
|
||||
/>
|
||||
<label htmlFor="payment-card" className="flex-grow cursor-pointer">
|
||||
<div className="font-medium">Банковская карта</div>
|
||||
<div className="text-sm text-gray-600 mt-1">Оплата картой при получении</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md p-4">
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
type="radio"
|
||||
id="payment-cash"
|
||||
name="payment_method"
|
||||
value="cash_on_delivery"
|
||||
checked={form.payment_method === 'cash_on_delivery'}
|
||||
onChange={handleChange}
|
||||
className="mt-1 mr-3"
|
||||
/>
|
||||
<label htmlFor="payment-cash" className="flex-grow cursor-pointer">
|
||||
<div className="font-medium">Наличные</div>
|
||||
<div className="text-sm text-gray-600 mt-1">Оплата наличными при получении</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md p-4">
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
type="radio"
|
||||
id="payment-paypal"
|
||||
name="payment_method"
|
||||
value="paypal"
|
||||
checked={form.payment_method === 'paypal'}
|
||||
onChange={handleChange}
|
||||
className="mt-1 mr-3"
|
||||
/>
|
||||
<label htmlFor="payment-paypal" className="flex-grow cursor-pointer">
|
||||
<div className="font-medium">PayPal</div>
|
||||
<div className="text-sm text-gray-600 mt-1">Оплата через PayPal</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md p-4">
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
type="radio"
|
||||
id="payment-bank"
|
||||
name="payment_method"
|
||||
value="bank_transfer"
|
||||
checked={form.payment_method === 'bank_transfer'}
|
||||
onChange={handleChange}
|
||||
className="mt-1 mr-3"
|
||||
/>
|
||||
<label htmlFor="payment-bank" className="flex-grow cursor-pointer">
|
||||
<div className="font-medium">Банковский перевод</div>
|
||||
<div className="text-sm text-gray-600 mt-1">Оплата банковским переводом</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Комментарий к заказу */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden mb-6">
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Комментарий к заказу</h2>
|
||||
|
||||
<textarea
|
||||
name="notes"
|
||||
value={form.notes}
|
||||
onChange={handleChange}
|
||||
placeholder="Дополнительная информация к заказу"
|
||||
className="w-full border border-gray-300 rounded-md px-4 py-2 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:hidden">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !formValidated}
|
||||
className="w-full bg-black text-white py-3 rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white mr-2"></div>
|
||||
Оформление...
|
||||
</div>
|
||||
) : (
|
||||
'Оформить заказ'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Сводка заказа */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-lg shadow-md p-6 sticky top-24">
|
||||
<h2 className="text-lg font-semibold mb-6">Ваш заказ</h2>
|
||||
|
||||
<div className="space-y-4 mb-6 max-h-80 overflow-y-auto">
|
||||
{cart.items.map((item) => (
|
||||
<div key={item.id} className="flex items-start py-2">
|
||||
<div className="flex-shrink-0 w-16 h-16 mr-4 relative">
|
||||
{item.product_image ? (
|
||||
<Image
|
||||
src={getImageUrl(item.product_image)}
|
||||
alt={item.product_name}
|
||||
fill
|
||||
className="object-cover rounded-md"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200 rounded-md flex items-center justify-center">
|
||||
<ShoppingBag className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<h3 className="text-sm font-medium">{item.product_name}</h3>
|
||||
<p className="text-xs text-gray-500">Вариант: {item.variant_name}</p>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-xs text-gray-500">Кол-во: {item.quantity}</span>
|
||||
<span className="text-sm font-medium">{item.total_price.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 space-y-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Товары ({cart.items_count})</span>
|
||||
<span>{cart.total_amount.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Доставка</span>
|
||||
<span>Бесплатно</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-4 flex justify-between font-semibold">
|
||||
<span>Итого</span>
|
||||
<span>{cart.total_amount.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<button
|
||||
type="submit"
|
||||
form="checkout-form"
|
||||
disabled={submitting || !formValidated}
|
||||
className="w-full bg-black text-white py-3 rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white mr-2"></div>
|
||||
Оформление...
|
||||
</div>
|
||||
) : (
|
||||
'Оформить заказ'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Link href="/cart" className="text-center block text-gray-600 hover:text-gray-800 transition-colors">
|
||||
Вернуться в корзину
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<div className="flex justify-center mb-6">
|
||||
<ShoppingBag className="w-16 h-16 text-gray-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Ваша корзина пуста</h2>
|
||||
<p className="text-gray-600 mb-8">Добавьте товары в корзину, чтобы оформить заказ</p>
|
||||
<Link href="/all-products" className="bg-black text-white px-6 py-3 rounded-md hover:bg-gray-800 transition-colors">
|
||||
Перейти к покупкам
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,200 +0,0 @@
|
||||
import { GetStaticPaths, GetStaticProps } from "next"
|
||||
import Head from "next/head"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { useState } from "react"
|
||||
import { Heart } from "lucide-react"
|
||||
import Header from "../../components/Header"
|
||||
import Footer from "../../components/Footer"
|
||||
import { collections, getCollectionBySlug } from "../../data/collections"
|
||||
import { Product, formatPrice, getProductsByCollection } from "../../data/products"
|
||||
import { useRouter } from "next/router"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
interface CollectionPageProps {
|
||||
collection: {
|
||||
id: number
|
||||
name: string
|
||||
image: string
|
||||
description: string
|
||||
url: string
|
||||
slug: string
|
||||
}
|
||||
collectionProducts: Product[]
|
||||
}
|
||||
|
||||
export default function CollectionPage({ collection, collectionProducts }: CollectionPageProps) {
|
||||
const router = useRouter()
|
||||
const [hoveredProduct, setHoveredProduct] = useState<number | null>(null)
|
||||
const [favorites, setFavorites] = useState<number[]>([])
|
||||
|
||||
// Если страница еще загружается, показываем заглушку
|
||||
if (router.isFallback) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Функция для добавления/удаления товара из избранного
|
||||
const toggleFavorite = (id: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
setFavorites((prev) => (prev.includes(id) ? prev.filter((itemId) => itemId !== id) : [...prev, id]))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white font-['Arimo']">
|
||||
<Head>
|
||||
<title>{collection.name} | Brand Store</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<Header />
|
||||
|
||||
<main>
|
||||
{/* Баннер коллекции */}
|
||||
<div className="relative h-[50vh] md:h-[60vh]">
|
||||
<Image
|
||||
src={collection.image}
|
||||
alt={collection.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
quality={95}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-40 flex items-center justify-center">
|
||||
<div className="text-center text-white px-4">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-3xl md:text-5xl font-bold mb-4 font-['Playfair_Display']"
|
||||
>
|
||||
{collection.name}
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="max-w-2xl mx-auto text-lg"
|
||||
>
|
||||
{collection.description}
|
||||
</motion.p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Список товаров */}
|
||||
<section className="py-12 px-4 md:px-8 max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<Link href="/collections" className="text-gray-600 hover:text-black transition-colors">
|
||||
← Все коллекции
|
||||
</Link>
|
||||
<h2 className="text-2xl md:text-3xl font-bold mt-4 font-['Playfair_Display']">
|
||||
Товары из коллекции
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{collectionProducts.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{collectionProducts.map((product) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: product.id * 0.05 }}
|
||||
whileHover={{ y: -5, transition: { duration: 0.2 } }}
|
||||
>
|
||||
<Link
|
||||
href={`/product/${product.slug}`}
|
||||
className="block h-full"
|
||||
onMouseEnter={() => setHoveredProduct(product.id)}
|
||||
onMouseLeave={() => setHoveredProduct(null)}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-xl">
|
||||
<div className="aspect-[3/4] relative overflow-hidden rounded-xl">
|
||||
<Image
|
||||
src={
|
||||
hoveredProduct === product.id && product.images.length > 1
|
||||
? product.images[1]
|
||||
: product.images[0]
|
||||
}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
||||
className="object-cover transition-all duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{product.isNew && (
|
||||
<span className="absolute top-4 left-4 bg-black text-white text-sm py-1 px-3 rounded">
|
||||
Новинка
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => toggleFavorite(product.id, e)}
|
||||
className="absolute top-4 right-4 bg-white/80 hover:bg-white rounded-full p-2 transition-all"
|
||||
aria-label={favorites.includes(product.id) ? "Удалить из избранного" : "Добавить в избранное"}
|
||||
>
|
||||
<Heart
|
||||
className={`w-5 h-5 ${favorites.includes(product.id) ? "fill-red-500 text-red-500" : "text-gray-700"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-medium">{product.name}</h3>
|
||||
<p className="mt-1 text-lg font-bold">{formatPrice(product.price)} ₽</p>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-xl text-gray-600">В этой коллекции пока нет товаров</p>
|
||||
<Link href="/" className="mt-4 inline-block bg-black text-white px-6 py-2 rounded-md hover:bg-gray-800 transition-colors">
|
||||
Вернуться на главную
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const paths = collections.map((collection) => ({
|
||||
params: { slug: collection.slug },
|
||||
}))
|
||||
|
||||
return {
|
||||
paths,
|
||||
fallback: 'blocking',
|
||||
}
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ({ params }) => {
|
||||
const slug = params?.slug as string
|
||||
const collection = getCollectionBySlug(slug)
|
||||
|
||||
if (!collection) {
|
||||
return {
|
||||
notFound: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Получаем товары, принадлежащие к данной коллекции
|
||||
const collectionProducts = getProductsByCollection(collection.id)
|
||||
|
||||
return {
|
||||
props: {
|
||||
collection,
|
||||
collectionProducts,
|
||||
},
|
||||
revalidate: 600, // Перегенерация страницы каждые 10 минут
|
||||
}
|
||||
}
|
||||
@ -1,81 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { motion } from 'framer-motion';
|
||||
import Header from '../../components/Header';
|
||||
import Footer from '../../components/Footer';
|
||||
import { Collection, collections } from '../../data/collections';
|
||||
|
||||
export default function Collections() {
|
||||
const [hoveredCollection, setHoveredCollection] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#2B5F47] font-['Arimo']">
|
||||
<Head>
|
||||
<title>Коллекции | Brand Store</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Arimo:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</Head>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="pt-24 pb-16 px-4 md:px-8">
|
||||
<div className="container mx-auto">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-3xl md:text-4xl font-bold mb-8 text-white text-center"
|
||||
>
|
||||
Наши коллекции
|
||||
</motion.h1>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-8"
|
||||
>
|
||||
{collections.map((collection, index) => (
|
||||
<motion.div
|
||||
key={collection.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 * index }}
|
||||
className="relative overflow-hidden rounded-lg shadow-md bg-white"
|
||||
onMouseEnter={() => setHoveredCollection(collection.id)}
|
||||
onMouseLeave={() => setHoveredCollection(null)}
|
||||
>
|
||||
<Link href={`/collections/${collection.slug}`}>
|
||||
<div className="relative h-96 w-full overflow-hidden">
|
||||
<Image
|
||||
src={collection.image}
|
||||
alt={collection.name}
|
||||
fill
|
||||
className={`object-cover transition-transform duration-500 ${
|
||||
hoveredCollection === collection.id ? 'scale-110' : 'scale-100'
|
||||
}`}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#2B5F47]/80 to-transparent"></div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-8">
|
||||
<h2 className="text-2xl font-semibold text-white mb-3">{collection.name}</h2>
|
||||
<p className="text-white/90 text-sm mb-6 line-clamp-3">{collection.description}</p>
|
||||
<div className={`inline-block px-5 py-2 bg-[#63823B] text-white rounded-md transition-transform duration-300 ${
|
||||
hoveredCollection === collection.id ? 'translate-y-0' : 'translate-y-8 opacity-0'
|
||||
}`}>
|
||||
Смотреть коллекцию
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,130 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { Mail, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import authService from '../services/auth';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleChange = (e) => {
|
||||
setEmail(e.target.value);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
// Предполагаем, что в authService есть метод resetPassword
|
||||
// Если его нет, нужно будет добавить
|
||||
await authService.resetPassword(email);
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при запросе сброса пароля:', err);
|
||||
setError('Не удалось отправить запрос на сброс пароля. Пожалуйста, проверьте введенный email.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Восстановление пароля
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Введите email, указанный при регистрации, и мы отправим вам инструкции по сбросу пароля.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success ? (
|
||||
<div className="text-center">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<CheckCircle className="h-12 w-12 text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Проверьте вашу почту</h3>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Мы отправили инструкции по сбросу пароля на адрес {email}.
|
||||
Если вы не получили письмо в течение нескольких минут, проверьте папку "Спам".
|
||||
</p>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<button
|
||||
onClick={() => setSuccess(false)}
|
||||
className="w-full flex justify-center py-2 px-4 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"
|
||||
>
|
||||
Отправить еще раз
|
||||
</button>
|
||||
<Link href="/login" className="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Вернуться на страницу входа
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="example@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Отправка...' : 'Отправить инструкции'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Link href="/login" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Вернуться на страницу входа
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,148 +0,0 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import { useState, useEffect } from 'react';
|
||||
import Header from '../components/Header';
|
||||
import Hero from '../components/Hero';
|
||||
import CookieNotification from '../components/CookieNotification';
|
||||
import TabSelector from '../components/TabSelector';
|
||||
import NewArrivals from '../components/NewArrivals';
|
||||
import Collections from '../components/Collections';
|
||||
import PopularCategories from '../components/PopularCategories';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { Heart } from 'lucide-react';
|
||||
import Footer from '../components/Footer';
|
||||
import { Product, products as allProducts } from '../data/products';
|
||||
import { Collection, collections as allCollections } from '../data/collections';
|
||||
import { Category, categories as allCategories } from '../data/categories';
|
||||
|
||||
// Типы для свойств компонента
|
||||
interface HomeProps {
|
||||
heroImages: string[];
|
||||
products: Product[];
|
||||
collections: Collection[];
|
||||
categories: Category[];
|
||||
}
|
||||
|
||||
export default function Home({ heroImages, products, collections, categories }: HomeProps) {
|
||||
// Состояние для отслеживания наведения на карточки товаров
|
||||
const [hoveredProduct, setHoveredProduct] = useState<number | null>(null);
|
||||
// Состояние для отслеживания избранных товаров
|
||||
const [favorites, setFavorites] = useState<number[]>([]);
|
||||
// Состояние для отслеживания активного таба
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
// Состояние для отслеживания прокрутки страницы
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
// Эффект для отслеживания прокрутки
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const isScrolled = window.scrollY > 50;
|
||||
if (isScrolled !== scrolled) {
|
||||
setScrolled(isScrolled);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [scrolled]);
|
||||
|
||||
// Функция для добавления/удаления товара из избранного
|
||||
const toggleFavorite = (id: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setFavorites(prev =>
|
||||
prev.includes(id)
|
||||
? prev.filter(itemId => itemId !== id)
|
||||
: [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
// Функция для обработки изменения активного таба
|
||||
const handleTabChange = (index: number) => {
|
||||
setActiveTab(index);
|
||||
};
|
||||
|
||||
// Рендерим контент в зависимости от активного таба
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 0:
|
||||
return <NewArrivals products={products} />;
|
||||
case 1:
|
||||
return <Collections collections={collections} />;
|
||||
case 2:
|
||||
return <PopularCategories categories={categories} />;
|
||||
default:
|
||||
return <NewArrivals products={products} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white font-['Arimo']">
|
||||
<Head>
|
||||
<title>Brand Store | Элегантная мода</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Arimo:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</Head>
|
||||
|
||||
<Header />
|
||||
|
||||
<main>
|
||||
{/* Секция с HERO элементом */}
|
||||
<Hero images={heroImages} />
|
||||
|
||||
{/* Табы для выбора категорий */}
|
||||
<TabSelector onTabChange={handleTabChange} />
|
||||
|
||||
{/* Контент в зависимости от выбранного таба */}
|
||||
{renderTabContent()}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
|
||||
<CookieNotification />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Функция для получения данных на стороне сервера
|
||||
export async function getStaticProps() {
|
||||
// Получение изображений для слайдера из папки hero_photos
|
||||
const heroImagesDirectory = path.join(process.cwd(), 'public/hero_photos');
|
||||
let heroImages = [];
|
||||
|
||||
try {
|
||||
const fileNames = fs.readdirSync(heroImagesDirectory);
|
||||
// Фильтруем только изображения
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif'];
|
||||
const imageFiles = fileNames.filter(file =>
|
||||
imageExtensions.some(ext => file.toLowerCase().endsWith(ext))
|
||||
);
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
heroImages = imageFiles.map(fileName => `/hero_photos/${fileName}`);
|
||||
} else {
|
||||
// Если нет изображений, используем изображения из папки photos
|
||||
heroImages = ['/photos/head_photo.png'];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading hero_photos directory:', error);
|
||||
// Если папка не существует или пуста, используем изображение из папки photos
|
||||
heroImages = ['/photos/head_photo.png'];
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
heroImages,
|
||||
products: allProducts,
|
||||
collections: allCollections,
|
||||
categories: allCategories
|
||||
},
|
||||
// Перегенерация страницы каждые 10 минут
|
||||
revalidate: 600,
|
||||
};
|
||||
}
|
||||
@ -1,200 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { Mail, Lock, AlertCircle } from 'lucide-react';
|
||||
import authService from '../services/auth';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [redirectTo, setRedirectTo] = useState<string | null>(null);
|
||||
|
||||
// Проверяем, есть ли параметр redirect в URL
|
||||
useEffect(() => {
|
||||
if (router.query.redirect) {
|
||||
setRedirectTo(router.query.redirect as string);
|
||||
}
|
||||
|
||||
// Если пользователь уже авторизован, перенаправляем его
|
||||
if (authService.isAuthenticated()) {
|
||||
router.push(redirectTo || '/');
|
||||
}
|
||||
}, [router.query.redirect, redirectTo, router]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await authService.login(formData);
|
||||
|
||||
// Перенаправляем пользователя после успешного входа
|
||||
router.push(redirectTo || '/');
|
||||
} catch (err) {
|
||||
console.error('Ошибка при входе:', err);
|
||||
setError('Неверный email или пароль. Пожалуйста, проверьте введенные данные.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Вход в аккаунт
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Или{' '}
|
||||
<Link href="/register" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
зарегистрируйтесь, если у вас еще нет аккаунта
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
||||
Имя пользователя или Email
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="username или example@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Пароль
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember_me"
|
||||
name="remember_me"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="remember_me" className="ml-2 block text-sm text-gray-900">
|
||||
Запомнить меня
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<Link href="/forgot-password" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Забыли пароль?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Вход...' : 'Войти'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Или войдите через</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<a
|
||||
href="#"
|
||||
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
<span className="sr-only">Войти через Google</span>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a
|
||||
href="#"
|
||||
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
<span className="sr-only">Войти через Facebook</span>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Heart } from 'lucide-react';
|
||||
import Header from '../../components/Header';
|
||||
import Footer from '../../components/Footer';
|
||||
import { Product, products } from '../../data/products';
|
||||
|
||||
export default function NewArrivals() {
|
||||
const [hoveredProduct, setHoveredProduct] = useState<number | null>(null);
|
||||
const [favorites, setFavorites] = useState<number[]>([]);
|
||||
|
||||
// Фильтруем только новые товары
|
||||
const newProducts = products.filter(product => product.isNew);
|
||||
|
||||
// Функция для добавления/удаления товара из избранного
|
||||
const toggleFavorite = (id: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setFavorites(prev =>
|
||||
prev.includes(id)
|
||||
? prev.filter(itemId => itemId !== id)
|
||||
: [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#2B5F47] font-['Arimo']">
|
||||
<Head>
|
||||
<title>Новинки | Brand Store</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Arimo:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</Head>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="pt-24 pb-16 px-4 md:px-8">
|
||||
<div className="container mx-auto">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-3xl md:text-4xl font-bold mb-8 text-[#2B5F47] text-center"
|
||||
>
|
||||
Новые поступления
|
||||
</motion.h1>
|
||||
|
||||
{newProducts.length > 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||
>
|
||||
{newProducts.map((product, index) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 * index }}
|
||||
className="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-lg transition-shadow"
|
||||
onMouseEnter={() => setHoveredProduct(product.id)}
|
||||
onMouseLeave={() => setHoveredProduct(null)}
|
||||
>
|
||||
<Link href={`/product/${product.slug}`}>
|
||||
<div className="relative h-72 w-full overflow-hidden">
|
||||
<Image
|
||||
src={hoveredProduct === product.id && product.images.length > 1
|
||||
? product.images[1]
|
||||
: product.images[0]}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 ease-in-out"
|
||||
/>
|
||||
<div className="absolute top-3 left-3 bg-[#63823B] text-white text-xs py-1 px-3 rounded-md">
|
||||
Новинка
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => toggleFavorite(product.id, e)}
|
||||
className="absolute top-3 right-3 p-2 rounded-full bg-white/80 hover:bg-white transition-colors z-10"
|
||||
>
|
||||
<Heart
|
||||
className={`w-5 h-5 ${favorites.includes(product.id) ? 'fill-[#63823B] text-[#63823B]' : 'text-gray-600'}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-medium text-[#2B5F47] mb-1">{product.name}</h3>
|
||||
{/* <p className="text-sm text-gray-600 mb-2 line-clamp-2">{product.description}</p> */}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-bold text-[#2B5F47]">{product.price} ₽</span>
|
||||
<span className="text-sm text-[#63823B]">
|
||||
{product.inStock ? 'В наличии' : 'Нет в наличии'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-xl text-[#2B5F47] mb-4">Новинок пока нет</p>
|
||||
<Link href="/" className="inline-block px-6 py-3 bg-[#63823B] text-white rounded-md hover:bg-[#2B5F47] transition-colors">
|
||||
Вернуться на главную
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,142 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { orderService } from '../services/orders';
|
||||
import authService from '../services/auth';
|
||||
|
||||
export default function OrderSuccessPage() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [orderDetails, setOrderDetails] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
router.push('/login?redirect=/account/orders');
|
||||
return;
|
||||
}
|
||||
|
||||
// Загружаем данные заказа, если есть ID
|
||||
if (id && !isNaN(Number(id))) {
|
||||
const fetchOrderDetails = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const orderId = Number(id);
|
||||
const orderData = await orderService.getOrderById(orderId);
|
||||
setOrderDetails(orderData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных заказа:', err);
|
||||
setError('Не удалось загрузить данные заказа. Пожалуйста, проверьте историю заказов в личном кабинете.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchOrderDetails();
|
||||
} else {
|
||||
setLoading(false);
|
||||
if (id && isNaN(Number(id))) {
|
||||
setError('Некорректный номер заказа. Пожалуйста, проверьте историю заказов в личном кабинете.');
|
||||
}
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto py-8 text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-gray-900 mx-auto"></div>
|
||||
<h2 className="text-xl mt-4">
|
||||
Загрузка информации о заказе...
|
||||
</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="bg-white rounded-lg shadow-md p-6 text-center">
|
||||
<h1 className="text-2xl text-red-600 mb-4">
|
||||
Произошла ошибка
|
||||
</h1>
|
||||
<p className="mb-4">
|
||||
{error}
|
||||
</p>
|
||||
<Link
|
||||
href="/account/orders"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
|
||||
>
|
||||
Перейти к истории заказов
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="bg-white rounded-lg shadow-md p-6 text-center">
|
||||
<h1 className="text-2xl text-red-600 mb-4">
|
||||
Информация о заказе не найдена
|
||||
</h1>
|
||||
<p className="mb-4">
|
||||
Не указан номер заказа. Пожалуйста, проверьте историю заказов в личном кабинете.
|
||||
</p>
|
||||
<Link
|
||||
href="/account/orders"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
|
||||
>
|
||||
Перейти к истории заказов
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-green-500 text-6xl mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-2">
|
||||
Заказ успешно оформлен!
|
||||
</h1>
|
||||
<h2 className="text-xl text-gray-600">
|
||||
Номер заказа: {id}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p className="mb-4">
|
||||
Спасибо за ваш заказ! Мы отправили подтверждение на вашу электронную почту.
|
||||
</p>
|
||||
|
||||
<p className="mb-4">
|
||||
Вы можете отслеживать статус вашего заказа в личном кабинете.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex justify-center gap-4">
|
||||
<Link
|
||||
href="/account/orders"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
|
||||
>
|
||||
Мои заказы
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="border border-gray-300 px-4 py-2 rounded hover:bg-gray-50"
|
||||
>
|
||||
Вернуться к покупкам
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,291 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { Search, Package, Truck, CheckCircle, Clock, AlertCircle } from 'lucide-react';
|
||||
import Header from '../components/Header';
|
||||
import Footer from '../components/Footer';
|
||||
import { orderService, Order } from '../services/orders';
|
||||
|
||||
export default function OrderTrackingPage() {
|
||||
const router = useRouter();
|
||||
const { id: initialOrderId } = router.query;
|
||||
|
||||
const [orderNumber, setOrderNumber] = useState<string>(initialOrderId as string || '');
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searched, setSearched] = useState(false);
|
||||
|
||||
// Обработчик поиска заказа
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!orderNumber || orderNumber.trim() === '') {
|
||||
setError('Пожалуйста, введите номер заказа');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const orderData = await orderService.getOrderById(Number(orderNumber));
|
||||
setOrder(orderData);
|
||||
setSearched(true);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при поиске заказа:', err);
|
||||
setError('Заказ не найден или у вас нет доступа к нему');
|
||||
setOrder(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Получение информации о статусе заказа
|
||||
const getOrderStatusInfo = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return {
|
||||
label: 'Ожидает оплаты',
|
||||
color: 'text-yellow-600',
|
||||
bgColor: 'bg-yellow-50',
|
||||
icon: <Clock className="h-5 w-5" />,
|
||||
description: 'Ваш заказ создан и ожидает оплаты. После подтверждения оплаты мы начнем его обработку.'
|
||||
};
|
||||
case 'processing':
|
||||
return {
|
||||
label: 'В обработке',
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50',
|
||||
icon: <Clock className="h-5 w-5" />,
|
||||
description: 'Мы обрабатываем ваш заказ. Наши сотрудники собирают товары и готовят их к отправке.'
|
||||
};
|
||||
case 'shipped':
|
||||
return {
|
||||
label: 'Отправлен',
|
||||
color: 'text-indigo-600',
|
||||
bgColor: 'bg-indigo-50',
|
||||
icon: <Truck className="h-5 w-5" />,
|
||||
description: 'Ваш заказ отправлен и находится в пути. Скоро он будет доставлен по указанному адресу.'
|
||||
};
|
||||
case 'delivered':
|
||||
return {
|
||||
label: 'Доставлен',
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-50',
|
||||
icon: <CheckCircle className="h-5 w-5" />,
|
||||
description: 'Ваш заказ успешно доставлен. Спасибо за покупку!'
|
||||
};
|
||||
case 'cancelled':
|
||||
return {
|
||||
label: 'Отменен',
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-50',
|
||||
icon: <AlertCircle className="h-5 w-5" />,
|
||||
description: 'Заказ был отменен. Если у вас есть вопросы, пожалуйста, свяжитесь с нашей службой поддержки.'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: 'Неизвестно',
|
||||
color: 'text-gray-600',
|
||||
bgColor: 'bg-gray-50',
|
||||
icon: <Clock className="h-5 w-5" />,
|
||||
description: 'Статус заказа неизвестен. Пожалуйста, свяжитесь с нашей службой поддержки для получения дополнительной информации.'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Форматирование даты
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// Отображение прогресса заказа
|
||||
const renderOrderProgress = (status: string) => {
|
||||
const steps = [
|
||||
{ key: 'pending', label: 'Заказ создан', icon: <Package /> },
|
||||
{ key: 'processing', label: 'В обработке', icon: <Clock /> },
|
||||
{ key: 'shipped', label: 'Отправлен', icon: <Truck /> },
|
||||
{ key: 'delivered', label: 'Доставлен', icon: <CheckCircle /> }
|
||||
];
|
||||
|
||||
let currentStepIndex = steps.findIndex(step => step.key === status);
|
||||
|
||||
// Если статус "cancelled" или неизвестный, показываем только первый шаг
|
||||
if (status === 'cancelled' || currentStepIndex === -1) {
|
||||
currentStepIndex = 0;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<div className="relative">
|
||||
{/* Линия прогресса */}
|
||||
<div className="absolute top-5 left-0 right-0 h-0.5 bg-gray-200">
|
||||
<div
|
||||
className="h-0.5 bg-green-500 transition-all duration-500"
|
||||
style={{ width: status === 'cancelled' ? '0%' : `${(currentStepIndex / (steps.length - 1)) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* Шаги */}
|
||||
<div className="relative flex justify-between">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = index <= currentStepIndex && status !== 'cancelled';
|
||||
const isCurrent = index === currentStepIndex && status !== 'cancelled';
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center z-10 ${
|
||||
isActive
|
||||
? 'bg-green-500 text-white'
|
||||
: status === 'cancelled' && index === 0
|
||||
? 'bg-red-500 text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
} ${isCurrent ? 'ring-4 ring-green-100' : ''}`}
|
||||
>
|
||||
{status === 'cancelled' && index === 0 ? <AlertCircle className="w-5 h-5" /> : step.icon}
|
||||
</div>
|
||||
<div className="text-xs font-medium mt-2 text-center">{step.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
|
||||
<main className="flex-grow pt-24 pb-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h1 className="text-2xl md:text-3xl font-bold mb-8 text-center">Отслеживание заказа</h1>
|
||||
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden mb-8">
|
||||
<div className="p-6">
|
||||
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-grow">
|
||||
<label htmlFor="orderNumber" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Номер заказа
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="orderNumber"
|
||||
value={orderNumber}
|
||||
onChange={(e) => setOrderNumber(e.target.value)}
|
||||
placeholder="Введите номер заказа"
|
||||
className="w-full border border-gray-300 rounded-md px-4 py-2 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full md:w-auto bg-black text-white px-6 py-2 rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
|
||||
) : (
|
||||
<>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
Найти
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{searched && order && (
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Заказ №{order.id}</h2>
|
||||
<p className="text-gray-500 mt-1">Создан: {formatDate(order.created_at)}</p>
|
||||
</div>
|
||||
|
||||
{order.status && (
|
||||
<div className={`px-3 py-1 rounded-full ${getOrderStatusInfo(order.status).bgColor} ${getOrderStatusInfo(order.status).color} flex items-center`}>
|
||||
{getOrderStatusInfo(order.status).icon}
|
||||
<span className="ml-1 text-sm font-medium">{getOrderStatusInfo(order.status).label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{order.status && renderOrderProgress(order.status)}
|
||||
|
||||
<div className="mt-6 p-4 bg-gray-50 rounded-md">
|
||||
<p className="text-gray-700">{getOrderStatusInfo(order.status).description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-2">Информация о заказе</h3>
|
||||
<div className="bg-gray-50 p-4 rounded-md">
|
||||
<p className="text-sm text-gray-600 mb-1">Сумма заказа:</p>
|
||||
<p className="font-medium">{order.total_amount.toLocaleString('ru-RU')} ₽</p>
|
||||
|
||||
<p className="text-sm text-gray-600 mt-3 mb-1">Способ оплаты:</p>
|
||||
<p className="font-medium">{order.payment_method}</p>
|
||||
|
||||
<p className="text-sm text-gray-600 mt-3 mb-1">Количество товаров:</p>
|
||||
<p className="font-medium">{order.items.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-2">Адрес доставки</h3>
|
||||
<div className="bg-gray-50 p-4 rounded-md">
|
||||
<p className="whitespace-pre-line">{order.shipping_address}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
href={`/account/orders/${order.id}`}
|
||||
className="text-indigo-600 hover:text-indigo-500 flex items-center"
|
||||
>
|
||||
<Package className="w-4 h-4 mr-2" />
|
||||
Перейти к подробной информации о заказе
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!searched && (
|
||||
<div className="text-center py-8">
|
||||
<Package className="h-16 w-16 mx-auto text-gray-400 mb-4" />
|
||||
<p className="text-gray-600">Введите номер заказа, чтобы отследить его статус</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,745 +0,0 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { GetServerSideProps } from 'next';
|
||||
import { useRouter } from 'next/router';
|
||||
import Header from '../../components/Header';
|
||||
import Footer from '../../components/Footer';
|
||||
import AddToCartButton from '../../components/AddToCartButton';
|
||||
import { productService, categoryService, Product as ApiProduct, Category, Collection, ProductVariant } from '../../services/catalog';
|
||||
import { Heart, ShoppingBag, ChevronLeft, ChevronRight, Check, X } from 'lucide-react';
|
||||
import { motion, AnimatePresence, PanInfo, useAnimation } from 'framer-motion';
|
||||
// Импортируем статические данные и функции из файла data/products.ts
|
||||
import { getProductBySlug as getStaticProductBySlug, getSimilarProducts as getStaticSimilarProducts, Product as StaticProduct } from '../../data/products';
|
||||
|
||||
// Функция для преобразования статического продукта в формат API продукта
|
||||
const convertStaticToApiProduct = (staticProduct: StaticProduct): ApiProduct => {
|
||||
return {
|
||||
id: staticProduct.id,
|
||||
name: staticProduct.name,
|
||||
slug: staticProduct.slug,
|
||||
description: staticProduct.description,
|
||||
is_active: true,
|
||||
category_id: staticProduct.categoryId,
|
||||
collection_id: staticProduct.collectionId || null,
|
||||
images: staticProduct.images.map((url, index) => ({
|
||||
id: index + 1,
|
||||
url,
|
||||
is_primary: index === 0,
|
||||
product_id: staticProduct.id
|
||||
})),
|
||||
variants: [
|
||||
{
|
||||
id: staticProduct.id * 100 + 1,
|
||||
name: 'Стандартный',
|
||||
sku: `SKU-${staticProduct.id}`,
|
||||
price: staticProduct.price,
|
||||
discount_price: null,
|
||||
stock: staticProduct.inStock ? 10 : 0,
|
||||
is_active: true,
|
||||
product_id: staticProduct.id
|
||||
}
|
||||
],
|
||||
category: {
|
||||
id: staticProduct.categoryId,
|
||||
name: 'Категория', // Заглушка, так как в статических данных нет имени категории
|
||||
slug: `category-${staticProduct.categoryId}`,
|
||||
parent_id: null,
|
||||
is_active: true,
|
||||
order: 1
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
interface ProductPageProps {
|
||||
product: ApiProduct;
|
||||
similarProducts: ApiProduct[];
|
||||
}
|
||||
|
||||
export default function ProductPage({ product, similarProducts }: ProductPageProps) {
|
||||
const router = useRouter();
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
||||
const [isFavorite, setIsFavorite] = useState(false);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [hoveredProduct, setHoveredProduct] = useState<number | null>(null);
|
||||
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
|
||||
product.variants && product.variants.length > 0 ? product.variants[0] : null
|
||||
);
|
||||
// Состояние для отображения галереи на весь экран
|
||||
const [fullscreenGallery, setFullscreenGallery] = useState(false);
|
||||
// Состояние для отслеживания направления свайпа
|
||||
const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null);
|
||||
// Состояние для отслеживания перетаскивания
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
// Ref для контейнера изображения
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null);
|
||||
// Контроллер анимации для свайпов
|
||||
const controls = useAnimation();
|
||||
|
||||
// Обновляем выбранный вариант при изменении product.variants
|
||||
useEffect(() => {
|
||||
if (product.variants && product.variants.length > 0) {
|
||||
setSelectedVariant(product.variants[0]);
|
||||
setCurrentImageIndex(0); // Сбрасываем индекс изображения
|
||||
setQuantity(1); // Сбрасываем количество
|
||||
}
|
||||
}, [product.id, product.variants]);
|
||||
|
||||
// Если страница еще загружается, показываем заглушку
|
||||
if (router.isFallback) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Функция для переключения изображений
|
||||
const nextImage = () => {
|
||||
if (product.images && product.images.length > 0) {
|
||||
setSwipeDirection('left');
|
||||
setCurrentImageIndex((prev) => (prev === product.images!.length - 1 ? 0 : prev + 1));
|
||||
}
|
||||
};
|
||||
|
||||
const prevImage = () => {
|
||||
if (product.images && product.images.length > 0) {
|
||||
setSwipeDirection('right');
|
||||
setCurrentImageIndex((prev) => (prev === 0 ? product.images!.length - 1 : prev - 1));
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для обработки начала перетаскивания
|
||||
const handleDragStart = () => {
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
// Функция для обработки свайпов
|
||||
const handleDragEnd = (event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
||||
setIsDragging(false);
|
||||
const threshold = 50; // Минимальное расстояние свайпа для переключения
|
||||
|
||||
if (info.offset.x > threshold) {
|
||||
// Свайп вправо - предыдущее изображение
|
||||
prevImage();
|
||||
} else if (info.offset.x < -threshold) {
|
||||
// Свайп влево - следующее изображение
|
||||
nextImage();
|
||||
} else {
|
||||
// Если свайп недостаточно сильный, возвращаем изображение на место
|
||||
controls.start({ x: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для отслеживания перетаскивания
|
||||
const handleDrag = (event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
||||
// Анимируем перетаскивание в реальном времени
|
||||
controls.set({ x: info.offset.x });
|
||||
};
|
||||
|
||||
// Функция для переключения полноэкранной галереи
|
||||
const toggleFullscreenGallery = () => {
|
||||
if (!isDragging) {
|
||||
setFullscreenGallery(!fullscreenGallery);
|
||||
}
|
||||
};
|
||||
|
||||
// Сбрасываем направление свайпа после анимации
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setSwipeDirection(null);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [currentImageIndex]);
|
||||
|
||||
// Функция для добавления/удаления товара из избранного
|
||||
const toggleFavorite = () => {
|
||||
|
||||
setIsFavorite(!isFavorite);
|
||||
};
|
||||
|
||||
// Функция для изменения количества товара
|
||||
const incrementQuantity = () => {
|
||||
setQuantity((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const decrementQuantity = () => {
|
||||
if (quantity > 1) {
|
||||
setQuantity((prev) => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для выбора варианта товара
|
||||
const handleVariantSelect = (variant: ProductVariant) => {
|
||||
setSelectedVariant(variant);
|
||||
};
|
||||
|
||||
// Функция для форматирования цены
|
||||
const formatPrice = (price: number): string => {
|
||||
return price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
};
|
||||
|
||||
// Получаем текущую цену на основе выбранного варианта
|
||||
const currentPrice = selectedVariant ? selectedVariant.price :
|
||||
(product.variants && product.variants.length > 0 ? product.variants[0].price : 0);
|
||||
|
||||
const currentDiscountPrice = selectedVariant ? selectedVariant.discount_price :
|
||||
(product.variants && product.variants.length > 0 ? product.variants[0].discount_price : null);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white font-['Arimo']">
|
||||
<Head>
|
||||
<title>{product.name} | Brand Store</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-12 md:px-8 pt-24 md:pt-32">
|
||||
<div className="mb-4 md:mb-8">
|
||||
<Link href={product.category ? `/category/${product.category.slug}` : "/category"} className="text-gray-600 hover:text-black transition-colors text-sm md:text-base">
|
||||
← {product.category ? product.category.name : 'Назад к категориям'}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 md:gap-12">
|
||||
{/* Галерея изображений */}
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={imageContainerRef}
|
||||
className="relative aspect-[3/4] overflow-hidden rounded-xl cursor-pointer"
|
||||
onClick={toggleFullscreenGallery}
|
||||
>
|
||||
<motion.div
|
||||
drag="x"
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={0.7}
|
||||
onDragStart={handleDragStart}
|
||||
onDrag={handleDrag}
|
||||
onDragEnd={handleDragEnd}
|
||||
animate={controls}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<div className="relative h-full w-full">
|
||||
{product.images && product.images.length > 0 ? (
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
<motion.div
|
||||
key={currentImageIndex}
|
||||
initial={{
|
||||
x: swipeDirection === 'left' ? '100%' : swipeDirection === 'right' ? '-100%' : 0,
|
||||
opacity: 1
|
||||
}}
|
||||
animate={{
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
zIndex: 1
|
||||
}}
|
||||
exit={{
|
||||
x: swipeDirection === 'left' ? '-100%' : swipeDirection === 'right' ? '100%' : 0,
|
||||
opacity: 1,
|
||||
zIndex: 0
|
||||
}}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
opacity: { duration: 0 }
|
||||
}}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
>
|
||||
<Image
|
||||
src={product.images[currentImageIndex].url}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
quality={95}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-500">Нет изображения</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Кнопки навигации по галерее */}
|
||||
{product.images && product.images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
prevImage();
|
||||
}}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 bg-white/50 hover:bg-white/70 p-2 md:p-3 rounded-full transition-all z-10"
|
||||
aria-label="Предыдущее изображение"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 md:w-5 md:h-5 text-black" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
nextImage();
|
||||
}}
|
||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 bg-white/50 hover:bg-white/70 p-2 md:p-3 rounded-full transition-all z-10"
|
||||
aria-label="Следующее изображение"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4 md:w-5 md:h-5 text-black" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Миниатюры изображений - скрыты на мобильных */}
|
||||
{product.images && product.images.length > 1 && (
|
||||
<div className="hidden md:flex mt-4 space-x-2 overflow-x-auto">
|
||||
{product.images.map((image, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentImageIndex(index)}
|
||||
className={`relative w-16 h-16 md:w-20 md:h-20 rounded-md overflow-hidden ${
|
||||
index === currentImageIndex ? 'ring-2 ring-black' : 'opacity-70 hover:opacity-100'
|
||||
}`}
|
||||
aria-label={`Изображение ${index + 1}`}
|
||||
>
|
||||
<Image src={image.url} alt={`${product.name} - изображение ${index + 1}`} fill className="object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Индикаторы слайдов для мобильных */}
|
||||
{product.images && product.images.length > 1 && (
|
||||
<div className="flex justify-center mt-4 space-x-2 md:hidden">
|
||||
{product.images.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentImageIndex(index)}
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
index === currentImageIndex ? 'bg-black' : 'bg-gray-300'
|
||||
}`}
|
||||
aria-label={`Перейти к изображению ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Подсказка о свайпе для мобильных */}
|
||||
<div className="mt-2 text-center text-xs text-gray-500 md:hidden">
|
||||
Свайпните для просмотра других фото
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Информация о товаре */}
|
||||
<div>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-2xl md:text-3xl font-bold font-['Playfair_Display']"
|
||||
>
|
||||
{product.name}
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="text-xl md:text-2xl font-bold mt-4"
|
||||
>
|
||||
{currentPrice ? (
|
||||
<>
|
||||
{currentDiscountPrice ? (
|
||||
<>
|
||||
{formatPrice(currentDiscountPrice)} ₽
|
||||
<span className="ml-2 text-sm line-through text-gray-500">
|
||||
{formatPrice(currentPrice)} ₽
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
`${formatPrice(currentPrice)} ₽`
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
'Цена по запросу'
|
||||
)}
|
||||
</motion.p>
|
||||
|
||||
{/* Варианты товара */}
|
||||
{product.variants && product.variants.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="mt-6"
|
||||
>
|
||||
<h2 className="text-base md:text-lg font-medium mb-2">Варианты</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{product.variants.map((variant) => (
|
||||
<button
|
||||
key={variant.id}
|
||||
onClick={() => handleVariantSelect(variant)}
|
||||
className={`px-3 py-1.5 md:px-4 md:py-2 border rounded-md transition-colors text-sm md:text-base ${
|
||||
selectedVariant?.id === variant.id
|
||||
? 'border-black bg-black text-white'
|
||||
: 'border-gray-300 hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{selectedVariant?.id === variant.id && <Check className="w-3 h-3 md:w-4 md:h-4 mr-1" />}
|
||||
{variant.name}
|
||||
{variant.stock <= 3 && variant.stock > 0 && (
|
||||
<span className="ml-2 text-xs text-red-500">Осталось {variant.stock} шт.</span>
|
||||
)}
|
||||
{variant.stock === 0 && (
|
||||
<span className="ml-2 text-xs text-red-500">Нет в наличии</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="mt-6"
|
||||
>
|
||||
<h2 className="text-base md:text-lg font-medium mb-2">Описание</h2>
|
||||
<p className="text-sm md:text-base text-gray-600">{product.description}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="mt-6 md:mt-8"
|
||||
>
|
||||
<h2 className="text-base md:text-lg font-medium mb-2">Количество</h2>
|
||||
<div className="flex items-center border border-gray-300 rounded-md w-fit">
|
||||
<button
|
||||
onClick={decrementQuantity}
|
||||
className="px-3 py-1.5 md:px-4 md:py-2 text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
aria-label="Уменьшить количество"
|
||||
disabled={quantity <= 1}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="px-3 py-1.5 md:px-4 md:py-2 border-l border-r border-gray-300 text-sm md:text-base">{quantity}</span>
|
||||
<button
|
||||
onClick={incrementQuantity}
|
||||
className="px-3 py-1.5 md:px-4 md:py-2 text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
aria-label="Увеличить количество"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
className="mt-6 md:mt-8 flex flex-col sm:flex-row gap-3 md:gap-4"
|
||||
>
|
||||
<AddToCartButton
|
||||
variantId={selectedVariant && selectedVariant.stock > 0 ? selectedVariant.id : 0}
|
||||
quantity={quantity}
|
||||
className={`px-6 py-2.5 md:px-8 md:py-3 text-sm md:text-base ${
|
||||
selectedVariant && selectedVariant.stock === 0 ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
onClick={toggleFavorite}
|
||||
className="flex items-center justify-center gap-2 border border-gray-300 px-6 py-2.5 md:px-8 md:py-3 rounded-md hover:bg-gray-50 transition-colors text-sm md:text-base"
|
||||
>
|
||||
<Heart className={`w-5 h-5 ${isFavorite ? 'fill-red-500 text-red-500' : ''}`} />
|
||||
{isFavorite ? 'В избранном' : 'В избранное'}
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="mt-6 md:mt-8 border-t border-gray-200 pt-6 md:pt-8"
|
||||
>
|
||||
<h2 className="text-base md:text-lg font-medium mb-2">Категория</h2>
|
||||
{product.category && (
|
||||
<Link href={`/category/${product.category.slug}`} className="text-sm md:text-base text-gray-600 hover:text-black transition-colors">
|
||||
{product.category.name}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{product.collection && (
|
||||
<div className="mt-4">
|
||||
<h2 className="text-base md:text-lg font-medium mb-2">Коллекция</h2>
|
||||
<Link href={`/collections/${product.collection.slug}`} className="text-sm md:text-base text-gray-600 hover:text-black transition-colors">
|
||||
{product.collection.name}
|
||||
</Link>
|
||||
{product.collection.description && (
|
||||
<p className="mt-2 text-xs md:text-sm text-gray-500">{product.collection.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Похожие товары */}
|
||||
{similarProducts.length > 0 && (
|
||||
<section className="mt-12 md:mt-16">
|
||||
<h2 className="text-xl md:text-2xl font-bold mb-6 md:mb-8 font-['Playfair_Display']">Похожие товары</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 md:gap-8">
|
||||
{similarProducts.map((similarProduct) => (
|
||||
<motion.div
|
||||
key={similarProduct.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: similarProduct.id * 0.05 }}
|
||||
whileHover={{ y: -5, transition: { duration: 0.2 } }}
|
||||
>
|
||||
<Link
|
||||
href={`/product/${similarProduct.slug}`}
|
||||
className="block h-full"
|
||||
onMouseEnter={() => setHoveredProduct(similarProduct.id)}
|
||||
onMouseLeave={() => setHoveredProduct(null)}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-xl">
|
||||
<div className="aspect-[3/4] relative overflow-hidden rounded-xl">
|
||||
{similarProduct.images && similarProduct.images.length > 0 ? (
|
||||
<Image
|
||||
src={
|
||||
hoveredProduct === similarProduct.id && similarProduct.images.length > 1
|
||||
? similarProduct.images[1].url
|
||||
: similarProduct.images[0].url
|
||||
}
|
||||
alt={similarProduct.name}
|
||||
fill
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
||||
className="object-cover transition-all duration-500 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-500">Нет изображения</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 md:mt-4">
|
||||
<h3 className="text-sm md:text-lg font-medium line-clamp-1">{similarProduct.name}</h3>
|
||||
{similarProduct.variants && similarProduct.variants.length > 0 ? (
|
||||
<p className="mt-1 text-sm md:text-lg font-bold">
|
||||
{similarProduct.variants[0].discount_price ? (
|
||||
<span className="text-lg font-bold">
|
||||
{formatPrice(similarProduct.variants[0].discount_price)} ₽
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-lg font-bold">
|
||||
{formatPrice(similarProduct.variants[0].price)} ₽
|
||||
</span>
|
||||
)}
|
||||
{similarProduct.variants[0].discount_price && (
|
||||
<span className="ml-2 text-xs md:text-sm line-through text-gray-500">
|
||||
{formatPrice(similarProduct.variants[0].price)} ₽
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-sm md:text-lg font-bold">Цена по запросу</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Полноэкранная галерея */}
|
||||
<AnimatePresence>
|
||||
{fullscreenGallery && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black z-50 flex flex-col"
|
||||
>
|
||||
<div className="flex justify-between items-center p-4 text-white">
|
||||
<button
|
||||
onClick={toggleFullscreenGallery}
|
||||
className="p-2"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
<span className="text-sm">{currentImageIndex + 1} / {product.images?.length || 1}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow flex items-center justify-center">
|
||||
<motion.div
|
||||
drag="x"
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={0.7}
|
||||
onDragStart={handleDragStart}
|
||||
onDrag={handleDrag}
|
||||
onDragEnd={handleDragEnd}
|
||||
className="w-full h-full flex items-center justify-center"
|
||||
>
|
||||
<div className="relative w-full h-full">
|
||||
{product.images && product.images.length > 0 ? (
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
<motion.div
|
||||
key={currentImageIndex}
|
||||
initial={{
|
||||
x: swipeDirection === 'left' ? '100%' : swipeDirection === 'right' ? '-100%' : 0,
|
||||
opacity: 1
|
||||
}}
|
||||
animate={{
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
zIndex: 1
|
||||
}}
|
||||
exit={{
|
||||
x: swipeDirection === 'left' ? '-100%' : swipeDirection === 'right' ? '100%' : 0,
|
||||
opacity: 1,
|
||||
zIndex: 0
|
||||
}}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
opacity: { duration: 0 }
|
||||
}}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
>
|
||||
<Image
|
||||
src={product.images[currentImageIndex].url}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-contain"
|
||||
quality={100}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-900 flex items-center justify-center">
|
||||
<span className="text-gray-400">Нет изображения</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{product.images && product.images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={prevImage}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 bg-black/30 p-3 rounded-full z-10"
|
||||
aria-label="Предыдущее изображение"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={nextImage}
|
||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 bg-black/30 p-3 rounded-full z-10"
|
||||
aria-label="Следующее изображение"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Миниатюры в полноэкранном режиме */}
|
||||
{product.images && product.images.length > 1 && (
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<div className="flex space-x-2">
|
||||
{product.images.map((image, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentImageIndex(index)}
|
||||
className={`relative w-16 h-16 rounded-md overflow-hidden flex-shrink-0 ${
|
||||
index === currentImageIndex ? 'ring-2 ring-white' : 'opacity-50'
|
||||
}`}
|
||||
>
|
||||
<Image src={image.url} alt={`${product.name} - изображение ${index + 1}`} fill className="object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Подсказка о свайпе */}
|
||||
<div className="pb-4 text-center text-xs text-gray-400">
|
||||
Свайпните для просмотра других фото
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
|
||||
try {
|
||||
const slug = params?.slug as string;
|
||||
|
||||
// Получаем данные о продукте через API
|
||||
let product;
|
||||
let similarProducts = [];
|
||||
|
||||
try {
|
||||
// Пытаемся получить продукт через API
|
||||
product = await productService.getProductBySlug(slug);
|
||||
|
||||
// Получаем похожие товары (товары из той же категории)
|
||||
similarProducts = await productService.getProducts({
|
||||
category_id: product.category_id,
|
||||
include_variants: true,
|
||||
limit: 4
|
||||
});
|
||||
|
||||
// Фильтруем, чтобы исключить текущий товар из похожих
|
||||
similarProducts = similarProducts.filter(p => p.id !== product.id);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении данных продукта через API:', error);
|
||||
|
||||
// Если не удалось получить данные через API, используем статические данные из файла
|
||||
const staticProduct = getStaticProductBySlug(slug);
|
||||
|
||||
if (!staticProduct) {
|
||||
return { notFound: true };
|
||||
}
|
||||
|
||||
// Преобразуем статический продукт в формат API продукта
|
||||
product = convertStaticToApiProduct(staticProduct);
|
||||
|
||||
// Получаем похожие товары из статических данных
|
||||
const staticSimilarProducts = getStaticSimilarProducts(staticProduct.id);
|
||||
similarProducts = staticSimilarProducts.map(convertStaticToApiProduct);
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
product,
|
||||
similarProducts
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении данных продукта:', error);
|
||||
return {
|
||||
notFound: true
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -1,260 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { Mail, Lock, User, Phone, AlertCircle } from 'lucide-react';
|
||||
import authService from '../services/auth';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirm: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
phone: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [redirectTo, setRedirectTo] = useState<string | null>(null);
|
||||
|
||||
// Проверяем, есть ли параметр redirect в URL
|
||||
useEffect(() => {
|
||||
if (router.query.redirect) {
|
||||
setRedirectTo(router.query.redirect as string);
|
||||
}
|
||||
|
||||
// Если пользователь уже авторизован, перенаправляем его
|
||||
if (authService.isAuthenticated()) {
|
||||
router.push(redirectTo || '/');
|
||||
}
|
||||
}, [router.query.redirect, redirectTo, router]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
// Проверяем совпадение паролей
|
||||
if (formData.password !== formData.password_confirm) {
|
||||
setError('Пароли не совпадают');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Отправляем данные для регистрации (включая поле password_confirm)
|
||||
await authService.register(formData);
|
||||
|
||||
// Перенаправляем пользователя после успешной регистрации
|
||||
router.push(redirectTo || '/');
|
||||
} catch (err) {
|
||||
console.error('Ошибка при регистрации:', err);
|
||||
setError('Не удалось зарегистрироваться. Возможно, пользователь с таким email уже существует.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Регистрация
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Уже есть аккаунт?{' '}
|
||||
<Link href="/login" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Войдите
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700">
|
||||
Имя
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
required
|
||||
value={formData.first_name}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="Иван"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="last_name" className="block text-sm font-medium text-gray-700">
|
||||
Фамилия
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
required
|
||||
value={formData.last_name}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="Иванов"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="example@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
|
||||
Телефон (необязательно)
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Phone className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="+7 (999) 123-45-67"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Пароль
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">Минимум 8 символов</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password_confirm" className="block text-sm font-medium text-gray-700">
|
||||
Подтверждение пароля
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password_confirm"
|
||||
name="password_confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.password_confirm}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="terms"
|
||||
name="terms"
|
||||
type="checkbox"
|
||||
required
|
||||
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="terms" className="ml-2 block text-sm text-gray-900">
|
||||
Я согласен с <a href="#" className="text-indigo-600 hover:text-indigo-500">условиями использования</a> и <a href="#" className="text-indigo-600 hover:text-indigo-500">политикой конфиденциальности</a>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Регистрация...' : 'Зарегистрироваться'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,210 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { Lock, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import authService from '../../services/auth';
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const router = useRouter();
|
||||
const { token } = router.query;
|
||||
const [formData, setFormData] = useState({
|
||||
password: '',
|
||||
password_confirm: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [tokenValid, setTokenValid] = useState(true);
|
||||
|
||||
// Проверка валидности токена
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
// В реальном приложении здесь можно сделать запрос к API для проверки токена
|
||||
// Для примера просто проверяем, что токен не пустой
|
||||
setTokenValid(typeof token === 'string' && token.length > 0);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
// Проверяем совпадение паролей
|
||||
if (formData.password !== formData.password_confirm) {
|
||||
setError('Пароли не совпадают');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof token !== 'string') {
|
||||
throw new Error('Недействительный токен');
|
||||
}
|
||||
|
||||
await authService.setNewPassword(token, formData.password);
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при сбросе пароля:', err);
|
||||
setError('Не удалось установить новый пароль. Возможно, ссылка устарела или недействительна.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10 text-center">
|
||||
<p className="text-gray-600 mb-4">Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tokenValid) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Недействительная ссылка
|
||||
</h2>
|
||||
<div className="mt-8 bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10 text-center">
|
||||
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 text-left">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">
|
||||
Ссылка для сброса пароля недействительна или устарела.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Пожалуйста, запросите новую ссылку для сброса пароля.
|
||||
</p>
|
||||
<Link href="/forgot-password" className="w-full flex justify-center py-2 px-4 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Установка нового пароля
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Введите новый пароль для вашей учетной записи.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success ? (
|
||||
<div className="text-center">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<CheckCircle className="h-12 w-12 text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Пароль успешно изменен</h3>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Ваш пароль был успешно изменен. Теперь вы можете войти в систему, используя новый пароль.
|
||||
</p>
|
||||
<Link href="/login" className="w-full flex justify-center py-2 px-4 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>
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Новый пароль
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">Минимум 8 символов</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password_confirm" className="block text-sm font-medium text-gray-700">
|
||||
Подтверждение пароля
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password_confirm"
|
||||
name="password_confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.password_confirm}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Сохранение...' : 'Установить новый пароль'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 441 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 125 KiB |
@ -1,4 +0,0 @@
|
||||
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 25 KiB |