Compare commits

..

25 Commits

Author SHA1 Message Date
c17c903bea first commit 2025-08-13 21:23:15 +03:00
10d4d41e95 fix 2025-07-30 07:43:53 +03:00
08f76a7633 pravki 2025-07-29 18:55:09 +03:00
94ed190869 fixes and shit 2025-07-18 20:50:08 +03:00
f96207c129 prices optimised 2025-07-18 18:12:06 +03:00
7fc55ab9c3 catalog prices fix 2025-07-17 21:22:20 +03:00
f5e8b4240c Обновлены резолверы для обработки групп товаров. Изменены типы переменной groupName на string | null для повышения строгости типизации. Это улучшает читаемость кода и предотвращает возможные ошибки при работе с данными. 2025-07-15 09:06:54 +03:00
70bcb48b92 Добавлена новая модель HeroBanner с соответствующими мутациями и запросами для управления баннерами героя. Обновлены типы GraphQL и резолверы для обработки данных баннеров, что улучшает функциональность приложения. В боковое меню добавлен новый элемент для навигации по баннерам героя, что повышает удобство работы с интерфейсом. 2025-07-15 09:03:34 +03:00
ac122411e0 Добавлены новые модели и мутации для навигационных категорий, включая создание, обновление и удаление. Обновлены типы GraphQL и резолверы для обработки навигационных категорий, что улучшает структуру данных и функциональность. В боковое меню добавлен новый элемент для навигации по категориям. Реализован кэш для оптимизации запросов к API, что повышает производительность приложения. 2025-07-13 21:42:04 +03:00
db29525da5 Удалены страницы для управления товарами с лучшими ценами и топом продаж. Обновлены компоненты и резолверы для улучшения структуры данных и функциональности. Изменены типы GraphQL и обновлены запросы для повышения гибкости взаимодействия с API. Упрощена навигация в боковом меню, что улучшает пользовательский опыт. 2025-07-11 02:42:44 +03:00
013c05fb02 Обновлены резолверы для обработки связанных и аксессуарных товаров. Теперь они объединяют данные из двух сторон связи, что улучшает полноту информации о товарах. Изменены типы данных в запросах для соответствия новым структурам. Это повышает гибкость и точность взаимодействия с API. 2025-07-10 10:28:15 +03:00
c7dcb96c05 Добавлены новые модели и мутации для управления товарами дня, лучшими ценами и топом продаж. Обновлены типы GraphQL и резолверы для обработки запросов, что улучшает функциональность и структуру данных. В боковое меню добавлены новые элементы для навигации по товарам. Это повышает удобство работы с приложением и расширяет возможности взаимодействия с API. 2025-07-10 00:11:02 +03:00
2c2ccf8876 Обновлены логика работы с параметром SSD в классе LaximoService и резолверах. Теперь SSD используется для получения узлов в категориях, что улучшает обработку запросов к API. Добавлено извлечение SSD узла и соответствующие изменения в логах для повышения информативности. Это повышает надежность взаимодействия с API и улучшает структуру данных. 2025-07-08 13:25:46 +03:00
667a581251 Обновлен конфиг middleware для применения правил маршрутизации. Теперь он исключает запросы к API, статическим файлам и метаданным, что улучшает обработку запросов и повышает гибкость работы с приложением. 2025-07-06 20:23:50 +03:00
2841996b31 Удален ограничитель на количество предложений в резолвере для улучшения обработки данных. Это позволяет возвращать больше предложений и повышает гибкость взаимодействия с API. 2025-07-05 18:38:13 +03:00
511785a617 Добавлено новое поле ssd в интерфейс LaximoUnit и обновлено извлечение SSD узла в классе LaximoService. Это улучшает структуру данных и позволяет более эффективно работать с информацией об узлах. 2025-07-05 13:35:52 +03:00
dfd21aad5c Добавлены новые поля ssd в типы GraphQL и обновлен резолвер для улучшения обработки категорий и узлов. Реализована логика получения узлов в зависимости от наличия categoryId и SSD, что повышает гибкость и надежность взаимодействия с API. 2025-07-03 15:35:34 +03:00
4ee005cea9 Добавлено новое поле attributes в интерфейс LaximoVehicleSearchResult и тип GraphQL для улучшения структуры данных. Реализован маппинг ключей к человеко-читаемым названиям в классе LaximoService, что улучшает читаемость и удобство работы с атрибутами автомобилей. 2025-07-02 17:52:51 +03:00
fd78900ac5 Добавлены новые поля в интерфейс LaximoVehicleSearchResult для расширения функциональности поиска автомобилей. Обновлен резолвер для обработки неавторизованных пользователей, возвращая пустой массив вместо ошибки. Это улучшает взаимодействие с API и пользовательский опыт. 2025-07-02 16:56:07 +03:00
80b699ff4e Удален файл CSS_FIX_GUIDE.md, который содержал инструкции по исправлению проблем со стилями в Docker. Обновлены зависимости в package-lock.json и package.json для улучшения совместимости с Tailwind CSS и PostCSS. Внесены изменения в конфигурацию PostCSS и удален устаревший файл tailwind.config.js. Обновлены стили в globals.css для улучшения структуры и добавлены новые переменные. Добавлен новый элемент в боковое меню для тестирования стилей. 2025-07-01 22:25:01 +03:00
249a07fc2b Обновлен docker-compose.yml для использования оптимизированного Dockerfile. Внесены изменения в Dockerfile для улучшения сборки приложения и проверки наличия CSS файлов. Обновлены настройки в next.config.ts для оптимизации работы с CSS и изображениями. Изменен импорт стилей в globals.css для улучшения структуры. 2025-06-30 21:37:15 +03:00
62739b0048 Удален файл DEPLOY.md, который содержал инструкции по деплою. Обновлен docker-compose.yml для упрощения конфигурации и устранения устаревших переменных окружения. Удален скрипт test-docker-compose.sh, который больше не нужен. Внесены изменения в globals.css для улучшения структуры тем и цветовых переменных, добавлен импорт tw-animate-css. 2025-06-30 18:31:37 +03:00
bbf7799e4b Обновлен файл docker-compose.yml для улучшения структуры переменных окружения и добавления поддержки нового файла конфигурации stack.env. Внесены изменения в globals.css для упрощения темы и улучшения цветовой схемы, включая обновление стилей для светлой и темной тем. Удалены устаревшие переменные и оптимизированы настройки стилей. 2025-06-30 18:18:45 +03:00
3a160efd17 Добавлены новые поля в тип LaximoFulltextDetail и обновлен резолвер для маппинга данных, включая обработку отсутствующих полей. Это улучшает соответствие данных GraphQL схеме и обеспечивает более полное представление деталей. 2025-06-30 00:46:45 +03:00
d565416b55 Добавлены плагины для Apollo Server, обеспечивающие логирование операций GraphQL и обработку ошибок. Улучшено логирование в резолверах, добавлены проверки параметров с детальной информацией. Обновлены типы GraphQL для поддержки новых полей в деталях группы быстрого поиска. 2025-06-30 00:39:51 +03:00
56 changed files with 12550 additions and 1160 deletions

1
.gitignore vendored
View File

@ -41,3 +41,4 @@ yarn-error.log*
next-env.d.ts
/src/generated
.zzap-session.json

View File

@ -94,12 +94,16 @@ COPY . .
# Генерируем Prisma Client
RUN npx prisma generate
# Устанавливаем флаг для Docker сборки
# Устанавливаем флаги для Docker сборки
ENV DOCKER_BUILD=true
ENV NEXT_TELEMETRY_DISABLED=1
# Собираем приложение
# Собираем приложение с правильной обработкой CSS
RUN npm run build
# Проверяем, что стили собрались
RUN ls -la .next/static/css/ || echo "CSS files not found, but continuing..."
# Удаляем dev зависимости для уменьшения размера образа
RUN npm ci --only=production && npm cache clean --force

108
Dockerfile.optimized Normal file
View File

@ -0,0 +1,108 @@
# Multi-stage build для оптимизации
FROM node:18-alpine AS base
# Устанавливаем зависимости для всех этапов
RUN apk update && apk add --no-cache \
chromium \
nss \
freetype \
harfbuzz \
ca-certificates \
ttf-freefont \
ttf-dejavu \
ttf-liberation \
bash \
libc6-compat
# Устанавливаем переменные окружения для Puppeteer
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
ENV NEXT_TELEMETRY_DISABLED=1
# Устанавливаем рабочую директорию
WORKDIR /app
# Этап установки зависимостей
FROM base AS deps
COPY package*.json ./
RUN npm ci
# Этап сборки
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# ARG переменные для сборки
ARG DATABASE_URL
ARG AWS_ACCESS_KEY_ID
ARG AWS_SECRET_ACCESS_KEY
ARG AWS_REGION
ARG AWS_BUCKET_NAME
ARG S3_ENDPOINT
ARG NEXTAUTH_SECRET
ARG JWT_SECRET
ARG NEXTAUTH_URL
ARG BEELINE_SMS_USER
ARG BEELINE_SMS_PASS
ARG BEELINE_SMS_SENDER
ARG LAXIMO_LOGIN
ARG LAXIMO_PASSWORD
ARG LAXIMO_DOC_LOGIN
ARG LAXIMO_DOC_PASSWORD
ARG AUTOEURO_API_KEY
ARG YOOKASSA_SHOP_ID
ARG YOOKASSA_SECRET_KEY
ARG PARTSAPI_CATEGORIES_KEY
ARG PARTSAPI_ARTICLES_KEY
ARG PARTSAPI_MEDIA_KEY
ARG PARTSAPI_URL
ARG PARTSINDEX_API_KEY
ARG YANDEX_MAPS_API_KEY
ARG YANDEX_DELIVERY_TOKEN
ARG YANDEX_GEOSUGGEST_API_KEY
ARG YANDEX_DELIVERY_SOURCE_STATION_ID
# ENV переменные для сборки
ENV NODE_ENV=production
ENV DOCKER_BUILD=true
# Генерируем Prisma Client
RUN npx prisma generate
# Собираем приложение
RUN npm run build
# Проверяем сборку
RUN echo "Checking build output..." && \
ls -la .next/ && \
ls -la .next/static/ && \
(ls -la .next/static/css/ || echo "No CSS directory found") && \
echo "Build check complete"
# Финальный этап
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Создаем пользователя
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Копируем необходимые файлы
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Устанавливаем права доступа
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@ -9,16 +9,17 @@
- ✅ GraphQL API
- ✅ Современный UI с Shadcn/ui
- ✅ Работа с базой данных через Prisma ORM
- ✅ Скрипт «AI Gateway» (AI SDK)
- 🔄 Интеграция с S3 для файлов (в разработке)
## Технологии
- **Frontend**: Next.js 15, React 19, TypeScript
- **UI**: Tailwind CSS, Shadcn/ui, Radix UI
- **Backend**: GraphQL (Apollo Server)
- **Database**: PostgreSQL + Prisma ORM
- **Forms**: React Hook Form + Zod validation
- **Storage**: AWS S3 (планируется)
- Frontend: Next.js 15, React 19, TypeScript
- UI: Tailwind CSS, Shadcn/ui, Radix UI
- Backend: GraphQL (Apollo Server)
- Database: PostgreSQL + Prisma ORM
- Forms: React Hook Form + Zod validation
- Storage: AWS S3 (планируется)
## Установка и настройка
@ -65,7 +66,23 @@ npx prisma generate
npm run dev
```
Откройте [http://localhost:3000](http://localhost:3000) в браузере.
Откройте http://localhost:3000 в браузере.
### 5. AI Gateway (скрипт)
Для запуска скрипта AI Gateway нужен ключ в `.env`:
```env
AI_GATEWAY_API_KEY="your_api_key_here"
```
Запуск примера (TypeScript выполняется через `tsx`):
```bash
npm run ai:gateway
```
Скрипт: `scripts/gateway.ts` — использует `ai` SDK и печатает стрим-ответ, usage и причину завершения.
## Первый запуск
@ -137,10 +154,10 @@ npx prisma migrate dev --name [migration-name]
## Тестирование
### Тест S3 хранилища
Откройте [http://localhost:3000/test-s3](http://localhost:3000/test-s3) для тестирования загрузки файлов в S3.
Откройте http://localhost:3000/test-s3 для тестирования загрузки файлов в S3.
### Тест GraphQL API
Откройте [http://localhost:3000/api/graphql](http://localhost:3000/api/graphql) для проверки GraphQL API.
Откройте http://localhost:3000/api/graphql для проверки GraphQL API.
## Следующие шаги
@ -149,4 +166,4 @@ npx prisma migrate dev --name [migration-name]
- [ ] Добавить управление контентом
- [ ] Создать систему ролей и разрешений
- [ ] Добавить логирование и мониторинг
# protekauto-cms
# protek

View File

@ -4,7 +4,7 @@ services:
protekauto-cms:
build:
context: .
dockerfile: Dockerfile # Используем упрощенный вариант для обхода rate limits
dockerfile: Dockerfile.optimized # Используем оптимизированный Dockerfile
args:
- BEELINE_SMS_USER=${BEELINE_SMS_USER}
- BEELINE_SMS_PASS=${BEELINE_SMS_PASS}
@ -29,13 +29,19 @@ services:
- PARTSAPI_CATEGORIES_KEY=${PARTSAPI_CATEGORIES_KEY}
- PARTSAPI_ARTICLES_KEY=${PARTSAPI_ARTICLES_KEY}
- PARTSAPI_MEDIA_KEY=${PARTSAPI_MEDIA_KEY}
- PARTSAPI_URL=${PARTSAPI_URL}
- PARTSINDEX_API_KEY=${PARTSINDEX_API_KEY}
- YANDEX_MAPS_API_KEY=${YANDEX_MAPS_API_KEY}
- YANDEX_DELIVERY_TOKEN=${YANDEX_DELIVERY_TOKEN}
- YANDEX_GEOSUGGEST_API_KEY=${YANDEX_GEOSUGGEST_API_KEY}
- YANDEX_DELIVERY_SOURCE_STATION_ID=${YANDEX_DELIVERY_SOURCE_STATION_ID}
- CMS_PORT=${CMS_PORT}
ports:
- "3000:3000"
- "${CMS_PORT:-3000}:3000"
environment:
# Порт приложения
- CMS_PORT=${CMS_PORT:-3000}
# База данных (внешняя)
- DATABASE_URL=${DATABASE_URL}
@ -74,6 +80,10 @@ services:
- PARTSAPI_CATEGORIES_KEY=${PARTSAPI_CATEGORIES_KEY}
- PARTSAPI_ARTICLES_KEY=${PARTSAPI_ARTICLES_KEY}
- PARTSAPI_MEDIA_KEY=${PARTSAPI_MEDIA_KEY}
- PARTSAPI_URL=${PARTSAPI_URL}
# PartsIndex API
- PARTSINDEX_API_KEY=${PARTSINDEX_API_KEY}
# Яндекс API ключи
- YANDEX_MAPS_API_KEY=${YANDEX_MAPS_API_KEY}

View File

@ -1,6 +1,65 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Оптимизация для Docker
output: 'standalone',
// Исключаем favicon из обработки как страницу
async rewrites() {
return [];
},
// Настройки для CSS (optimizeCss отключен из-за проблем с critters)
// experimental: {
// optimizeCss: true,
// },
// Настройки для статических файлов
assetPrefix: process.env.NODE_ENV === 'production' ? undefined : '',
// Настройки для сборки
// swcMinify удален в Next.js 15 (включен по умолчанию)
// Настройки для изображений
images: {
unoptimized: true,
domains: ['localhost'],
},
// Настройки webpack для CSS и server-only packages
webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => {
// Убеждаемся, что CSS правильно обрабатывается
if (!dev && !isServer) {
config.optimization.splitChunks.cacheGroups.styles = {
name: 'styles',
test: /\.(css|scss)$/,
chunks: 'all',
enforce: true,
};
}
// Исключаем server-only пакеты из client bundle
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
pg: false,
'pg-native': false,
fs: false,
path: false,
crypto: false,
};
}
// Make pg external for all environments
config.externals = config.externals || [];
if (isServer) {
config.externals.push({
pg: 'commonjs pg',
'pg-native': 'commonjs pg-native'
});
}
return config;
},
};
export default nextConfig;

1569
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "protekauto-cms",
"version": "0.1.0",
"dependencies": {
"@ai-sdk/openai": "^1.3.24",
"@apollo/client": "^3.13.8",
"@apollo/experimental-nextjs-app-support": "^0.12.2",
"@apollo/server": "^4.12.2",
@ -23,19 +24,22 @@
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@tailwindcss/postcss": "^4",
"@tailwindcss/postcss": "^4.1.11",
"@types/bcryptjs": "^2.4.6",
"@types/js-cookie": "^3.0.6",
"@types/jsonwebtoken": "^9.0.9",
"@types/jspdf": "^1.3.3",
"@types/pdfkit": "^0.14.0",
"@types/pg": "^8.15.4",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^10.0.0",
"ai": "^3.4.33",
"axios": "^1.10.0",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
@ -55,6 +59,8 @@
"lucide-react": "^0.513.0",
"next": "15.3.3",
"pdfkit": "^0.17.1",
"pg": "^8.16.3",
"postcss": "^8.5.6",
"prisma": "^6.9.0",
"puppeteer": "^24.10.2",
"qrcode": "^1.5.4",
@ -63,8 +69,8 @@
"react-hook-form": "^7.57.0",
"react-hot-toast": "^2.5.2",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.0",
"tailwindcss": "^4",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tailwindcss-animate": "^1.0.7",
"ttf2woff2": "^8.0.0",
"uuid": "^11.1.0",
@ -78,10 +84,207 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.3.3",
"tsx": "^4.19.2",
"tw-animate-css": "^1.3.4",
"typescript": "^5"
}
},
"node_modules/@ai-sdk/openai": {
"version": "1.3.24",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-1.3.24.tgz",
"integrity": "sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
"@ai-sdk/provider-utils": "2.2.8"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.0.0"
}
},
"node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz",
"integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider-utils": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz",
"integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
"nanoid": "^3.3.8",
"secure-json-parse": "^2.7.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.23.8"
}
},
"node_modules/@ai-sdk/provider": {
"version": "0.0.26",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-0.0.26.tgz",
"integrity": "sha512-dQkfBDs2lTYpKM8389oopPdQgIU007GQyCbuPPrV+K6MtSII3HBfE0stUIMXUb44L+LK1t6GXPP7wjSzjO6uKg==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/provider-utils": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-1.0.22.tgz",
"integrity": "sha512-YHK2rpj++wnLVc9vPGzGFP3Pjeld2MwhKinetA0zKXOoHAT/Jit5O8kZsxcSlJPu9wvcGT1UGZEjZrtO7PfFOQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "0.0.26",
"eventsource-parser": "^1.1.2",
"nanoid": "^3.3.7",
"secure-json-parse": "^2.7.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@ai-sdk/react": {
"version": "0.0.70",
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-0.0.70.tgz",
"integrity": "sha512-GnwbtjW4/4z7MleLiW+TOZC2M29eCg1tOUpuEiYFMmFNZK8mkrqM0PFZMo6UsYeUYMWqEOOcPOU9OQVJMJh7IQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider-utils": "1.0.22",
"@ai-sdk/ui-utils": "0.0.50",
"swr": "^2.2.5",
"throttleit": "2.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"zod": "^3.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/@ai-sdk/solid": {
"version": "0.0.54",
"resolved": "https://registry.npmjs.org/@ai-sdk/solid/-/solid-0.0.54.tgz",
"integrity": "sha512-96KWTVK+opdFeRubqrgaJXoNiDP89gNxFRWUp0PJOotZW816AbhUf4EnDjBjXTLjXL1n0h8tGSE9sZsRkj9wQQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider-utils": "1.0.22",
"@ai-sdk/ui-utils": "0.0.50"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"solid-js": "^1.7.7"
},
"peerDependenciesMeta": {
"solid-js": {
"optional": true
}
}
},
"node_modules/@ai-sdk/svelte": {
"version": "0.0.57",
"resolved": "https://registry.npmjs.org/@ai-sdk/svelte/-/svelte-0.0.57.tgz",
"integrity": "sha512-SyF9ItIR9ALP9yDNAD+2/5Vl1IT6kchgyDH8xkmhysfJI6WrvJbtO1wdQ0nylvPLcsPoYu+cAlz1krU4lFHcYw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider-utils": "1.0.22",
"@ai-sdk/ui-utils": "0.0.50",
"sswr": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0"
},
"peerDependenciesMeta": {
"svelte": {
"optional": true
}
}
},
"node_modules/@ai-sdk/ui-utils": {
"version": "0.0.50",
"resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-0.0.50.tgz",
"integrity": "sha512-Z5QYJVW+5XpSaJ4jYCCAVG7zIAuKOOdikhgpksneNmKvx61ACFaf98pmOd+xnjahl0pIlc/QIe6O4yVaJ1sEaw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "0.0.26",
"@ai-sdk/provider-utils": "1.0.22",
"json-schema": "^0.4.0",
"secure-json-parse": "^2.7.0",
"zod-to-json-schema": "^3.23.3"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@ai-sdk/vue": {
"version": "0.0.59",
"resolved": "https://registry.npmjs.org/@ai-sdk/vue/-/vue-0.0.59.tgz",
"integrity": "sha512-+ofYlnqdc8c4F6tM0IKF0+7NagZRAiqBJpGDJ+6EYhDW8FHLUP/JFBgu32SjxSxC6IKFZxEnl68ZoP/Z38EMlw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider-utils": "1.0.22",
"@ai-sdk/ui-utils": "0.0.50",
"swrv": "^1.0.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"vue": "^3.3.4"
},
"peerDependenciesMeta": {
"vue": {
"optional": true
}
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@ -1405,6 +1608,16 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
@ -1414,6 +1627,22 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.28.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
@ -1423,6 +1652,20 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.28.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz",
"integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
@ -1454,6 +1697,448 @@
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
"integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz",
"integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz",
"integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz",
"integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz",
"integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz",
"integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz",
"integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz",
"integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz",
"integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz",
"integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz",
"integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz",
"integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz",
"integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz",
"integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz",
"integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz",
"integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz",
"integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz",
"integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz",
"integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz",
"integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz",
"integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz",
"integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz",
"integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz",
"integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz",
"integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz",
"integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
@ -2271,17 +2956,24 @@
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.11.tgz",
"integrity": "sha512-C512c1ytBTio4MrpWKlJpyFHT6+qfFL8SZ58zBzJ1OOzUEjHeF1BtjY2fH7n4x/g2OV/KiiMLAivOp1DXmiMMw==",
"license": "MIT",
"dependencies": {
"@jridgewell/set-array": "^1.2.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
@ -2293,25 +2985,16 @@
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/set-array": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.3.tgz",
"integrity": "sha512-AiR5uKpFxP3PjO4R19kQGIMwxyRyPuXmKEEy301V1C0+1rVjS94EZQXf1QKZYN8Q0YM+estSPhmx5JwNftv6nw==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
"version": "0.3.28",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.28.tgz",
"integrity": "sha512-KNNHHwW3EIp4EDYOvYFGyIFfx36R2dNJYH4knnZlF8T5jdbD5Wx8xmSaQ2gP9URkJ04LGEtlcCtwArKcmFcwKw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@ -2556,6 +3239,15 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -3332,6 +4024,37 @@
}
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz",
"integrity": "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz",
@ -4410,6 +5133,16 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz",
"integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"acorn": "^8.9.0"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
@ -4426,9 +5159,9 @@
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.8.tgz",
"integrity": "sha512-OWwBsbC9BFAJelmnNcrKuf+bka2ZxCE2A4Ft53Tkg4uoiE67r/PMEYwCsourC26E+kmxfwE0hVzMdxqeW+xu7Q==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz",
"integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==",
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.3.0",
@ -4437,13 +5170,13 @@
"lightningcss": "1.30.1",
"magic-string": "^0.30.17",
"source-map-js": "^1.2.1",
"tailwindcss": "4.1.8"
"tailwindcss": "4.1.11"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.8.tgz",
"integrity": "sha512-d7qvv9PsM5N3VNKhwVUhpK6r4h9wtLkJ6lz9ZY9aeZgrUWk1Z8VPyqyDT9MZlem7GTGseRQHkeB1j3tC7W1P+A==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz",
"integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@ -4454,24 +5187,24 @@
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.8",
"@tailwindcss/oxide-darwin-arm64": "4.1.8",
"@tailwindcss/oxide-darwin-x64": "4.1.8",
"@tailwindcss/oxide-freebsd-x64": "4.1.8",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.8",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.8",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.8",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.8",
"@tailwindcss/oxide-linux-x64-musl": "4.1.8",
"@tailwindcss/oxide-wasm32-wasi": "4.1.8",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.8",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.8"
"@tailwindcss/oxide-android-arm64": "4.1.11",
"@tailwindcss/oxide-darwin-arm64": "4.1.11",
"@tailwindcss/oxide-darwin-x64": "4.1.11",
"@tailwindcss/oxide-freebsd-x64": "4.1.11",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.11",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.11",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.11",
"@tailwindcss/oxide-linux-x64-musl": "4.1.11",
"@tailwindcss/oxide-wasm32-wasi": "4.1.11",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.11",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.11"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.8.tgz",
"integrity": "sha512-Fbz7qni62uKYceWYvUjRqhGfZKwhZDQhlrJKGtnZfuNtHFqa8wmr+Wn74CTWERiW2hn3mN5gTpOoxWKk0jRxjg==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz",
"integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==",
"cpu": [
"arm64"
],
@ -4485,9 +5218,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.8.tgz",
"integrity": "sha512-RdRvedGsT0vwVVDztvyXhKpsU2ark/BjgG0huo4+2BluxdXo8NDgzl77qh0T1nUxmM11eXwR8jA39ibvSTbi7A==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz",
"integrity": "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==",
"cpu": [
"arm64"
],
@ -4501,9 +5234,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.8.tgz",
"integrity": "sha512-t6PgxjEMLp5Ovf7uMb2OFmb3kqzVTPPakWpBIFzppk4JE4ix0yEtbtSjPbU8+PZETpaYMtXvss2Sdkx8Vs4XRw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz",
"integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==",
"cpu": [
"x64"
],
@ -4517,9 +5250,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.8.tgz",
"integrity": "sha512-g8C8eGEyhHTqwPStSwZNSrOlyx0bhK/V/+zX0Y+n7DoRUzyS8eMbVshVOLJTDDC+Qn9IJnilYbIKzpB9n4aBsg==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz",
"integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==",
"cpu": [
"x64"
],
@ -4533,9 +5266,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.8.tgz",
"integrity": "sha512-Jmzr3FA4S2tHhaC6yCjac3rGf7hG9R6Gf2z9i9JFcuyy0u79HfQsh/thifbYTF2ic82KJovKKkIB6Z9TdNhCXQ==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz",
"integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==",
"cpu": [
"arm"
],
@ -4549,9 +5282,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.8.tgz",
"integrity": "sha512-qq7jXtO1+UEtCmCeBBIRDrPFIVI4ilEQ97qgBGdwXAARrUqSn/L9fUrkb1XP/mvVtoVeR2bt/0L77xx53bPZ/Q==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz",
"integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==",
"cpu": [
"arm64"
],
@ -4565,9 +5298,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.8.tgz",
"integrity": "sha512-O6b8QesPbJCRshsNApsOIpzKt3ztG35gfX9tEf4arD7mwNinsoCKxkj8TgEE0YRjmjtO3r9FlJnT/ENd9EVefQ==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz",
"integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==",
"cpu": [
"arm64"
],
@ -4581,9 +5314,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.8.tgz",
"integrity": "sha512-32iEXX/pXwikshNOGnERAFwFSfiltmijMIAbUhnNyjFr3tmWmMJWQKU2vNcFX0DACSXJ3ZWcSkzNbaKTdngH6g==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz",
"integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==",
"cpu": [
"x64"
],
@ -4597,9 +5330,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.8.tgz",
"integrity": "sha512-s+VSSD+TfZeMEsCaFaHTaY5YNj3Dri8rST09gMvYQKwPphacRG7wbuQ5ZJMIJXN/puxPcg/nU+ucvWguPpvBDg==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz",
"integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==",
"cpu": [
"x64"
],
@ -4613,9 +5346,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.8.tgz",
"integrity": "sha512-CXBPVFkpDjM67sS1psWohZ6g/2/cd+cq56vPxK4JeawelxwK4YECgl9Y9TjkE2qfF+9/s1tHHJqrC4SS6cVvSg==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz",
"integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@ -4633,7 +5366,7 @@
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@emnapi/wasi-threads": "^1.0.2",
"@napi-rs/wasm-runtime": "^0.2.10",
"@napi-rs/wasm-runtime": "^0.2.11",
"@tybys/wasm-util": "^0.9.0",
"tslib": "^2.8.0"
},
@ -4642,9 +5375,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.8.tgz",
"integrity": "sha512-7GmYk1n28teDHUjPlIx4Z6Z4hHEgvP5ZW2QS9ygnDAdI/myh3HTHjDqtSqgu1BpRoI4OiLx+fThAyA1JePoENA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",
"integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==",
"cpu": [
"arm64"
],
@ -4658,9 +5391,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.8.tgz",
"integrity": "sha512-fou+U20j+Jl0EHwK92spoWISON2OBnCazIc038Xj2TdweYV33ZRkS9nwqiUi2d/Wba5xg5UoHfvynnb/UB49cQ==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz",
"integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==",
"cpu": [
"x64"
],
@ -4674,16 +5407,16 @@
}
},
"node_modules/@tailwindcss/postcss": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.8.tgz",
"integrity": "sha512-vB/vlf7rIky+w94aWMw34bWW1ka6g6C3xIOdICKX2GC0VcLtL6fhlLiafF0DVIwa9V6EHz8kbWMkS2s2QvvNlw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.11.tgz",
"integrity": "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==",
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.1.8",
"@tailwindcss/oxide": "4.1.8",
"@tailwindcss/node": "4.1.11",
"@tailwindcss/oxide": "4.1.11",
"postcss": "^8.4.41",
"tailwindcss": "4.1.8"
"tailwindcss": "4.1.11"
}
},
"node_modules/@tootallnate/quickjs-emscripten": {
@ -4727,11 +5460,16 @@
"@types/node": "*"
}
},
"node_modules/@types/diff-match-patch": {
"version": "1.0.36",
"resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz",
"integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/express": {
@ -4846,6 +5584,17 @@
"@types/node": "*"
}
},
"node_modules/@types/pg": {
"version": "8.15.4",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.4.tgz",
"integrity": "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
@ -5464,6 +6213,115 @@
"win32"
]
},
"node_modules/@vue/compiler-core": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.18.tgz",
"integrity": "sha512-3slwjQrrV1TO8MoXgy3aynDQ7lslj5UqDxuHnrzHtpON5CBinhWjJETciPngpin/T3OuW3tXUf86tEurusnztw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.28.0",
"@vue/shared": "3.5.18",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.18.tgz",
"integrity": "sha512-RMbU6NTU70++B1JyVJbNbeFkK+A+Q7y9XKE2EM4NLGm2WFR8x9MbAtWxPPLdm0wUkuZv9trpwfSlL6tjdIa1+A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-core": "3.5.18",
"@vue/shared": "3.5.18"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.18.tgz",
"integrity": "sha512-5aBjvGqsWs+MoxswZPoTB9nSDb3dhd1x30xrrltKujlCxo48j8HGDNj3QPhF4VIS0VQDUrA1xUfp2hEa+FNyXA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.28.0",
"@vue/compiler-core": "3.5.18",
"@vue/compiler-dom": "3.5.18",
"@vue/compiler-ssr": "3.5.18",
"@vue/shared": "3.5.18",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.17",
"postcss": "^8.5.6",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.18.tgz",
"integrity": "sha512-xM16Ak7rSWHkM3m22NlmcdIM+K4BMyFARAfV9hYFl+SFuRzrZ3uGMNW05kA5pmeMa0X9X963Kgou7ufdbpOP9g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.18",
"@vue/shared": "3.5.18"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.18.tgz",
"integrity": "sha512-x0vPO5Imw+3sChLM5Y+B6G1zPjwdOri9e8V21NnTnlEvkxatHEH5B5KEAJcjuzQ7BsjGrKtfzuQ5eQwXh8HXBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.18"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.18.tgz",
"integrity": "sha512-DUpHa1HpeOQEt6+3nheUfqVXRog2kivkXHUhoqJiKR33SO4x+a5uNOMkV487WPerQkL0vUuRvq/7JhRgLW3S+w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.18",
"@vue/shared": "3.5.18"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.18.tgz",
"integrity": "sha512-YwDj71iV05j4RnzZnZtGaXwPoUWeRsqinblgVJwR8XTXYZ9D5PbahHQgsbmzUvCWNF6x7siQ89HgnX5eWkr3mw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.18",
"@vue/runtime-core": "3.5.18",
"@vue/shared": "3.5.18",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.18.tgz",
"integrity": "sha512-PvIHLUoWgSbDG7zLHqSqaCoZvHi6NNmfVFOqO+OnwvqMz/tqQr3FuGWS8ufluNddk7ZLBJYMrjcw1c6XzR12mA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.18",
"@vue/shared": "3.5.18"
},
"peerDependencies": {
"vue": "3.5.18"
}
},
"node_modules/@vue/shared": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.18.tgz",
"integrity": "sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==",
"license": "MIT",
"peer": true
},
"node_modules/@whatwg-node/promise-helpers": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz",
@ -5568,7 +6426,6 @@
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@ -5605,6 +6462,54 @@
"node": ">= 14"
}
},
"node_modules/ai": {
"version": "3.4.33",
"resolved": "https://registry.npmjs.org/ai/-/ai-3.4.33.tgz",
"integrity": "sha512-plBlrVZKwPoRTmM8+D1sJac9Bq8eaa2jiZlHLZIWekKWI1yMWYZvCCEezY9ASPwRhULYDJB2VhKOBUUeg3S5JQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "0.0.26",
"@ai-sdk/provider-utils": "1.0.22",
"@ai-sdk/react": "0.0.70",
"@ai-sdk/solid": "0.0.54",
"@ai-sdk/svelte": "0.0.57",
"@ai-sdk/ui-utils": "0.0.50",
"@ai-sdk/vue": "0.0.59",
"@opentelemetry/api": "1.9.0",
"eventsource-parser": "1.1.2",
"json-schema": "^0.4.0",
"jsondiffpatch": "0.6.0",
"secure-json-parse": "^2.7.0",
"zod-to-json-schema": "^3.23.3"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"openai": "^4.42.0",
"react": "^18 || ^19 || ^19.0.0-rc",
"sswr": "^2.1.0",
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0",
"zod": "^3.0.0"
},
"peerDependenciesMeta": {
"openai": {
"optional": true
},
"react": {
"optional": true
},
"sswr": {
"optional": true
},
"svelte": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@ -5668,7 +6573,6 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@ -5937,7 +6841,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@ -6874,6 +7777,15 @@
"node": ">= 0.8"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
@ -6911,6 +7823,12 @@
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/diff-match-patch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
"integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==",
"license": "Apache-2.0"
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
@ -7044,9 +7962,9 @@
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
"version": "5.18.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz",
"integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
@ -7056,6 +7974,19 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@ -7259,6 +8190,48 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/esbuild": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.9",
"@esbuild/android-arm": "0.25.9",
"@esbuild/android-arm64": "0.25.9",
"@esbuild/android-x64": "0.25.9",
"@esbuild/darwin-arm64": "0.25.9",
"@esbuild/darwin-x64": "0.25.9",
"@esbuild/freebsd-arm64": "0.25.9",
"@esbuild/freebsd-x64": "0.25.9",
"@esbuild/linux-arm": "0.25.9",
"@esbuild/linux-arm64": "0.25.9",
"@esbuild/linux-ia32": "0.25.9",
"@esbuild/linux-loong64": "0.25.9",
"@esbuild/linux-mips64el": "0.25.9",
"@esbuild/linux-ppc64": "0.25.9",
"@esbuild/linux-riscv64": "0.25.9",
"@esbuild/linux-s390x": "0.25.9",
"@esbuild/linux-x64": "0.25.9",
"@esbuild/netbsd-arm64": "0.25.9",
"@esbuild/netbsd-x64": "0.25.9",
"@esbuild/openbsd-arm64": "0.25.9",
"@esbuild/openbsd-x64": "0.25.9",
"@esbuild/openharmony-arm64": "0.25.9",
"@esbuild/sunos-x64": "0.25.9",
"@esbuild/win32-arm64": "0.25.9",
"@esbuild/win32-ia32": "0.25.9",
"@esbuild/win32-x64": "0.25.9"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -7670,6 +8643,13 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esm-env": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT",
"peer": true
},
"node_modules/espree": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
@ -7714,6 +8694,16 @@
"node": ">=0.10"
}
},
"node_modules/esrap": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.0.tgz",
"integrity": "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
}
},
"node_modules/esrecurse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
@ -7736,6 +8726,13 @@
"node": ">=4.0"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT",
"peer": true
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@ -7762,6 +8759,15 @@
"optional": true,
"peer": true
},
"node_modules/eventsource-parser": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.2.tgz",
"integrity": "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==",
"license": "MIT",
"engines": {
"node": ">=14.18"
}
},
"node_modules/exponential-backoff": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz",
@ -8187,6 +9193,21 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -9033,6 +10054,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "^1.0.6"
}
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@ -9293,6 +10324,12 @@
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@ -9320,6 +10357,35 @@
"json5": "lib/cli.js"
}
},
"node_modules/jsondiffpatch": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz",
"integrity": "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==",
"license": "MIT",
"dependencies": {
"@types/diff-match-patch": "^1.0.36",
"chalk": "^5.3.0",
"diff-match-patch": "^1.0.5"
},
"bin": {
"jsondiffpatch": "bin/jsondiffpatch.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/jsondiffpatch/node_modules/chalk": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz",
"integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
@ -9694,6 +10760,13 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"license": "MIT",
"peer": true
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -10778,6 +11851,95 @@
"license": "MIT",
"optional": true
},
"node_modules/pg": {
"version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
"pg-protocol": "^1.10.3",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.2.7"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -10822,9 +11984,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.4",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.4.tgz",
"integrity": "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==",
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [
{
"type": "opencollective",
@ -10849,6 +12011,45 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -11553,6 +12754,12 @@
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"license": "MIT"
},
"node_modules/secure-json-parse": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==",
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
@ -11926,6 +13133,15 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
@ -11956,6 +13172,18 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/sswr": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/sswr/-/sswr-2.2.0.tgz",
"integrity": "sha512-clTszLPZkmycALTHD1mXGU+mOtA/MIoLgS1KGTTzFNVm9rytQVykgRaP+z1zl572cz0bTqj4rFVoC2N+IGK4Sg==",
"license": "MIT",
"dependencies": {
"swrev": "^4.0.0"
},
"peerDependencies": {
"svelte": "^4.0.0 || ^5.0.0"
}
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@ -12319,6 +13547,32 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svelte": {
"version": "5.38.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.38.1.tgz",
"integrity": "sha512-fO6CLDfJYWHgfo6lQwkQU2vhCiHc2MBl6s3vEhK+sSZru17YL4R5s1v14ndRpqKAIkq8nCz6MTk1yZbESZWeyQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@types/estree": "^1.0.5",
"acorn": "^8.12.1",
"aria-query": "^5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"esm-env": "^1.2.1",
"esrap": "^2.1.0",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
"zimmerframe": "^1.1.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
@ -12329,6 +13583,34 @@
"node": ">=12.0.0"
}
},
"node_modules/swr": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz",
"integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/swrev": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/swrev/-/swrev-4.0.0.tgz",
"integrity": "sha512-LqVcOHSB4cPGgitD1riJ1Hh4vdmITOp+BkmfmXRh4hSF/t7EnS4iD+SOTmq7w5pPm/SiPeto4ADbKS6dHUDWFA==",
"license": "MIT"
},
"node_modules/swrv": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/swrv/-/swrv-1.1.0.tgz",
"integrity": "sha512-pjllRDr2s0iTwiE5Isvip51dZGR7GjLH1gCSVyE8bQnbAx6xackXsFdojau+1O5u98yHF5V73HQGOFxKUXO9gQ==",
"license": "Apache-2.0",
"peerDependencies": {
"vue": ">=3.2.26 < 4"
}
},
"node_modules/symbol-observable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",
@ -12339,9 +13621,9 @@
}
},
"node_modules/tailwind-merge": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.0.tgz",
"integrity": "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
"integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==",
"license": "MIT",
"funding": {
"type": "github",
@ -12349,9 +13631,9 @@
}
},
"node_modules/tailwindcss": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.8.tgz",
"integrity": "sha512-kjeW8gjdxasbmFKpVGrGd5T4i40mV5J2Rasw48QARfYeQ8YS9x02ON9SFWax3Qf616rt4Cp3nVNIj6Hd1mP3og==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==",
"license": "MIT"
},
"node_modules/tailwindcss-animate": {
@ -12432,6 +13714,18 @@
"utrie": "^1.0.2"
}
},
"node_modules/throttleit": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
"integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
@ -12552,6 +13846,26 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.4.tgz",
"integrity": "sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/ttf2woff2": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/ttf2woff2/-/ttf2woff2-8.0.0.tgz",
@ -12935,6 +14249,28 @@
"node": ">= 0.8"
}
},
"node_modules/vue": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.18.tgz",
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.18",
"@vue/compiler-sfc": "3.5.18",
"@vue/runtime-dom": "3.5.18",
"@vue/server-renderer": "3.5.18",
"@vue/shared": "3.5.18"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@ -13180,6 +14516,15 @@
"node": ">=0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
@ -13329,6 +14674,13 @@
"zen-observable": "0.8.15"
}
},
"node_modules/zimmerframe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
"license": "MIT",
"peer": true
},
"node_modules/zod": {
"version": "3.25.56",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.56.tgz",
@ -13337,6 +14689,15 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.24.6",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz",
"integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"
}
}
}
}

View File

@ -7,6 +7,7 @@
"build": "prisma generate && next build",
"start": "next start",
"lint": "next lint",
"ai:gateway": "tsx scripts/gateway.ts",
"test-db": "node scripts/test-db.mjs",
"db:push": "prisma db push",
"db:generate": "prisma generate",
@ -18,6 +19,7 @@
"update:env": "bash scripts/update-env.sh"
},
"dependencies": {
"@ai-sdk/openai": "^1.3.24",
"@apollo/client": "^3.13.8",
"@apollo/experimental-nextjs-app-support": "^0.12.2",
"@apollo/server": "^4.12.2",
@ -33,19 +35,22 @@
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@tailwindcss/postcss": "^4",
"@tailwindcss/postcss": "^4.1.11",
"@types/bcryptjs": "^2.4.6",
"@types/js-cookie": "^3.0.6",
"@types/jsonwebtoken": "^9.0.9",
"@types/jspdf": "^1.3.3",
"@types/pdfkit": "^0.14.0",
"@types/pg": "^8.15.4",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^10.0.0",
"ai": "^3.4.33",
"axios": "^1.10.0",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
@ -65,6 +70,8 @@
"lucide-react": "^0.513.0",
"next": "15.3.3",
"pdfkit": "^0.17.1",
"pg": "^8.16.3",
"postcss": "^8.5.6",
"prisma": "^6.9.0",
"puppeteer": "^24.10.2",
"qrcode": "^1.5.4",
@ -73,8 +80,8 @@
"react-hook-form": "^7.57.0",
"react-hot-toast": "^2.5.2",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.0",
"tailwindcss": "^4",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tailwindcss-animate": "^1.0.7",
"ttf2woff2": "^8.0.0",
"uuid": "^11.1.0",
@ -88,6 +95,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.3.3",
"tsx": "^4.19.2",
"tw-animate-css": "^1.3.4",
"typescript": "^5"
}

View File

@ -1,6 +1,7 @@
/** @type {import('postcss-load-config').Config} */
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@ -9,21 +9,19 @@ datasource db {
}
model User {
id String @id @default(cuid())
firstName String
lastName String
email String @unique
password String
avatar String?
role UserRole @default(ADMIN)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Связь с логами аудита
auditLogs AuditLog[]
id String @id @default(cuid())
firstName String
lastName String
email String @unique
password String
avatar String?
role UserRole @default(ADMIN)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
auditLogs AuditLog[]
balanceChanges ClientBalanceHistory[]
managedClients Client[]
productHistory ProductHistory[]
managedClients Client[] // Клиенты, которыми управляет менеджер
balanceChanges ClientBalanceHistory[] // История изменений баланса
@@map("users")
}
@ -31,103 +29,125 @@ model User {
model AuditLog {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
action AuditAction
details String?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("audit_logs")
}
// Модели каталога товаров
model Category {
id String @id @default(cuid())
name String
slug String @unique
description String?
seoTitle String?
seoDescription String?
image String?
isHidden Boolean @default(false)
includeSubcategoryProducts Boolean @default(false)
parentId String?
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
children Category[] @relation("CategoryHierarchy")
products Product[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
name String
slug String @unique
description String?
seoTitle String?
seoDescription String?
image String?
isHidden Boolean @default(false)
includeSubcategoryProducts Boolean @default(false)
parentId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
children Category[] @relation("CategoryHierarchy")
products Product[] @relation("CategoryToProduct")
@@map("categories")
}
model NavigationCategory {
id String @id @default(cuid())
// Привязка к категории PartsIndex
partsIndexCatalogId String // ID каталога из PartsIndex API
partsIndexGroupId String? // ID группы из PartsIndex API (необязательно)
// Только иконка - название берем из PartsIndex
icon String? // URL иконки в S3
// Настройки отображения
isHidden Boolean @default(false)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Уникальная комбинация catalogId + groupId
@@unique([partsIndexCatalogId, partsIndexGroupId])
@@map("navigation_categories")
}
model Product {
id String @id @default(cuid())
name String
slug String @unique
article String? @unique
description String?
videoUrl String?
wholesalePrice Float?
retailPrice Float?
weight Float?
dimensions String? // ДхШхВ в формате "10x20x30"
unit String @default("шт")
isVisible Boolean @default(true)
applyDiscounts Boolean @default(true)
stock Int @default(0)
// Связи
categories Category[]
images ProductImage[]
options ProductOption[]
characteristics ProductCharacteristic[]
relatedProducts Product[] @relation("RelatedProducts")
relatedTo Product[] @relation("RelatedProducts")
accessoryProducts Product[] @relation("AccessoryProducts")
accessoryTo Product[] @relation("AccessoryProducts")
history ProductHistory[]
orderItems OrderItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
name String
slug String @unique
article String? @unique
description String?
videoUrl String?
wholesalePrice Float?
retailPrice Float?
weight Float?
dimensions String?
unit String @default("шт")
isVisible Boolean @default(true)
applyDiscounts Boolean @default(true)
stock Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
brand String?
bestPriceProducts BestPriceProduct?
dailyProducts DailyProduct[]
topSalesProducts TopSalesProduct?
orderItems OrderItem[]
characteristics ProductCharacteristic[]
history ProductHistory[]
images ProductImage[]
options ProductOption[]
products_AccessoryProducts_A Product[] @relation("AccessoryProducts")
products_AccessoryProducts_B Product[] @relation("AccessoryProducts")
categories Category[] @relation("CategoryToProduct")
products_RelatedProducts_A Product[] @relation("RelatedProducts")
products_RelatedProducts_B Product[] @relation("RelatedProducts")
@@map("products")
}
model ProductImage {
id String @id @default(cuid())
id String @id @default(cuid())
url String
alt String?
order Int @default(0)
order Int @default(0)
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@map("product_images")
}
model Option {
id String @id @default(cuid())
name String @unique
type OptionType @default(SINGLE)
values OptionValue[]
products ProductOption[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
name String @unique
type OptionType @default(SINGLE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
values OptionValue[]
products ProductOption[]
@@map("options")
}
model OptionValue {
id String @id @default(cuid())
id String @id @default(cuid())
value String
price Float @default(0)
price Float @default(0)
optionId String
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
products ProductOption[]
createdAt DateTime @default(now())
@@map("option_values")
}
@ -135,22 +155,22 @@ model OptionValue {
model ProductOption {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
optionId String
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
optionValueId String
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
optionValue OptionValue @relation(fields: [optionValueId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([productId, optionId, optionValueId])
@@map("product_options")
}
model Characteristic {
id String @id @default(cuid())
name String @unique
id String @id @default(cuid())
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
products ProductCharacteristic[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("characteristics")
}
@ -159,9 +179,9 @@ model ProductCharacteristic {
id String @id @default(cuid())
value String
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
characteristicId String
characteristic Characteristic @relation(fields: [characteristicId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([productId, characteristicId])
@@map("product_characteristics")
@ -170,16 +190,557 @@ model ProductCharacteristic {
model ProductHistory {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
action String // CREATE, UPDATE, DELETE
changes Json? // JSON с изменениями
action String
changes Json?
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id])
@@map("product_history")
}
model Client {
id String @id @default(cuid())
clientNumber String @unique
type ClientType @default(INDIVIDUAL)
name String
email String?
phone String
city String?
markup Float? @default(0)
isConfirmed Boolean @default(false)
profileId String?
managerId String?
balance Float @default(0)
comment String?
emailNotifications Boolean @default(true)
smsNotifications Boolean @default(true)
pushNotifications Boolean @default(false)
legalEntityType String?
legalEntityName String?
inn String?
kpp String?
ogrn String?
okpo String?
legalAddress String?
actualAddress String?
bankAccount String?
bankName String?
bankBik String?
correspondentAccount String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
balanceHistory ClientBalanceHistory[]
bankDetails ClientBankDetails[]
contacts ClientContact[]
contracts ClientContract[]
deliveryAddresses ClientDeliveryAddress[]
discounts ClientDiscount[]
legalEntities ClientLegalEntity[]
vehicles ClientVehicle[]
manager User? @relation(fields: [managerId], references: [id])
profile ClientProfile? @relation(fields: [profileId], references: [id])
favorites Favorite[]
orders Order[]
partsSearchHistory PartsSearchHistory[]
@@map("clients")
}
model Favorite {
id String @id @default(cuid())
clientId String
productId String?
offerKey String?
name String
brand String
article String
price Float?
currency String?
image String?
createdAt DateTime @default(now())
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@unique([clientId, productId, offerKey, article, brand])
@@map("favorites")
}
model ClientProfile {
id String @id @default(cuid())
code String @unique
name String @unique
description String?
baseMarkup Float @default(0)
autoSendInvoice Boolean @default(true)
vinRequestModule Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
clients Client[]
discountProfiles DiscountProfile[]
brandMarkups ProfileBrandMarkup[]
categoryMarkups ProfileCategoryMarkup[]
excludedBrands ProfileExcludedBrand[]
excludedCategories ProfileExcludedCategory[]
orderDiscounts ProfileOrderDiscount[]
paymentTypes ProfilePaymentType[]
priceRangeMarkups ProfilePriceRangeMarkup[]
supplierMarkups ProfileSupplierMarkup[]
@@map("client_profiles")
}
model ProfilePriceRangeMarkup {
id String @id @default(cuid())
profileId String
priceFrom Float
priceTo Float
markupType MarkupType @default(PERCENTAGE)
markupValue Float
createdAt DateTime @default(now())
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@map("profile_price_range_markups")
}
model ProfileOrderDiscount {
id String @id @default(cuid())
profileId String
minOrderSum Float
discountType DiscountType @default(PERCENTAGE)
discountValue Float
createdAt DateTime @default(now())
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@map("profile_order_discounts")
}
model ProfileSupplierMarkup {
id String @id @default(cuid())
profileId String
supplierName String
markupType MarkupType @default(PERCENTAGE)
markupValue Float
createdAt DateTime @default(now())
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@map("profile_supplier_markups")
}
model ProfileBrandMarkup {
id String @id @default(cuid())
profileId String
brandName String
markupType MarkupType @default(PERCENTAGE)
markupValue Float
createdAt DateTime @default(now())
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@map("profile_brand_markups")
}
model ProfileCategoryMarkup {
id String @id @default(cuid())
profileId String
categoryName String
markupType MarkupType @default(PERCENTAGE)
markupValue Float
createdAt DateTime @default(now())
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@map("profile_category_markups")
}
model ProfileExcludedBrand {
id String @id @default(cuid())
profileId String
brandName String
createdAt DateTime @default(now())
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@map("profile_excluded_brands")
}
model ProfileExcludedCategory {
id String @id @default(cuid())
profileId String
categoryName String
createdAt DateTime @default(now())
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@map("profile_excluded_categories")
}
model ProfilePaymentType {
id String @id @default(cuid())
profileId String
paymentType PaymentType
isEnabled Boolean @default(true)
createdAt DateTime @default(now())
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@map("profile_payment_types")
}
model ClientVehicle {
id String @id @default(cuid())
clientId String
name String
vin String?
frame String?
licensePlate String?
brand String?
model String?
modification String?
year Int?
mileage Int?
comment String?
createdAt DateTime @default(now())
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@map("client_vehicles")
}
model PartsSearchHistory {
id String @id @default(cuid())
clientId String
searchQuery String
searchType SearchType
brand String?
articleNumber String?
vehicleBrand String?
vehicleModel String?
vehicleYear Int?
resultCount Int @default(0)
createdAt DateTime @default(now())
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@map("parts_search_history")
}
// История запросов ZZAP со скриншотами
model ZzapRequest {
id String @id @default(cuid())
provider String @default("zzap")
article String
statsUrl String?
imageUrl String?
ok Boolean @default(false)
selector String?
logs Json?
requestedBy String?
createdAt DateTime @default(now())
@@index([article])
@@map("zzap_requests")
}
model ClientDeliveryAddress {
id String @id @default(cuid())
clientId String
name String
address String
deliveryType DeliveryType @default(COURIER)
comment String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
apartment String?
contactPhone String?
deliveryTime String?
entrance String?
floor String?
intercom String?
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@map("client_delivery_addresses")
}
model ClientContact {
id String @id @default(cuid())
clientId String
phone String?
email String?
comment String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@map("client_contacts")
}
model ClientContract {
id String @id @default(cuid())
clientId String
contractNumber String
contractDate DateTime
name String
ourLegalEntity String
clientLegalEntity String
balance Float @default(0)
currency String @default("RUB")
isActive Boolean @default(true)
isDefault Boolean @default(false)
contractType String
relationship String
paymentDelay Boolean @default(false)
creditLimit Float?
delayDays Int?
fileUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
balanceInvoices BalanceInvoice[]
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@map("client_contracts")
}
model BalanceInvoice {
id String @id @default(cuid())
contractId String
amount Float
currency String @default("RUB")
status InvoiceStatus @default(PENDING)
invoiceNumber String @unique
qrCode String
pdfUrl String?
paymentUrl String?
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contract ClientContract @relation(fields: [contractId], references: [id], onDelete: Cascade)
@@map("balance_invoices")
}
model ClientLegalEntity {
id String @id @default(cuid())
clientId String
shortName String
fullName String
form String
legalAddress String
actualAddress String?
taxSystem String
responsiblePhone String?
responsiblePosition String?
responsibleName String?
accountant String?
signatory String?
registrationReasonCode String?
ogrn String?
inn String
vatPercent Float @default(20)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bankDetails ClientBankDetails[]
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@map("client_legal_entities")
}
model ClientBankDetails {
id String @id @default(cuid())
clientId String
legalEntityId String?
name String
accountNumber String
bankName String
bik String
correspondentAccount String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
legalEntity ClientLegalEntity? @relation(fields: [legalEntityId], references: [id])
@@map("client_bank_details")
}
model ClientBalanceHistory {
id String @id @default(cuid())
clientId String
userId String?
oldValue Float
newValue Float
comment String?
createdAt DateTime @default(now())
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id])
@@map("client_balance_history")
}
model ClientDiscount {
id String @id @default(cuid())
clientId String
name String
type DiscountType
value Float
isActive Boolean @default(true)
validFrom DateTime?
validTo DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@map("client_discounts")
}
model ClientStatus {
id String @id @default(cuid())
name String @unique
color String @default("#6B7280")
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("client_statuses")
}
model Discount {
id String @id @default(cuid())
name String
type DiscountCodeType @default(DISCOUNT)
code String? @unique
minOrderAmount Float? @default(0)
discountType DiscountType @default(PERCENTAGE)
discountValue Float
isActive Boolean @default(true)
validFrom DateTime?
validTo DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
profiles DiscountProfile[]
@@map("discounts")
}
model DiscountProfile {
id String @id @default(cuid())
discountId String
profileId String
createdAt DateTime @default(now())
discount Discount @relation(fields: [discountId], references: [id], onDelete: Cascade)
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@unique([discountId, profileId])
@@map("discount_profiles")
}
model Order {
id String @id @default(cuid())
orderNumber String @unique
clientId String?
clientEmail String?
clientPhone String?
clientName String?
status OrderStatus @default(PENDING)
totalAmount Float
discountAmount Float @default(0)
finalAmount Float
currency String @default("RUB")
deliveryAddress String?
comment String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items OrderItem[]
client Client? @relation(fields: [clientId], references: [id])
payments Payment[]
@@map("orders")
}
model OrderItem {
id String @id @default(cuid())
orderId String
productId String?
externalId String?
name String
article String?
brand String?
price Float
quantity Int
totalPrice Float
createdAt DateTime @default(now())
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
product Product? @relation(fields: [productId], references: [id])
@@map("order_items")
}
model Payment {
id String @id @default(cuid())
orderId String
yookassaPaymentId String @unique
status PaymentStatus @default(PENDING)
amount Float
currency String @default("RUB")
paymentMethod String?
description String?
confirmationUrl String?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
paidAt DateTime?
canceledAt DateTime?
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@map("payments")
}
model DailyProduct {
id String @id @default(cuid())
productId String
displayDate DateTime
discount Float?
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([productId, displayDate])
@@map("daily_products")
}
model BestPriceProduct {
id String @id @default(cuid())
productId String @unique
discount Float?
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@map("best_price_products")
}
model TopSalesProduct {
id String @id @default(cuid())
productId String @unique
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@map("top_sales_products")
}
model HeroBanner {
id String @id @default(cuid())
title String
subtitle String?
imageUrl String
linkUrl String?
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("hero_banners")
}
enum UserRole {
ADMIN
MODERATOR
@ -208,591 +769,109 @@ enum OptionType {
MULTIPLE
}
// Модели для клиентов
model Client {
id String @id @default(cuid())
clientNumber String @unique
type ClientType @default(INDIVIDUAL)
name String
email String?
phone String
city String?
markup Float? @default(0)
isConfirmed Boolean @default(false)
profileId String?
profile ClientProfile? @relation(fields: [profileId], references: [id])
managerId String? // Личный менеджер
manager User? @relation(fields: [managerId], references: [id])
balance Float @default(0)
comment String?
// Уведомления
emailNotifications Boolean @default(true)
smsNotifications Boolean @default(true)
pushNotifications Boolean @default(false)
// Поля для юридических лиц
legalEntityType String? // ООО, ИП, АО и т.д.
legalEntityName String? // Наименование юрлица
inn String?
kpp String?
ogrn String?
okpo String?
legalAddress String?
actualAddress String?
bankAccount String?
bankName String?
bankBik String?
correspondentAccount String?
// Связи
vehicles ClientVehicle[]
discounts ClientDiscount[]
deliveryAddresses ClientDeliveryAddress[]
contacts ClientContact[]
contracts ClientContract[]
legalEntities ClientLegalEntity[]
bankDetails ClientBankDetails[]
balanceHistory ClientBalanceHistory[]
orders Order[]
partsSearchHistory PartsSearchHistory[]
favorites Favorite[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("clients")
}
// Модель для избранных товаров
model Favorite {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
// Данные о товаре - для внешних товаров (AutoEuro, PartsAPI)
productId String? // ID товара во внешней системе или внутренний ID
offerKey String? // Ключ предложения (для AutoEuro)
name String // Название товара
brand String // Бренд
article String // Артикул
price Float? // Цена (может отсутствовать)
currency String? // Валюта
image String? // URL изображения
createdAt DateTime @default(now())
// Уникальность по клиенту и комбинации идентификаторов товара
@@unique([clientId, productId, offerKey, article, brand])
@@map("favorites")
}
model ClientProfile {
id String @id @default(cuid())
code String @unique
name String @unique
description String?
baseMarkup Float @default(0)
autoSendInvoice Boolean @default(true)
vinRequestModule Boolean @default(false)
clients Client[]
// Связи с дополнительными настройками
priceRangeMarkups ProfilePriceRangeMarkup[]
orderDiscounts ProfileOrderDiscount[]
supplierMarkups ProfileSupplierMarkup[]
brandMarkups ProfileBrandMarkup[]
categoryMarkups ProfileCategoryMarkup[]
excludedBrands ProfileExcludedBrand[]
excludedCategories ProfileExcludedCategory[]
paymentTypes ProfilePaymentType[]
discountProfiles DiscountProfile[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("client_profiles")
}
// Наценки от стоимости товара
model ProfilePriceRangeMarkup {
id String @id @default(cuid())
profileId String
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
priceFrom Float
priceTo Float
markupType MarkupType @default(PERCENTAGE)
markupValue Float
createdAt DateTime @default(now())
@@map("profile_price_range_markups")
}
// Скидки от суммы заказа
model ProfileOrderDiscount {
id String @id @default(cuid())
profileId String
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
minOrderSum Float
discountType DiscountType @default(PERCENTAGE)
discountValue Float
createdAt DateTime @default(now())
@@map("profile_order_discounts")
}
// Наценки на поставщиков
model ProfileSupplierMarkup {
id String @id @default(cuid())
profileId String
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
supplierName String
markupType MarkupType @default(PERCENTAGE)
markupValue Float
createdAt DateTime @default(now())
@@map("profile_supplier_markups")
}
// Наценки на бренды
model ProfileBrandMarkup {
id String @id @default(cuid())
profileId String
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
brandName String
markupType MarkupType @default(PERCENTAGE)
markupValue Float
createdAt DateTime @default(now())
@@map("profile_brand_markups")
}
// Наценки на категории товаров
model ProfileCategoryMarkup {
id String @id @default(cuid())
profileId String
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
categoryName String
markupType MarkupType @default(PERCENTAGE)
markupValue Float
createdAt DateTime @default(now())
@@map("profile_category_markups")
}
// Исключенные бренды
model ProfileExcludedBrand {
id String @id @default(cuid())
profileId String
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
brandName String
createdAt DateTime @default(now())
@@map("profile_excluded_brands")
}
// Исключенные категории
model ProfileExcludedCategory {
id String @id @default(cuid())
profileId String
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
categoryName String
createdAt DateTime @default(now())
@@map("profile_excluded_categories")
}
// Типы платежей для профиля
model ProfilePaymentType {
id String @id @default(cuid())
profileId String
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
paymentType PaymentType
isEnabled Boolean @default(true)
createdAt DateTime @default(now())
@@map("profile_payment_types")
}
enum MarkupType {
PERCENTAGE // Процентная наценка
FIXED_AMOUNT // Фиксированная сумма
PERCENTAGE
FIXED_AMOUNT
}
enum PaymentType {
CASH // Наличные
CARD // Банковская карта
BANK_TRANSFER // Банковский перевод
ONLINE // Онлайн платежи
CREDIT // В кредит
}
model ClientVehicle {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
name String // Название авто
vin String?
frame String?
licensePlate String?
brand String?
model String?
modification String?
year Int?
mileage Int?
comment String?
createdAt DateTime @default(now())
@@map("client_vehicles")
}
// История поиска запчастей
model PartsSearchHistory {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
searchQuery String // Поисковый запрос
searchType SearchType // Тип поиска
brand String? // Бренд (если искали по бренду)
articleNumber String? // Артикул (если искали по артикулу)
// Информация об автомобиле (если поиск был для конкретного авто)
vehicleBrand String?
vehicleModel String?
vehicleYear Int?
resultCount Int @default(0) // Количество найденных результатов
createdAt DateTime @default(now())
@@map("parts_search_history")
CASH
CARD
BANK_TRANSFER
ONLINE
CREDIT
}
enum SearchType {
TEXT // Текстовый поиск
ARTICLE // Поиск по артикулу
OEM // Поиск по OEM номеру
VIN // Поиск автомобиля по VIN/Frame
PLATE // Поиск автомобиля по госномеру
WIZARD // Поиск автомобиля по параметрам
PART_VEHICLES // Поиск автомобилей по артикулу детали
}
// Адреса доставки
model ClientDeliveryAddress {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
name String // Название адреса
address String // Полный адрес
deliveryType DeliveryType @default(COURIER)
comment String?
// Дополнительные поля для курьерской доставки
entrance String? // Подъезд
floor String? // Этаж
apartment String? // Квартира/офис
intercom String? // Домофон
deliveryTime String? // Желаемое время доставки
contactPhone String? // Контактный телефон
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("client_delivery_addresses")
}
// Контакты
model ClientContact {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
phone String?
email String?
comment String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("client_contacts")
}
// Договоры
model ClientContract {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
contractNumber String
contractDate DateTime
name String
ourLegalEntity String // Наше ЮЛ
clientLegalEntity String // ЮЛ клиента
balance Float @default(0)
currency String @default("RUB")
isActive Boolean @default(true)
isDefault Boolean @default(false)
contractType String // Тип договора
relationship String // Отношение
paymentDelay Boolean @default(false)
creditLimit Float?
delayDays Int?
fileUrl String? // Ссылка на файл договора
balanceInvoices BalanceInvoice[] // Счета на пополнение баланса
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("client_contracts")
}
// Счета на пополнение баланса
model BalanceInvoice {
id String @id @default(cuid())
contractId String
contract ClientContract @relation(fields: [contractId], references: [id], onDelete: Cascade)
amount Float
currency String @default("RUB")
status InvoiceStatus @default(PENDING)
invoiceNumber String @unique
qrCode String // QR код для оплаты
pdfUrl String? // Ссылка на PDF счета
paymentUrl String? // Ссылка на оплату
expiresAt DateTime // Срок действия счета
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("balance_invoices")
TEXT
ARTICLE
OEM
VIN
PLATE
WIZARD
PART_VEHICLES
}
enum InvoiceStatus {
PENDING // Ожидает оплаты
PAID // Оплачен
EXPIRED // Просрочен
CANCELLED // Отменен
}
// Юридические лица клиента
model ClientLegalEntity {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
shortName String // Короткое наименование
fullName String // Полное наименование
form String // Форма (ООО, ИП и т.д.)
legalAddress String // Юридический адрес
actualAddress String? // Фактический адрес
taxSystem String // Система налогообложения
responsiblePhone String? // Телефон ответственного
responsiblePosition String? // Должность ответственного
responsibleName String? // ФИО ответственного
accountant String? // Бухгалтер
signatory String? // Подписант
registrationReasonCode String? // Код причины постановки на учет
ogrn String? // ОГРН
inn String // ИНН
vatPercent Float @default(20) // НДС в процентах
bankDetails ClientBankDetails[] // Банковские реквизиты
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("client_legal_entities")
}
// Банковские реквизиты
model ClientBankDetails {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
legalEntityId String?
legalEntity ClientLegalEntity? @relation(fields: [legalEntityId], references: [id])
name String // Название реквизитов
accountNumber String // Расчетный счет
bankName String // Наименование банка
bik String // БИК
correspondentAccount String // Корреспондентский счет
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("client_bank_details")
}
// История изменения баланса
model ClientBalanceHistory {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
oldValue Float
newValue Float
comment String?
createdAt DateTime @default(now())
@@map("client_balance_history")
}
model ClientDiscount {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
name String
type DiscountType
value Float // процент или фиксированная сумма
isActive Boolean @default(true)
validFrom DateTime?
validTo DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("client_discounts")
}
model ClientStatus {
id String @id @default(cuid())
name String @unique
color String @default("#6B7280")
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("client_statuses")
PENDING
PAID
EXPIRED
CANCELLED
}
enum ClientType {
INDIVIDUAL // Физическое лицо
LEGAL_ENTITY // Юридическое лицо
INDIVIDUAL
LEGAL_ENTITY
}
enum DiscountType {
PERCENTAGE // Процентная скидка
FIXED_AMOUNT // Фиксированная сумма
}
// Модели для скидок и промокодов
model Discount {
id String @id @default(cuid())
name String
type DiscountCodeType @default(DISCOUNT)
code String? @unique // Промокод (если есть)
minOrderAmount Float? @default(0)
discountType DiscountType @default(PERCENTAGE)
discountValue Float
isActive Boolean @default(true)
validFrom DateTime?
validTo DateTime?
// Связи с профилями
profiles DiscountProfile[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("discounts")
}
// Связь скидок с профилями клиентов
model DiscountProfile {
id String @id @default(cuid())
discountId String
discount Discount @relation(fields: [discountId], references: [id], onDelete: Cascade)
profileId String
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([discountId, profileId])
@@map("discount_profiles")
PERCENTAGE
FIXED_AMOUNT
}
enum DiscountCodeType {
DISCOUNT // Обычная скидка
PROMOCODE // Промокод
DISCOUNT
PROMOCODE
}
// Cart models for backend cart storage
model Cart {
id String @id @default(cuid())
clientId String @unique // Can be authenticated client ID or anonymous session ID
items CartItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("carts")
}
model CartItem {
id String @id @default(cuid())
cartId String
productId String? // For internal products
offerKey String? // For external products (AutoEuro)
name String
description String
brand String
article String
price Float
currency String @default("RUB")
quantity Int
stock Int?
deliveryTime String?
warehouse String?
supplier String?
isExternal Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade)
@@map("cart_items")
}
enum DeliveryType {
COURIER // Курьер
PICKUP // Самовывоз
POST // Почта России
TRANSPORT // Транспортная компания
}
// Модели для заказов и платежей
model Order {
id String @id @default(cuid())
orderNumber String @unique
clientId String?
client Client? @relation(fields: [clientId], references: [id], onDelete: SetNull)
clientEmail String? // Для гостевых заказов
clientPhone String? // Для гостевых заказов
clientName String? // Для гостевых заказов
status OrderStatus @default(PENDING)
totalAmount Float
discountAmount Float @default(0)
finalAmount Float // totalAmount - discountAmount
currency String @default("RUB")
items OrderItem[]
payments Payment[]
deliveryAddress String?
comment String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("orders")
}
model OrderItem {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
productId String? // Для внутренних товаров
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
// Для внешних товаров (AutoEuro)
externalId String? // ID товара во внешней системе
name String // Название товара
article String? // Артикул
brand String? // Бренд
price Float // Цена за единицу
quantity Int // Количество
totalPrice Float // price * quantity
createdAt DateTime @default(now())
@@map("order_items")
}
model Payment {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
yookassaPaymentId String @unique // ID платежа в YooKassa
status PaymentStatus @default(PENDING)
amount Float
currency String @default("RUB")
paymentMethod String? // Способ оплаты
description String?
confirmationUrl String? // URL для подтверждения платежа
// Метаданные от YooKassa
metadata Json?
// Даты
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
paidAt DateTime? // Дата успешной оплаты
canceledAt DateTime? // Дата отмены
@@map("payments")
COURIER
PICKUP
POST
TRANSPORT
}
enum OrderStatus {
PENDING // Ожидает оплаты
PAID // Оплачен
PROCESSING // В обработке
SHIPPED // Отправлен
DELIVERED // Доставлен
CANCELED // Отменен
REFUNDED // Возвращен
PENDING
PAID
PROCESSING
SHIPPED
DELIVERED
CANCELED
REFUNDED
}
enum PaymentStatus {
PENDING // Ожидает оплаты
WAITING_FOR_CAPTURE // Ожидает подтверждения
SUCCEEDED // Успешно оплачен
CANCELED // Отменен
REFUNDED // Возвращен
PENDING
WAITING_FOR_CAPTURE
SUCCEEDED
CANCELED
REFUNDED
}

25
rebuild-cms.sh Executable file
View File

@ -0,0 +1,25 @@
#!/bin/bash
echo "🔄 Пересоздание CMS контейнера..."
# Останавливаем и удаляем существующий контейнер
echo "⏹️ Остановка существующего контейнера..."
docker-compose down protekauto-cms
# Удаляем образ для полной пересборки
echo "🗑️ Удаление старого образа..."
docker rmi protekauto-cms_protekauto-cms 2>/dev/null || true
# Очищаем кэш сборки
echo "🧹 Очистка кэша сборки..."
docker builder prune -f
# Собираем и запускаем заново
echo "🏗️ Сборка нового контейнера..."
docker-compose up --build -d protekauto-cms
echo "✅ Готово! CMS доступен на http://localhost:3000"
# Показываем логи
echo "📋 Логи контейнера:"
docker-compose logs -f protekauto-cms

20
scripts/gateway.ts Normal file
View File

@ -0,0 +1,20 @@
import { streamText } from 'ai'
import 'dotenv/config'
async function main() {
const result = streamText({
model: 'openai/gpt-4.1',
prompt: 'Invent a new holiday and describe its traditions.',
})
for await (const textPart of result.textStream) {
process.stdout.write(textPart)
}
console.log()
console.log('Token usage:', await result.usage)
console.log('Finish reason:', await result.finishReason)
}
main().catch(console.error)

6
src/app/admin/page.tsx Normal file
View File

@ -0,0 +1,6 @@
import { redirect } from 'next/navigation'
export default function AdminRedirect() {
redirect('/dashboard')
}

View File

@ -0,0 +1,6 @@
import { redirect } from 'next/navigation'
export default function AdminZzapRedirect() {
redirect('/dashboard/zzap')
}

View File

@ -0,0 +1,79 @@
export async function POST(req: Request) {
try {
const { messages } = await req.json();
const response = await fetch('https://gateway.ai.cloudflare.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.AI_GATEWAY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages,
stream: true,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const stream = new ReadableStream({
async start(controller) {
const reader = response.body?.getReader();
if (!reader) return;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
const content = parsed.choices?.[0]?.delta?.content;
if (content) {
controller.enqueue(encoder.encode(content));
}
} catch (e) {
// Ignore parsing errors
}
}
}
}
} finally {
reader.releaseLock();
controller.close();
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
} catch (error) {
console.error('AI Chat Error:', error);
return new Response(
JSON.stringify({ error: 'Произошла ошибка при обработке запроса' }),
{
status: 500,
headers: { 'Content-Type': 'application/json' }
}
);
}
}

View File

@ -0,0 +1,123 @@
import { NextRequest, NextResponse } from 'next/server';
import { partsIndexService } from '@/lib/partsindex-service';
export async function GET(request: NextRequest) {
try {
const url = new URL(request.url);
const action = url.searchParams.get('action');
const prefix = url.searchParams.get('prefix');
switch (action) {
case 'stats':
// Возвращаем статистику кэша
const stats = partsIndexService.getCacheStats();
return NextResponse.json({
success: true,
data: {
...stats,
formattedEntries: stats.entries.map(entry => ({
...entry,
sizeKB: Math.round(entry.size / 1024 * 100) / 100,
ageMinutes: Math.round(entry.age / (1000 * 60) * 100) / 100,
ttlMinutes: Math.round(entry.ttl / (1000 * 60) * 100) / 100,
isExpired: entry.age > entry.ttl
}))
}
});
case 'clear':
if (prefix) {
partsIndexService.clearCacheByPrefix(prefix);
return NextResponse.json({
success: true,
message: `Кэш с префиксом "${prefix}" очищен`
});
} else {
partsIndexService.clearCache();
return NextResponse.json({
success: true,
message: 'Весь кэш PartsIndex очищен'
});
}
case 'test':
// Тестовый запрос для проверки работы кэша
const catalogs = await partsIndexService.getCatalogs('ru');
return NextResponse.json({
success: true,
data: {
catalogsCount: catalogs.length,
catalogs: catalogs.slice(0, 3).map(c => ({ id: c.id, name: c.name }))
}
});
default:
return NextResponse.json({
success: false,
error: 'Неизвестное действие. Доступные: stats, clear, test'
}, { status: 400 });
}
} catch (error) {
console.error('❌ Ошибка в debug-partsindex API:', error);
return NextResponse.json({
success: false,
error: 'Внутренняя ошибка сервера'
}, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { action, catalogId, lang = 'ru' } = body;
switch (action) {
case 'preload':
// Предзагрузка данных в кэш
console.log('🔄 Предзагрузка данных PartsIndex в кэш...');
const catalogs = await partsIndexService.getCatalogs(lang);
console.log(`✅ Загружено ${catalogs.length} каталогов`);
if (catalogId) {
// Загружаем группы конкретного каталога
const groups = await partsIndexService.getCatalogGroups(catalogId, lang);
console.log(`✅ Загружено ${groups.length} групп для каталога ${catalogId}`);
return NextResponse.json({
success: true,
data: {
catalogsCount: catalogs.length,
groupsCount: groups.length,
catalogId
}
});
} else {
// Загружаем полную структуру
const categoriesWithGroups = await partsIndexService.getCategoriesWithGroups(lang);
const totalGroups = categoriesWithGroups.reduce((acc, cat) => acc + cat.groups.length, 0);
return NextResponse.json({
success: true,
data: {
catalogsCount: catalogs.length,
categoriesWithGroupsCount: categoriesWithGroups.length,
totalGroups
}
});
}
default:
return NextResponse.json({
success: false,
error: 'Неизвестное действие. Доступные: preload'
}, { status: 400 });
}
} catch (error) {
console.error('❌ Ошибка в debug-partsindex POST API:', error);
return NextResponse.json({
success: false,
error: 'Внутренняя ошибка сервера'
}, { status: 500 });
}
}

View File

@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server'
import { laximoUnitService } from '@/lib/laximo-service'
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const catalogCode = searchParams.get('catalogCode') || 'KIA202404'
const vehicleId = searchParams.get('vehicleId') || '2095869513'
const unitId = searchParams.get('unitId') || '1842820926'
const ssd = searchParams.get('ssd') || '$*KwGhjK205_fnwvL-5sH4hIPO7Nv15cSn9L68mOiw9rapsNDZ6Oj64LCvtt2w6OmfqrHnxKf35-rV7Oi3-qmwpqWgp6TV-_X67uzD8fvEp_S-vJjosPa2qbDH0p-WiNuip6Wxvrfy6P762NvUpqOgoaf-5vSx_7eusdqlxP6P7KWj07a_sOa18Oaesb634rGot83z8JuhpqTV0d_Hpf7x4aWkt-ntzdXzn6aLp6PEx_DNzLa0m4fTqqaio6ulpf_wpszi5uDTxNDfg4eU1tvR0d3GxsOYjZbU7Mrk4OTVzfPwm6GmpNXR38el_vHhpaTr_f71wOWmkurT8fTgvKO63OWWncbAxdyjoKS4-fXrpqPXpaIAAAAADaPhQw==$'
console.log('🔍 Debug Unit API - Параметры:', { catalogCode, vehicleId, unitId, ssd: ssd ? `${ssd.substring(0, 30)}...` : 'отсутствует' })
try {
const results: any = {
testParams: { catalogCode, vehicleId, unitId, ssdLength: ssd?.length },
timestamp: new Date().toISOString()
}
// 1. Тестируем GetUnitInfo
console.log('🔍 Тестируем GetUnitInfo...')
const unitInfo = await laximoUnitService.getUnitInfo(catalogCode, vehicleId, unitId, ssd)
results.unitInfo = {
success: !!unitInfo,
data: unitInfo,
hasImage: !!(unitInfo?.imageurl || unitInfo?.largeimageurl),
attributesCount: unitInfo?.attributes?.length || 0
}
// 2. Тестируем ListDetailByUnit
console.log('🔍 Тестируем ListDetailByUnit...')
const unitDetails = await laximoUnitService.getUnitDetails(catalogCode, vehicleId, unitId, ssd)
results.unitDetails = {
success: Array.isArray(unitDetails),
detailsCount: unitDetails?.length || 0,
data: unitDetails || [],
sampleDetail: unitDetails?.[0] || null
}
// 3. Тестируем ListImageMapByUnit
console.log('🔍 Тестируем ListImageMapByUnit...')
const imageMap = await laximoUnitService.getUnitImageMap(catalogCode, vehicleId, unitId, ssd)
results.imageMap = {
success: !!imageMap,
coordinatesCount: imageMap?.coordinates?.length || 0,
data: imageMap,
sampleCoordinate: imageMap?.coordinates?.[0] || null
}
// Суммарная статистика
results.summary = {
hasUnitInfo: !!unitInfo,
hasImage: !!(unitInfo?.imageurl || unitInfo?.largeimageurl),
detailsCount: unitDetails?.length || 0,
coordinatesCount: imageMap?.coordinates?.length || 0,
allDataPresent: !!unitInfo && (unitDetails?.length || 0) > 0 && !!imageMap
}
console.log('✅ Debug Unit API результаты:', results.summary)
return NextResponse.json(results)
} catch (error) {
console.error('❌ Ошибка в Debug Unit API:', error)
return NextResponse.json({
error: 'Ошибка тестирования API узлов',
details: error instanceof Error ? error.message : String(error),
testParams: { catalogCode, vehicleId, unitId, ssdLength: ssd?.length }
}, { status: 500 })
}
}

View File

@ -86,6 +86,24 @@ const server = new ApolloServer({
typeDefs,
resolvers,
introspection: true,
plugins: [
{
async requestDidStart() {
return {
async didResolveOperation(requestContext: any) {
console.log('🔍 GraphQL Operation:', {
operationName: requestContext.request.operationName,
query: requestContext.request.query?.replace(/\s+/g, ' ').substring(0, 200) + '...',
variables: requestContext.request.variables
});
},
async didEncounterErrors(requestContext: any) {
console.error('❌ GraphQL Errors:', requestContext.errors);
}
};
}
}
]
})
const handler = startServerAndCreateNextHandler(server, {

View File

@ -0,0 +1,71 @@
import type { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10) || 1)
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '20', 10) || 20))
const q = (searchParams.get('q') || '').trim()
const skip = (page - 1) * pageSize
let items: any[] = []
let total = 0
if ((prisma as any).zzapRequest?.findMany) {
const where = q ? { article: { contains: q, mode: 'insensitive' as const } } : undefined
const [i, t] = await Promise.all([
(prisma as any).zzapRequest.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
select: {
id: true,
provider: true,
article: true,
statsUrl: true,
imageUrl: true,
ok: true,
selector: true,
logs: true,
createdAt: true,
}
}),
(prisma as any).zzapRequest.count({ where })
])
items = i
total = t
} else {
// Fallback via raw SQL if client not regenerated yet (Postgres quoted camelCase)
let where = 'WHERE 1=1'
if (q) {
const esc = q.replace(/['\\]/g, (m) => ({"'":"''","\\":"\\\\"}[m] as string)).replace(/[%_]/g, (m) => '\\' + m)
where += ` AND "article" ILIKE '%${esc}%' ESCAPE '\\'`
}
const rows = await prisma.$queryRawUnsafe<any[]>(
`SELECT id, provider, article, "statsUrl" as "statsUrl", "imageUrl" as "imageUrl", ok, selector, logs, "createdAt" as "createdAt"
FROM "zzap_requests"
${where}
ORDER BY "createdAt" DESC
LIMIT ${pageSize} OFFSET ${skip}`
)
const cnt = await prisma.$queryRawUnsafe<any[]>(
`SELECT COUNT(*)::int as count FROM "zzap_requests" ${where}`
)
items = rows
total = cnt?.[0]?.count || 0
}
return new Response(JSON.stringify({ items, total, page, pageSize }), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
})
} catch (err: any) {
return new Response(JSON.stringify({ error: String(err?.message || err || 'Unknown error') }), {
status: 500,
headers: { 'content-type': 'application/json; charset=utf-8' }
})
}
}

View File

@ -0,0 +1,666 @@
import type { NextRequest } from 'next/server'
import puppeteer from 'puppeteer'
import { uploadBuffer } from '@/lib/s3'
import { prisma } from '@/lib/prisma'
import fs from 'fs'
import path from 'path'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const ZZAP_BASE = process.env.ZZAP_BASE || 'https://www.zzap.ru'
const ZZAP_TIMEOUT_MS = Number(process.env.ZZAP_TIMEOUT_MS || 30000)
const COOKIE_FILE = process.env.ZZAP_COOKIE_FILE || path.join(process.cwd(), '.zzap-session.json')
const COOKIE_TTL_MIN = Number(process.env.ZZAP_SESSION_TTL_MINUTES || 180)
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms))
}
async function waitForAnySelector(page: any, selectors: string[], timeoutMs = 10000) {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
for (const sel of selectors) {
try { if (await page.$(sel)) return sel } catch {}
}
await sleep(300)
}
return null as string | null
}
async function setInputValue(page: any, selector: string, value: string) {
const exists = await page.$(selector)
if (!exists) return false
try {
await page.evaluate((sel, val) => {
const el = document.querySelector(sel) as HTMLInputElement | null
if (!el) return
el.focus()
el.value = val
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
el.blur()
}, selector, value)
return true
} catch {
try {
await page.click(selector, { clickCount: 3 })
await page.type(selector, value, { delay: 20 })
return true
} catch {
return false
}
}
}
async function clickByText(page: any, text: string) {
const handle = await page.evaluateHandle((t: string) => {
const target = t.toLowerCase()
const candidates = Array.from(document.querySelectorAll('button, a, input[type="submit"], span, div')) as HTMLElement[]
for (const el of candidates) {
const txt = (el.innerText || el.textContent || '').trim().toLowerCase()
if (!txt) continue
if (txt.includes(target)) return el
}
return null
}, text)
try {
const el = (handle as any).asElement?.()
if (el) {
await el.click()
return true
}
} catch {}
try { await (handle as any).dispose?.() } catch {}
return false
}
async function findLargestElementHandle(page: any, selectors: string[]) {
for (const sel of selectors) {
const handles = await page.$$(sel)
if (handles.length) {
let best = handles[0]
let bestArea = 0
for (const h of handles) {
const box = await h.boundingBox()
const area = box ? box.width * box.height : 0
if (area > bestArea) {
best = h
bestArea = area
}
}
return best
}
}
return null
}
async function persistHistorySafely(
data: { article: string; statsUrl: string | null; imageUrl?: string; ok: boolean; selector?: string | null; logs?: any },
log: (m: string) => void
) {
try {
if ((prisma as any).zzapRequest?.create) {
await (prisma as any).zzapRequest.create({
data: {
provider: 'zzap',
article: data.article,
statsUrl: data.statsUrl || undefined,
imageUrl: data.imageUrl || undefined,
ok: data.ok,
selector: data.selector || undefined,
logs: data.logs ?? undefined
}
})
log('DB: request persisted')
} else {
const esc = (v: any) => (v == null ? 'NULL' : `'${String(v).replace(/'/g, "''")}'`)
const logsJson = data.logs ? `'${JSON.stringify(data.logs).replace(/'/g, "''")}'::jsonb` : 'NULL'
const sql = `INSERT INTO "zzap_requests" ("provider","article","statsUrl","imageUrl","ok","selector","logs") VALUES ('zzap', ${esc(data.article)}, ${esc(data.statsUrl)}, ${esc(data.imageUrl)}, ${data.ok ? 'true' : 'false'}, ${esc(data.selector)}, ${logsJson})`
await prisma.$executeRawUnsafe(sql)
log('DB: request persisted (raw)')
}
} catch (e: any) {
log(`DB error: ${e?.message || e}`)
}
}
async function restoreSession(page: any, log: (m: string) => void) {
try {
if (!fs.existsSync(COOKIE_FILE)) return false
const raw = fs.readFileSync(COOKIE_FILE, 'utf-8')
const data = JSON.parse(raw) as { cookies: any[]; savedAt: number }
if (!data?.cookies?.length || !data?.savedAt) return false
const ageMin = (Date.now() - data.savedAt) / 60000
if (ageMin > COOKIE_TTL_MIN) { log(`Session expired: ${ageMin.toFixed(1)}min > ${COOKIE_TTL_MIN}min`); return false }
await page.setCookie(...data.cookies)
log('Session cookies restored')
return true
} catch (e) {
log(`Restore session error: ${String((e as any)?.message || e)}`)
return false
}
}
async function saveSession(page: any, log: (m: string) => void) {
try {
const cookies = await page.cookies()
const payload = { cookies, savedAt: Date.now() }
fs.writeFileSync(COOKIE_FILE, JSON.stringify(payload))
log(`Session cookies saved (${cookies.length})`)
} catch (e) {
log(`Save session error: ${String((e as any)?.message || e)}`)
}
}
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url)
const article = searchParams.get('article')?.trim()
const explicitSelector = searchParams.get('selector')?.trim()
const debug = searchParams.get('debug') === '1'
if (!article) {
return new Response(JSON.stringify({ error: 'Не передан артикул ?article=' }), { status: 400, headers: { 'content-type': 'application/json; charset=utf-8' } })
}
const email = process.env.ZZAP_EMAIL
const password = process.env.ZZAP_PASSWORD
if (!email || !password) {
return new Response(JSON.stringify({ error: 'Отсутствуют ZZAP_EMAIL/ZZAP_PASSWORD в .env' }), { status: 500, headers: { 'content-type': 'application/json; charset=utf-8' } })
}
const logs: string[] = []
const log = (m: string) => { logs.push(m) }
try {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
})
const page = await browser.newPage()
let workPage: any = page
await page.setViewport({ width: 1440, height: 900 })
await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36')
await page.setExtraHTTPHeaders({ 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7' })
try { page.setDefaultNavigationTimeout?.(ZZAP_TIMEOUT_MS); page.setDefaultTimeout?.(ZZAP_TIMEOUT_MS) } catch {}
// 0) Try restore session first, verify, otherwise login
let loggedInEarly = false
await restoreSession(page, log)
try {
await page.goto(ZZAP_BASE, { waitUntil: 'domcontentloaded', timeout: 60000 })
const byDom = await page.evaluate(() => {
const byId = !!document.querySelector('#ctl00_lnkLogout')
const byText = Array.from(document.querySelectorAll('a')).some(a => /выход|выйти|logout|logoff/i.test((a.textContent||'').trim()))
return byId || byText
}).catch(() => false)
loggedInEarly = Boolean(byDom)
log(`Restored session check: loggedIn=${loggedInEarly}`)
} catch {}
// 0b) Explicit login if not logged yet
try {
if (!loggedInEarly) {
await page.goto(`${ZZAP_BASE}/user/logon.aspx`, { waitUntil: 'domcontentloaded', timeout: Math.min(20000, ZZAP_TIMEOUT_MS) })
log(`Open login: ${page.url()}`)
} else {
log('Skip login: already authenticated')
}
// DevExpress (ZZap) stable selectors by id suffix / full name
const devxEmail = 'input[id$="AddrEmail1TextBox_I"]'
const devxPass = 'input[id$="PasswordTextBox_I"]'
const devxEmailName = 'input[name="ctl00$BodyPlace$LogonFormCallbackPanel$LogonFormLayout$AddrEmail1TextBox"]'
const devxPassName = 'input[name="ctl00$BodyPlace$LogonFormCallbackPanel$LogonFormLayout$PasswordTextBox"]'
const devxEmailFull = '#ctl00_BodyPlace_LogonFormCallbackPanel_LogonFormLayout_AddrEmail1TextBox_I'
const devxPassFull = '#ctl00_BodyPlace_LogonFormCallbackPanel_LogonFormLayout_PasswordTextBox_I'
// Prefer known DevExpress IDs first, then legacy ASP.NET, then generic
const userSelPriority = [devxEmailFull, devxEmail, devxEmailName, '#ctl00_ContentPlaceHolder1_Login1_UserName', '#ctl00_ContentPlaceHolder1_tbLogin', 'input[name*="login" i]', 'input[type="email"]', 'input[name*="email" i]']
const passSelPriority = [devxPassFull, devxPass, devxPassName, '#ctl00_ContentPlaceHolder1_Login1_Password', '#ctl00_ContentPlaceHolder1_tbPass', 'input[type="password"]', 'input[name*="pass" i]']
const submitSelPriority = ['#ctl00_ContentPlaceHolder1_Login1_LoginButton', '#ctl00_ContentPlaceHolder1_btnLogin', 'button[type="submit" i]', 'input[type="submit" i]']
// Wait a moment for anti-bot/DevExpress to initialize
if (!loggedInEarly) await sleep(2000)
// Wait for any input to appear explicitly
const appeared = await waitForAnySelector(page, [devxEmailFull, devxPassFull, devxEmail, devxPass, devxEmailName, devxPassName], 10000)
if (!appeared) log('Login inputs still not present after wait')
// Try waiting explicitly for DevExpress fields
// Resolve selectors to strings (avoid handle click issues)
const emailSelectors = [devxEmailFull, devxEmail, devxEmailName, '#ctl00_ContentPlaceHolder1_Login1_UserName', '#ctl00_ContentPlaceHolder1_tbLogin', 'input[name*="login" i]', 'input[type="email"]', 'input[name*="email" i]']
const passSelectors = [devxPassFull, devxPass, devxPassName, '#ctl00_ContentPlaceHolder1_Login1_Password', '#ctl00_ContentPlaceHolder1_tbPass', 'input[type="password"]', 'input[name*="pass" i]']
let emailSelUsed: string | null = null
for (const sel of emailSelectors) { if (await setInputValue(page, sel, email)) { emailSelUsed = sel; break } }
// No XPath fallback to avoid $x in older runtimes
let passSelUsed: string | null = null
for (const sel of passSelectors) { if (await setInputValue(page, sel, password)) { passSelUsed = sel; break } }
// No XPath fallback to avoid $x in older runtimes
if (!loggedInEarly && emailSelUsed && passSelUsed) {
log(`Login using emailSel=${emailSelUsed}, passSel=${passSelUsed}`)
// Helper to detect login without relying only on nav
const checkLoggedIn = async () => {
const url = page.url()
if (!/logon\.aspx/i.test(url)) return true
const byDom = await page.evaluate(() => {
const byId = !!document.querySelector('#ctl00_lnkLogout')
const byText = Array.from(document.querySelectorAll('a')).some(a => /выход|выйти|logout|logoff/i.test((a.textContent||'').trim()))
return byId || byText
}).catch(() => false)
return byDom
}
const waitStep = async (label: string) => {
await sleep(1500)
const ok = await checkLoggedIn()
log(`${label} -> loggedIn=${ok}, url=${page.url()}`)
return ok
}
let loggedIn = false
// 1) Press Enter on password
try { await page.focus(passSelUsed); await page.keyboard.press('Enter') ; } catch {}
loggedIn = await waitStep('After Enter')
// 2) Click submit via known selectors
if (!loggedIn) {
for (const sel of submitSelPriority) {
try {
const el = await page.$(sel)
if (el) {
await el.click().catch(() => {})
if (await waitStep(`After click ${sel}`)) { loggedIn = true; break }
}
} catch {}
}
}
// 3) Click any descendant with text "Войти" inside login panel
if (!loggedIn) {
try {
const did = await page.evaluate(() => {
const root = document.querySelector('#ctl00_BodyPlace_LogonFormCallbackPanel') || document.body
if (!root) return false
const nodes = Array.from(root.querySelectorAll('button, a, span, div, input[type="submit"]')) as HTMLElement[]
const lc = 'войти'
for (const el of nodes) {
const txt = (el.innerText || el.textContent || '').trim().toLowerCase()
if (!txt) continue
if (txt.includes(lc)) { (el as HTMLElement).click(); return true }
}
return false
})
if (did) loggedIn = await waitStep('After panel text click')
} catch {}
}
// 4) Try submitting the form directly
if (!loggedIn) {
try { await page.evaluate(() => { (document.querySelector('form') as HTMLFormElement | null)?.submit() }) } catch {}
loggedIn = await waitStep('After form.submit()')
}
log(`Login success=${loggedIn}`)
if (loggedIn) { await saveSession(page, log) }
} else {
if (!loggedInEarly) log('Login inputs not found; continuing')
}
const cookies = await page.cookies()
log(`Cookies: ${cookies.map(c=>c.name).join(',')}`)
} catch (e: any) {
log(`Login step error: ${e?.message || e}`)
}
// 1) Open homepage
await page.goto(ZZAP_BASE, { waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) })
log(`Open: ${page.url()}`)
// Cookie banners common accept
await clickByText(page, 'Соглас').catch(() => {})
await clickByText(page, 'Принять').catch(() => {})
await clickByText(page, 'Хорошо').catch(() => {})
// 2) Try to open login
const loginCandidates = ['a[href*="login" i]', 'a[href*="signin" i]', 'button[name="login" i]']
let openedLogin = false
for (const sel of loginCandidates) {
const el = await page.$(sel)
if (el) {
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {}),
el.click()
])
openedLogin = true
break
}
}
if (!openedLogin) {
await clickByText(page, 'войти').catch(() => {})
}
log(`Login page: ${page.url()}`)
// 3) Fill credentials
// Try DevExpress selectors first on whatever login UI is visible
const emailSel = ['#ctl00_BodyPlace_LogonFormCallbackPanel_LogonFormLayout_AddrEmail1TextBox_I', 'input[id$="AddrEmail1TextBox_I"]', 'input[name="ctl00$BodyPlace$LogonFormCallbackPanel$LogonFormLayout$AddrEmail1TextBox"]', 'input[type="email"]', 'input[name="email" i]', 'input[name*="login" i]']
const passSel = ['#ctl00_BodyPlace_LogonFormCallbackPanel_LogonFormLayout_PasswordTextBox_I', 'input[id$="PasswordTextBox_I"]', 'input[name="ctl00$BodyPlace$LogonFormCallbackPanel$LogonFormLayout$PasswordTextBox"]', 'input[type="password"]', 'input[name="password" i]']
let emailInput = null
for (const sel of emailSel) { emailInput = await page.$(sel); if (emailInput) break }
let passInput = null
for (const sel of passSel) { passInput = await page.$(sel); if (passInput) break }
if (emailInput && passInput) {
await emailInput.click({ clickCount: 3 }).catch(() => {})
await emailInput.type(email, { delay: 20 })
await passInput.type(password, { delay: 20 })
const submitSel = ['button[type="submit" i]', 'input[type="submit" i]']
let clicked = false
for (const sel of submitSel) {
const el = await page.$(sel)
if (el) {
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 60000 }).catch(() => {}),
el.click()
])
clicked = true
break
}
}
if (!clicked) {
await clickByText(page, 'войти').catch(() => {})
await sleep(1500)
}
}
log(`After login: ${page.url()}`)
// 4) Navigate to search by article (try a few patterns)
const searchUrls = [
`${ZZAP_BASE}/public/search.aspx#rawdata=${encodeURIComponent(article)}`,
`${ZZAP_BASE}/search/?article=${encodeURIComponent(article)}`,
`${ZZAP_BASE}/search?article=${encodeURIComponent(article)}`,
`${ZZAP_BASE}/search?txt=${encodeURIComponent(article)}`,
`${ZZAP_BASE}/search?query=${encodeURIComponent(article)}`,
`${ZZAP_BASE}/catalog/?q=${encodeURIComponent(article)}`
]
let reached = false
for (const url of searchUrls) {
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) })
log(`Search try: ${url} -> ${page.url()}`)
reached = true
break
} catch {}
}
if (!reached) {
// fallback: try search input on homepage
await page.goto(ZZAP_BASE, { waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) })
const inputCandidates = ['input[type="search"]', 'input[name*="search" i]', 'input[placeholder*="артик" i]']
let searchInput = null
for (const sel of inputCandidates) { searchInput = await page.$(sel); if (searchInput) break }
if (!searchInput) throw new Error('Не найдено поле поиска')
await searchInput.type(article, { delay: 30 })
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) }).catch(() => {}),
page.keyboard.press('Enter')
])
}
log(`Search results: ${page.url()}`)
// 4b) Try to open stats via explicit anchor present in the grid
try {
const statLinkSel = 'a[id$="_StatHyperlink"], a[onclick*="statpartpricehistory.aspx" i]'
const statLink = await page.waitForSelector(statLinkSel, { timeout: Math.min(8000, ZZAP_TIMEOUT_MS) }).catch(() => null)
if (statLink) {
const rel = await statLink.evaluate((el: HTMLAnchorElement) => {
const href = el.getAttribute('href') || ''
const onclick = el.getAttribute('onclick') || ''
const rx = /['"]([^'\"]*statpartpricehistory\.aspx[^'\"]*)['"]/i
const m = onclick.match(rx)
const candidate = m ? m[1] : (href && href.includes('statpartpricehistory') ? href : null)
return candidate ? candidate.replace(/&amp;/g, '&') : null
})
if (rel) {
const targetUrl = rel.startsWith('http') ? rel : `${ZZAP_BASE}${rel}`
const statsPage = await browser.newPage()
await statsPage.setViewport({ width: 1440, height: 900 })
await statsPage.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36')
await statsPage.setExtraHTTPHeaders({ 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7' })
const cookies = await page.cookies()
await statsPage.setCookie(...cookies)
await statsPage.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) })
workPage = statsPage
log(`Stats page opened via anchor onclick: ${targetUrl}`)
}
}
} catch {}
// If we already opened stats page directly, skip modal logic
const onStatsPage = () => /\/user\/statpartpricehistory\.aspx/i.test(workPage.url?.() || '')
let statsOpened = onStatsPage()
if (statsOpened) {
log(`Stats page already open: ${workPage.url?.()}`)
}
// 5) Open statistics (open modal, extract iframe src, load it as page) or new page
// Attempt 0: scrape any statpartpricehistory URL from page HTML (onclick/inline)
try {
const rel = await page.evaluate(() => {
const html = document.documentElement?.outerHTML || ''
const m = html.match(/(https?:\/\/[^"'<> ]+)?(\/user\/statpartpricehistory\.aspx[^"'<> ]*)/i)
if (!m) return null
const full = m[1] ? `${m[1]}${m[2]}` : m[2]
return full.replace(/&amp;/g, '&')
})
if (rel) {
const targetUrl = rel.startsWith('http') ? rel : `${ZZAP_BASE}${rel}`
const statsPage = await browser.newPage()
await statsPage.setViewport({ width: 1440, height: 900 })
await statsPage.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36')
await statsPage.setExtraHTTPHeaders({ 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7' })
const cookies = await page.cookies()
await statsPage.setCookie(...cookies)
await statsPage.goto(targetUrl, { waitUntil: 'networkidle0', timeout: 60000 })
workPage = statsPage
statsOpened = true
log(`Stats page opened via HTML scrape: ${targetUrl}`)
}
} catch {}
if (!statsOpened) {
await clickByText(page, 'статистика').catch(() => {})
try {
const clickedByTitle = await page.evaluate(() => {
const cand = document.querySelector('[title*="статист" i], [alt*="статист" i]') as HTMLElement | null
if (cand) { cand.click(); return true }
return false
})
if (clickedByTitle) { log('Clicked stats by title/alt attribute') }
} catch {}
await sleep(500)
}
// Look for iframe anywhere, not only inside DevExpress container
let modalIframeHandle = statsOpened ? null : await page.waitForSelector('iframe[src*="statpartpricehistory.aspx"]', { timeout: Math.min(4000, ZZAP_TIMEOUT_MS) }).catch(() => null)
if (!modalIframeHandle) {
// one more short wait and retry
if (!statsOpened) {
await sleep(300)
modalIframeHandle = await page.waitForSelector('iframe[src*="statpartpricehistory.aspx"]', { timeout: Math.min(2500, ZZAP_TIMEOUT_MS) }).catch(() => null)
}
}
if (modalIframeHandle) {
statsOpened = true
try {
const src: string | null = await modalIframeHandle.evaluate((el: HTMLIFrameElement) => el.getAttribute('src'))
if (src) {
const targetUrl = src.startsWith('http') ? src : `${ZZAP_BASE}${src}`
const statsPage = await browser.newPage()
await statsPage.setViewport({ width: 1440, height: 900 })
await statsPage.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36')
await statsPage.setExtraHTTPHeaders({ 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7' })
// Reuse session cookies
const cookies = await page.cookies()
await statsPage.setCookie(...cookies)
await statsPage.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) })
workPage = statsPage
log(`Stats page opened via iframe src: ${targetUrl}`)
// We won't use the iframe anymore
modalIframeHandle = null
}
} catch {}
}
if (!statsOpened || workPage === page) {
// Fallback: direct link on page to statpartpricehistory
try {
const rel = await page.evaluate(() => {
const a = document.querySelector('a[href*="statpartpricehistory.aspx"]') as HTMLAnchorElement | null
return a?.getAttribute('href') || null
})
if (rel) {
const targetUrl = rel.startsWith('http') ? rel : `${ZZAP_BASE}${rel}`
const statsPage = await browser.newPage()
await statsPage.setViewport({ width: 1440, height: 900 })
await statsPage.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36')
await statsPage.setExtraHTTPHeaders({ 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7' })
const cookies = await page.cookies()
await statsPage.setCookie(...cookies)
await statsPage.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) })
workPage = statsPage
statsOpened = true
log(`Stats page opened via anchor href: ${targetUrl}`)
}
} catch {}
}
if (!statsOpened || workPage === page) {
// Fallback: explicit selectors that might open new page
const statsSelectors = ['a[href*="statpartpricehistory" i]', 'a[href*="stat" i]', 'button[href*="stat" i]']
for (const sel of statsSelectors) {
const el = await page.$(sel)
if (el) {
const targetCreated = new Promise<any>((resolve) => {
const handler = async (target: any) => {
const newPage = await target.page().catch(() => null)
if (newPage) {
browser.off('targetcreated', handler)
resolve(newPage)
}
}
browser.on('targetcreated', handler)
})
await el.click().catch(() => {})
const maybeNewPage: any = await Promise.race([
targetCreated,
(async () => { await page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: Math.min(8000, ZZAP_TIMEOUT_MS) }).catch(() => {}) ; return null })()
])
if (maybeNewPage) {
workPage = maybeNewPage
await sleep(1200)
}
statsOpened = true
break
}
}
}
log(`Stats page: ${workPage.url?.() || page.url()}`)
// Early bailout: if we couldn't navigate away from search page quickly, return its screenshot
try {
const urlNow = workPage.url?.() || page.url()
if (/\/public\/search\.aspx/i.test(urlNow)) {
log('Bailout: still on search page, returning current page screenshot')
const buf = (await page.screenshot({ fullPage: true, type: 'png' })) as Buffer
// Save to S3 + DB history
try {
const key = `zzap/${encodeURIComponent(article)}/${Date.now()}-search.png`
const up = await uploadBuffer(buf, key, 'image/png')
logs.push(`Uploaded to S3: ${up.url}`)
await persistHistorySafely({ article, statsUrl: urlNow, imageUrl: up.url, ok: false, selector: null, logs }, log)
} catch (e: any) {
logs.push(`Persist error (bailout): ${e?.message || e}`)
}
await browser.close().catch(() => {})
if (debug) {
return new Response(JSON.stringify({ ok: true, url: urlNow, foundSelector: null, logs }), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8' }
})
}
return new Response(buf, { status: 200, headers: { 'content-type': 'image/png', 'cache-control': 'no-store' } })
}
} catch {}
// 6) Wait a bit for charts to render (short)
if (onStatsPage()) {
try { await (workPage.waitForSelector?.('.highcharts-container', { timeout: Math.min(3000, ZZAP_TIMEOUT_MS) })) } catch {}
await sleep(300)
}
// 7) Capture element screenshot
let handle = null as any
// No modal any more we open stats full page; try in current/new page context
if (explicitSelector) {
handle = await workPage.$(explicitSelector)
}
if (!handle) {
handle = await findLargestElementHandle(workPage, [
'.highcharts-container',
'canvas',
'svg',
'[id*="chart" i]',
'[class*="chart" i]'
])
}
let imageBuffer: Buffer
let foundSelector: string | null = null
if (!onStatsPage() && handle) {
try { await handle.evaluate((el: any) => el.scrollIntoView({ behavior: 'instant', block: 'center' })) } catch {}
await sleep(300)
foundSelector = explicitSelector || 'auto'
imageBuffer = (await handle.screenshot({ type: 'png' })) as Buffer
} else {
// Full-page screenshot (stats page often has 3 charts; capture all)
imageBuffer = (await workPage.screenshot({ fullPage: true, type: 'png' })) as Buffer
}
// Upload to S3 and persist history
let uploadedUrl: string | undefined
try {
const key = `zzap/${encodeURIComponent(article)}/${Date.now()}.png`
const up = await uploadBuffer(imageBuffer, key, 'image/png')
uploadedUrl = up.url
logs.push(`Uploaded to S3: ${up.url}`)
} catch (e: any) {
logs.push(`S3 upload error: ${e?.message || e}`)
}
await persistHistorySafely({ article, statsUrl: workPage.url?.() || page.url(), imageUrl: uploadedUrl, ok: true, selector: foundSelector, logs }, log)
await browser.close().catch(() => {})
if (debug) {
return new Response(JSON.stringify({ ok: true, url: workPage.url?.() || page.url(), foundSelector, imageUrl: uploadedUrl, logs }), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8' }
})
}
return new Response(imageBuffer, {
status: 200,
headers: {
'content-type': 'image/png',
'cache-control': 'no-store'
}
})
} catch (err: any) {
const errorBody = { error: String(err?.message || err || 'Unknown error'), logs }
return new Response(JSON.stringify(errorBody), {
status: 500,
headers: { 'content-type': 'application/json; charset=utf-8' }
})
}
}

View File

@ -0,0 +1,125 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useChat } from 'ai/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Bot, Send, User } from 'lucide-react';
export default function AIChat() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
api: '/api/ai/chat',
});
const scrollAreaRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight;
}
}, [messages]);
return (
<div className="container max-w-4xl mx-auto py-6">
<Card className="h-[calc(100vh-8rem)] flex flex-col">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bot className="h-5 w-5" />
Чат с ИИ
</CardTitle>
<p className="text-sm text-muted-foreground">
Задайте любой вопрос искусственному интеллекту
</p>
</CardHeader>
<CardContent className="flex-1 min-h-0">
<ScrollArea className="h-full pr-4" ref={scrollAreaRef}>
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-center">
<Bot className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Добро пожаловать!</h3>
<p className="text-muted-foreground">
Начните разговор с ИИ, задав свой первый вопрос
</p>
</div>
)}
<div className="space-y-4">
{messages.map((message, index) => (
<div
key={index}
className={`flex gap-3 ${
message.role === 'user' ? 'justify-end' : 'justify-start'
}`}
>
{message.role === 'assistant' && (
<Avatar className="h-8 w-8">
<AvatarFallback>
<Bot className="h-4 w-4" />
</AvatarFallback>
</Avatar>
)}
<div
className={`rounded-lg px-4 py-2 max-w-[80%] ${
message.role === 'user'
? 'bg-primary text-primary-foreground ml-auto'
: 'bg-muted text-foreground'
}`}
>
<div className="whitespace-pre-wrap text-sm">
{message.content}
</div>
</div>
{message.role === 'user' && (
<Avatar className="h-8 w-8">
<AvatarFallback>
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
)}
</div>
))}
{isLoading && (
<div className="flex gap-3 justify-start">
<Avatar className="h-8 w-8">
<AvatarFallback>
<Bot className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<div className="bg-muted rounded-lg px-4 py-2">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-pulse" />
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-pulse delay-100" />
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-pulse delay-200" />
</div>
</div>
</div>
)}
</div>
</ScrollArea>
</CardContent>
<CardFooter>
<form onSubmit={handleSubmit} className="flex gap-2 w-full">
<Input
value={input}
onChange={handleInputChange}
placeholder="Введите ваш вопрос..."
disabled={isLoading}
className="flex-1"
/>
<Button type="submit" disabled={isLoading || !input.trim()} size="icon">
<Send className="h-4 w-4" />
</Button>
</form>
</CardFooter>
</Card>
</div>
);
}

View File

@ -210,6 +210,7 @@ export default function CatalogPage() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onProductEdit={(product: any) => setEditingProduct(product)}
onProductCreated={handleProductCreated}
categories={categories}
/>
{/* Пагинация и информация */}

View File

@ -0,0 +1,453 @@
"use client"
import { useState } from 'react'
import { useQuery, useMutation } from '@apollo/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter
} from '@/components/ui/dialog'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { FileUpload } from '@/components/ui/file-upload'
import { Plus, Edit, Trash2, Image, ExternalLink } from 'lucide-react'
import { GET_HERO_BANNERS } from '@/lib/graphql/queries'
import { CREATE_HERO_BANNER, UPDATE_HERO_BANNER, DELETE_HERO_BANNER } from '@/lib/graphql/mutations'
import { toast } from 'sonner'
interface HeroBanner {
id: string
title: string
subtitle?: string
imageUrl: string
linkUrl?: string
isActive: boolean
sortOrder: number
createdAt: string
updatedAt: string
}
interface BannerFormData {
title: string
subtitle: string
imageUrl: string
linkUrl: string
isActive: boolean
sortOrder: number
}
const defaultFormData: BannerFormData = {
title: '',
subtitle: '',
imageUrl: '',
linkUrl: '',
isActive: true,
sortOrder: 0
}
export default function HeroBannersPage() {
const [showDialog, setShowDialog] = useState(false)
const [editingBanner, setEditingBanner] = useState<HeroBanner | null>(null)
const [formData, setFormData] = useState<BannerFormData>(defaultFormData)
const [uploading, setUploading] = useState(false)
const { data, loading, error, refetch } = useQuery(GET_HERO_BANNERS, {
fetchPolicy: 'cache-and-network'
})
const [createBanner] = useMutation(CREATE_HERO_BANNER, {
onCompleted: () => {
toast.success('Баннер успешно создан')
setShowDialog(false)
setFormData(defaultFormData)
refetch()
},
onError: (error) => {
toast.error(error.message || 'Ошибка создания баннера')
}
})
const [updateBanner] = useMutation(UPDATE_HERO_BANNER, {
onCompleted: () => {
toast.success('Баннер успешно обновлен')
setShowDialog(false)
setEditingBanner(null)
setFormData(defaultFormData)
refetch()
},
onError: (error) => {
toast.error(error.message || 'Ошибка обновления баннера')
}
})
const [deleteBanner] = useMutation(DELETE_HERO_BANNER, {
onCompleted: () => {
toast.success('Баннер успешно удален')
refetch()
},
onError: (error) => {
toast.error(error.message || 'Ошибка удаления баннера')
}
})
const banners: HeroBanner[] = data?.heroBanners || []
const handleOpenDialog = (banner?: HeroBanner) => {
if (banner) {
setEditingBanner(banner)
setFormData({
title: banner.title,
subtitle: banner.subtitle || '',
imageUrl: banner.imageUrl,
linkUrl: banner.linkUrl || '',
isActive: banner.isActive,
sortOrder: banner.sortOrder
})
} else {
setEditingBanner(null)
setFormData(defaultFormData)
}
setShowDialog(true)
}
const handleCloseDialog = () => {
setShowDialog(false)
setEditingBanner(null)
setFormData(defaultFormData)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!formData.title.trim()) {
toast.error('Заголовок обязателен')
return
}
if (!formData.imageUrl.trim()) {
toast.error('Изображение обязательно')
return
}
if (editingBanner) {
updateBanner({
variables: {
id: editingBanner.id,
input: formData
}
})
} else {
createBanner({
variables: {
input: formData
}
})
}
}
const handleDelete = (banner: HeroBanner) => {
if (confirm(`Вы уверены, что хотите удалить баннер "${banner.title}"?`)) {
deleteBanner({
variables: { id: banner.id }
})
}
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<div className="text-red-600 text-center">
<div className="text-lg font-semibold mb-2">Ошибка загрузки данных</div>
<div className="text-sm mb-4">{error.message}</div>
<Button onClick={() => refetch()}>Повторить</Button>
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Баннеры героя</h1>
<p className="text-gray-600">
Управление баннерами на главной странице
</p>
</div>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogTrigger asChild>
<Button onClick={() => handleOpenDialog()}>
<Plus className="w-4 h-4 mr-2" />
Добавить баннер
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{editingBanner ? 'Редактировать баннер' : 'Создать баннер'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="title">Заголовок *</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
placeholder="Введите заголовок баннера"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="sortOrder">Порядок сортировки</Label>
<Input
id="sortOrder"
type="number"
value={formData.sortOrder}
onChange={(e) => setFormData(prev => ({ ...prev, sortOrder: parseInt(e.target.value) || 0 }))}
placeholder="0"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="subtitle">Подзаголовок</Label>
<Textarea
id="subtitle"
value={formData.subtitle}
onChange={(e) => setFormData(prev => ({ ...prev, subtitle: e.target.value }))}
placeholder="Введите подзаголовок баннера (необязательно)"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="linkUrl">Ссылка</Label>
<Input
id="linkUrl"
type="url"
value={formData.linkUrl}
onChange={(e) => setFormData(prev => ({ ...prev, linkUrl: e.target.value }))}
placeholder="https://example.com (необязательно)"
/>
</div>
<div className="space-y-2">
<Label>Изображение *</Label>
<div className="space-y-2">
{formData.imageUrl && (
<div className="relative">
<img
src={formData.imageUrl}
alt="Превью"
className="w-full h-32 object-cover rounded-lg border"
/>
</div>
)}
<FileUpload
onUpload={(url) => setFormData(prev => ({ ...prev, imageUrl: url }))}
accept="image/*"
maxSize={5 * 1024 * 1024}
disabled={uploading}
/>
{uploading && (
<div className="text-sm text-gray-500">Загрузка изображения...</div>
)}
</div>
</div>
<div className="flex items-center space-x-2">
<Switch
id="isActive"
checked={formData.isActive}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isActive: checked }))}
/>
<Label htmlFor="isActive">Активен</Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleCloseDialog}>
Отмена
</Button>
<Button type="submit" disabled={uploading}>
{editingBanner ? 'Обновить' : 'Создать'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
{/* Статистика */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Всего баннеров</CardTitle>
<Image className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{banners.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Активные</CardTitle>
<Image className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{banners.filter(b => b.isActive).length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Неактивные</CardTitle>
<Image className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-600">
{banners.filter(b => !b.isActive).length}
</div>
</CardContent>
</Card>
</div>
{/* Таблица баннеров */}
<Card>
<CardHeader>
<CardTitle>Список баннеров ({banners.length})</CardTitle>
<CardDescription>
Управление баннерами на главной странице сайта
</CardDescription>
</CardHeader>
<CardContent>
{banners.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Image className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<p>Нет созданных баннеров</p>
<Button
className="mt-4"
onClick={() => handleOpenDialog()}
>
Создать первый баннер
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Изображение</TableHead>
<TableHead>Заголовок</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Порядок</TableHead>
<TableHead>Ссылка</TableHead>
<TableHead>Дата создания</TableHead>
<TableHead>Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...banners]
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((banner) => (
<TableRow key={banner.id}>
<TableCell>
<img
src={banner.imageUrl}
alt={banner.title}
className="w-16 h-10 object-cover rounded border"
/>
</TableCell>
<TableCell>
<div>
<div className="font-medium">{banner.title}</div>
{banner.subtitle && (
<div className="text-sm text-gray-500 truncate max-w-xs">
{banner.subtitle}
</div>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={banner.isActive ? 'default' : 'secondary'}>
{banner.isActive ? 'Активен' : 'Неактивен'}
</Badge>
</TableCell>
<TableCell>{banner.sortOrder}</TableCell>
<TableCell>
{banner.linkUrl ? (
<div className="flex items-center">
<ExternalLink className="w-4 h-4 mr-1 text-gray-400" />
<span className="text-sm text-blue-600 truncate max-w-xs">
{banner.linkUrl}
</span>
</div>
) : (
<span className="text-gray-400"></span>
)}
</TableCell>
<TableCell>
{new Date(banner.createdAt).toLocaleDateString('ru-RU')}
</TableCell>
<TableCell>
<div className="flex space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleOpenDialog(banner)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(banner)}
className="text-red-600 hover:text-red-800"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,1075 @@
"use client"
import { useState, useEffect } from 'react'
import { useQuery, useMutation } from '@apollo/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import {
Calendar,
Plus,
Search,
Edit,
Trash2,
Package,
Star,
ChevronUp,
ChevronDown
} from 'lucide-react'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
import {
GET_DAILY_PRODUCTS,
GET_BEST_PRICE_PRODUCTS,
GET_TOP_SALES_PRODUCTS,
GET_PRODUCTS
} from '@/lib/graphql/queries'
import {
CREATE_DAILY_PRODUCT,
UPDATE_DAILY_PRODUCT,
DELETE_DAILY_PRODUCT,
CREATE_BEST_PRICE_PRODUCT,
UPDATE_BEST_PRICE_PRODUCT,
DELETE_BEST_PRICE_PRODUCT,
CREATE_TOP_SALES_PRODUCT,
UPDATE_TOP_SALES_PRODUCT,
DELETE_TOP_SALES_PRODUCT
} from '@/lib/graphql/mutations'
import toast from 'react-hot-toast'
// Типы данных
interface DailyProduct {
id: string
productId: string
displayDate: string
discount?: number
isActive: boolean
sortOrder: number
product: {
id: string
name: string
article?: string
brand?: string
retailPrice?: number
images: { url: string; alt?: string }[]
}
}
interface BestPriceProduct {
id: string
productId: string
discount: number
isActive: boolean
sortOrder: number
product: {
id: string
name: string
article?: string
brand?: string
retailPrice?: number
images: { url: string; alt?: string }[]
}
}
interface TopSalesProduct {
id: string
productId: string
isActive: boolean
sortOrder: number
product: {
id: string
name: string
article?: string
brand?: string
retailPrice?: number
images: { url: string; alt?: string }[]
}
createdAt: string
updatedAt: string
}
interface Product {
id: string
name: string
article?: string
brand?: string
retailPrice?: number
images: { url: string; alt?: string }[]
}
export default function HomepageProductsPage() {
// Состояния для товаров дня
const [selectedDate, setSelectedDate] = useState<string>(format(new Date(), 'yyyy-MM-dd'))
const [showDailyProductSelector, setShowDailyProductSelector] = useState(false)
const [editingDailyProduct, setEditingDailyProduct] = useState<DailyProduct | null>(null)
const [dailyDiscount, setDailyDiscount] = useState<number>(0)
// Состояния для лучших цен
const [showBestPriceProductSelector, setShowBestPriceProductSelector] = useState(false)
const [editingBestPriceProduct, setEditingBestPriceProduct] = useState<BestPriceProduct | null>(null)
const [bestPriceDiscount, setBestPriceDiscount] = useState<number>(0)
// Состояния для топ продаж
const [showTopSalesProductSelector, setShowTopSalesProductSelector] = useState(false)
const [editingTopSalesProduct, setEditingTopSalesProduct] = useState<TopSalesProduct | null>(null)
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
// Общие состояния
const [searchQuery, setSearchQuery] = useState('')
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState('daily')
// Debounce для поиска
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchQuery(searchQuery)
}, 500)
return () => clearTimeout(timer)
}, [searchQuery])
// Запросы данных
const { data: dailyProductsData, loading: dailyProductsLoading, refetch: refetchDailyProducts } = useQuery(GET_DAILY_PRODUCTS, {
variables: { displayDate: selectedDate }
})
const { data: bestPriceProductsData, loading: bestPriceProductsLoading, refetch: refetchBestPriceProducts } = useQuery(GET_BEST_PRICE_PRODUCTS)
const { data: topSalesProductsData, loading: topSalesProductsLoading, refetch: refetchTopSalesProducts } = useQuery(GET_TOP_SALES_PRODUCTS)
const { data: productsData, loading: productsLoading } = useQuery(GET_PRODUCTS, {
variables: {
search: debouncedSearchQuery || undefined,
limit: 100
},
skip: !showDailyProductSelector && !showBestPriceProductSelector && !showTopSalesProductSelector
})
// Мутации для товаров дня
const [createDailyProduct, { loading: creatingDaily }] = useMutation(CREATE_DAILY_PRODUCT)
const [updateDailyProduct, { loading: updatingDaily }] = useMutation(UPDATE_DAILY_PRODUCT)
const [deleteDailyProduct, { loading: deletingDaily }] = useMutation(DELETE_DAILY_PRODUCT)
// Мутации для лучших цен
const [createBestPriceProduct, { loading: creatingBestPrice }] = useMutation(CREATE_BEST_PRICE_PRODUCT)
const [updateBestPriceProduct, { loading: updatingBestPrice }] = useMutation(UPDATE_BEST_PRICE_PRODUCT)
const [deleteBestPriceProduct, { loading: deletingBestPrice }] = useMutation(DELETE_BEST_PRICE_PRODUCT)
// Мутации для топ продаж
const [createTopSalesProduct] = useMutation(CREATE_TOP_SALES_PRODUCT)
const [updateTopSalesProduct] = useMutation(UPDATE_TOP_SALES_PRODUCT)
const [deleteTopSalesProduct] = useMutation(DELETE_TOP_SALES_PRODUCT)
// Данные
const dailyProducts: DailyProduct[] = dailyProductsData?.dailyProducts || []
const bestPriceProducts: BestPriceProduct[] = bestPriceProductsData?.bestPriceProducts || []
const topSalesProducts: TopSalesProduct[] = topSalesProductsData?.topSalesProducts || []
const products: Product[] = productsData?.products || []
// Обработчики для товаров дня
const handleAddDailyProduct = async (productId: string) => {
try {
await createDailyProduct({
variables: {
input: {
productId,
displayDate: selectedDate,
discount: dailyDiscount || null,
isActive: true,
sortOrder: dailyProducts.length
}
}
})
toast.success('Товар добавлен!')
setShowDailyProductSelector(false)
setDailyDiscount(0)
refetchDailyProducts()
} catch (error) {
console.error('Ошибка добавления товара:', error)
toast.error('Не удалось добавить товар')
}
}
const handleEditDailyProduct = (dailyProduct: DailyProduct) => {
setEditingDailyProduct(dailyProduct)
setDailyDiscount(dailyProduct.discount || 0)
}
const handleUpdateDailyProduct = async () => {
if (!editingDailyProduct) return
try {
await updateDailyProduct({
variables: {
id: editingDailyProduct.id,
input: {
discount: dailyDiscount || null,
isActive: editingDailyProduct.isActive
}
}
})
toast.success('Товар обновлен!')
setEditingDailyProduct(null)
setDailyDiscount(0)
refetchDailyProducts()
} catch (error) {
console.error('Ошибка обновления товара:', error)
toast.error('Не удалось обновить товар')
}
}
const handleDeleteDailyProduct = async (id: string) => {
if (!confirm('Удалить товар из списка товаров дня?')) return
try {
await deleteDailyProduct({
variables: { id }
})
toast.success('Товар удален!')
refetchDailyProducts()
} catch (error) {
console.error('Ошибка удаления товара:', error)
toast.error('Не удалось удалить товар')
}
}
// Обработчики для лучших цен
const handleAddBestPriceProduct = async (productId: string) => {
try {
await createBestPriceProduct({
variables: {
input: {
productId,
discount: bestPriceDiscount || 0,
isActive: true,
sortOrder: bestPriceProducts.length
}
}
})
toast.success('Товар добавлен в лучшие цены!')
setShowBestPriceProductSelector(false)
setBestPriceDiscount(0)
refetchBestPriceProducts()
} catch (error) {
console.error('Ошибка добавления товара:', error)
toast.error('Не удалось добавить товар')
}
}
const handleEditBestPriceProduct = (bestPriceProduct: BestPriceProduct) => {
setEditingBestPriceProduct(bestPriceProduct)
setBestPriceDiscount(bestPriceProduct.discount || 0)
}
const handleUpdateBestPriceProduct = async () => {
if (!editingBestPriceProduct) return
try {
await updateBestPriceProduct({
variables: {
id: editingBestPriceProduct.id,
input: {
discount: bestPriceDiscount || 0,
isActive: editingBestPriceProduct.isActive
}
}
})
toast.success('Товар обновлен!')
setEditingBestPriceProduct(null)
setBestPriceDiscount(0)
refetchBestPriceProducts()
} catch (error) {
console.error('Ошибка обновления товара:', error)
toast.error('Не удалось обновить товар')
}
}
const handleDeleteBestPriceProduct = async (id: string) => {
if (!confirm('Удалить товар из списка товаров с лучшей ценой?')) return
try {
await deleteBestPriceProduct({
variables: { id }
})
toast.success('Товар удален!')
refetchBestPriceProducts()
} catch (error) {
console.error('Ошибка удаления товара:', error)
toast.error('Не удалось удалить товар')
}
}
// Обработчики для топ продаж
const handleAddTopSalesProduct = () => {
if (!selectedProduct) {
toast.error('Выберите товар')
return
}
createTopSalesProduct({
variables: {
input: {
productId: selectedProduct.id,
isActive: true,
sortOrder: 0
}
},
onCompleted: () => {
toast.success('Товар добавлен в топ продаж')
refetchTopSalesProducts()
setShowTopSalesProductSelector(false)
setSelectedProduct(null)
},
onError: (error) => {
toast.error(`Ошибка: ${error.message}`)
}
})
}
const handleDeleteTopSalesProduct = (id: string) => {
if (confirm('Вы уверены, что хотите удалить этот товар из топ продаж?')) {
deleteTopSalesProduct({
variables: { id },
onCompleted: () => {
toast.success('Товар удален из топ продаж')
refetchTopSalesProducts()
},
onError: (error) => {
toast.error(`Ошибка: ${error.message}`)
}
})
}
}
const handleToggleTopSalesActive = (item: TopSalesProduct) => {
updateTopSalesProduct({
variables: {
id: item.id,
input: {
isActive: !item.isActive,
sortOrder: item.sortOrder
}
},
onCompleted: () => {
refetchTopSalesProducts()
}
})
}
const handleTopSalesSortOrderChange = (item: TopSalesProduct, direction: 'up' | 'down') => {
const newSortOrder = direction === 'up' ? item.sortOrder - 1 : item.sortOrder + 1
updateTopSalesProduct({
variables: {
id: item.id,
input: {
isActive: item.isActive,
sortOrder: Math.max(0, newSortOrder)
}
},
onCompleted: () => {
refetchTopSalesProducts()
}
})
}
// Утилиты
const formatPrice = (price?: number) => {
if (!price) return '—'
return `${price.toLocaleString('ru-RU')}`
}
const calculateDiscountedPrice = (price?: number, discount?: number) => {
if (!price || !discount) return price
return price * (1 - discount / 100)
}
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Управление товарами главной страницы</h1>
<p className="text-gray-600">Управление товарами дня, лучшими ценами и топ продажами на главной странице сайта</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="daily" className="flex items-center">
<Calendar className="w-4 h-4 mr-2" />
Товары дня
</TabsTrigger>
<TabsTrigger value="best-price" className="flex items-center">
<Star className="w-4 h-4 mr-2" />
Лучшие цены
</TabsTrigger>
<TabsTrigger value="top-sales" className="flex items-center">
<Package className="w-4 h-4 mr-2" />
Топ продаж
</TabsTrigger>
</TabsList>
{/* Товары дня */}
<TabsContent value="daily" className="space-y-6">
{/* Выбор даты */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Calendar className="w-5 h-5 mr-2" />
Выбор даты показа
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center space-x-4">
<div>
<Label htmlFor="date">Дата показа товаров</Label>
<Input
id="date"
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="w-48"
/>
</div>
<div className="pt-6">
<p className="text-sm text-gray-500">
Выбранная дата: {format(new Date(selectedDate), 'dd MMMM yyyy', { locale: ru })}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Товары дня */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center">
<Package className="w-5 h-5 mr-2" />
Товары дня
</CardTitle>
<Button
onClick={() => setShowDailyProductSelector(true)}
className="flex items-center"
>
<Plus className="w-4 h-4 mr-2" />
Добавить товар
</Button>
</div>
</CardHeader>
<CardContent>
{dailyProductsLoading ? (
<div className="text-center py-8 text-gray-500">Загрузка товаров...</div>
) : dailyProducts.length === 0 ? (
<div className="text-center py-8 text-gray-500">
Товары дня не добавлены на выбранную дату
</div>
) : (
<div className="space-y-4">
{dailyProducts.map((dailyProduct) => (
<div key={dailyProduct.id} className="border rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center space-x-4">
{/* Изображение товара */}
<div className="w-16 h-16 bg-gray-100 rounded border flex items-center justify-center">
{dailyProduct.product.images?.[0]?.url ? (
<img
src={dailyProduct.product.images[0].url}
alt={dailyProduct.product.name}
className="w-full h-full object-cover rounded"
/>
) : (
<Package className="w-6 h-6 text-gray-400" />
)}
</div>
{/* Информация о товаре */}
<div className="flex-1">
<h3 className="font-medium text-gray-900">{dailyProduct.product.name}</h3>
<div className="text-sm text-gray-500 space-y-1">
{dailyProduct.product.article && (
<p>Артикул: {dailyProduct.product.article}</p>
)}
{dailyProduct.product.brand && (
<p>Бренд: {dailyProduct.product.brand}</p>
)}
<div className="flex items-center space-x-2">
<span>Цена: {formatPrice(dailyProduct.product.retailPrice)}</span>
{dailyProduct.discount && (
<span className="text-green-600 font-medium">
Со скидкой {dailyProduct.discount}%: {formatPrice(calculateDiscountedPrice(dailyProduct.product.retailPrice, dailyProduct.discount))}
</span>
)}
</div>
</div>
</div>
{/* Скидка */}
{dailyProduct.discount && (
<Badge variant="secondary" className="bg-green-100 text-green-800">
-{dailyProduct.discount}%
</Badge>
)}
</div>
{/* Действия */}
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditDailyProduct(dailyProduct)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteDailyProduct(dailyProduct.id)}
disabled={deletingDaily}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Лучшие цены */}
<TabsContent value="best-price" className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center">
<Star className="w-5 h-5 mr-2 text-yellow-500" />
Товары с лучшей ценой
</CardTitle>
<Button
onClick={() => setShowBestPriceProductSelector(true)}
className="flex items-center"
>
<Plus className="w-4 h-4 mr-2" />
Добавить товар
</Button>
</div>
</CardHeader>
<CardContent>
{bestPriceProductsLoading ? (
<div className="text-center py-8 text-gray-500">Загрузка товаров...</div>
) : bestPriceProducts.length === 0 ? (
<div className="text-center py-8 text-gray-500">
Товары с лучшей ценой не добавлены
</div>
) : (
<div className="space-y-4">
{bestPriceProducts.map((bestPriceProduct) => (
<div key={bestPriceProduct.id} className="border rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center space-x-4">
{/* Изображение товара */}
<div className="w-16 h-16 bg-gray-100 rounded border flex items-center justify-center">
{bestPriceProduct.product.images?.[0]?.url ? (
<img
src={bestPriceProduct.product.images[0].url}
alt={bestPriceProduct.product.name}
className="w-full h-full object-cover rounded"
/>
) : (
<Package className="w-6 h-6 text-gray-400" />
)}
</div>
{/* Информация о товаре */}
<div className="flex-1">
<h3 className="font-medium text-gray-900">{bestPriceProduct.product.name}</h3>
<div className="text-sm text-gray-500 space-y-1">
{bestPriceProduct.product.article && (
<p>Артикул: {bestPriceProduct.product.article}</p>
)}
{bestPriceProduct.product.brand && (
<p>Бренд: {bestPriceProduct.product.brand}</p>
)}
<div className="flex items-center space-x-2">
<span>Цена: {formatPrice(bestPriceProduct.product.retailPrice)}</span>
<span className="text-green-600 font-medium">
Со скидкой {bestPriceProduct.discount}%: {formatPrice(calculateDiscountedPrice(bestPriceProduct.product.retailPrice, bestPriceProduct.discount))}
</span>
</div>
</div>
</div>
{/* Скидка */}
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800">
-{bestPriceProduct.discount}%
</Badge>
</div>
{/* Действия */}
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditBestPriceProduct(bestPriceProduct)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteBestPriceProduct(bestPriceProduct.id)}
disabled={deletingBestPrice}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Топ продаж */}
<TabsContent value="top-sales" className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center">
<Package className="w-5 h-5 mr-2" />
Топ продаж
</CardTitle>
<Button
onClick={() => setShowTopSalesProductSelector(true)}
className="flex items-center"
>
<Plus className="w-4 h-4 mr-2" />
Добавить товар
</Button>
</div>
</CardHeader>
<CardContent>
{topSalesProductsLoading ? (
<div className="text-center py-8 text-gray-500">Загрузка товаров...</div>
) : topSalesProducts.length === 0 ? (
<div className="text-center py-8 text-gray-500">
Товары в топ продаж не добавлены
</div>
) : (
<div className="space-y-4">
{topSalesProducts.map((topSalesProduct) => (
<div key={topSalesProduct.id} className="border rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center space-x-4">
{/* Изображение товара */}
<div className="w-16 h-16 bg-gray-100 rounded border flex items-center justify-center">
{topSalesProduct.product.images?.[0]?.url ? (
<img
src={topSalesProduct.product.images[0].url}
alt={topSalesProduct.product.name}
className="w-full h-full object-cover rounded"
/>
) : (
<Package className="w-6 h-6 text-gray-400" />
)}
</div>
{/* Информация о товаре */}
<div className="flex-1">
<h3 className="font-medium text-gray-900">{topSalesProduct.product.name}</h3>
<div className="text-sm text-gray-500 space-y-1">
{topSalesProduct.product.article && (
<p>Артикул: {topSalesProduct.product.article}</p>
)}
{topSalesProduct.product.brand && (
<p>Бренд: {topSalesProduct.product.brand}</p>
)}
<p>Цена: {formatPrice(topSalesProduct.product.retailPrice)}</p>
</div>
</div>
{/* Статус */}
<div className="flex items-center space-x-2">
<Switch
checked={topSalesProduct.isActive}
onCheckedChange={() => handleToggleTopSalesActive(topSalesProduct)}
/>
<span className="text-sm text-gray-500">
{topSalesProduct.isActive ? 'Активен' : 'Неактивен'}
</span>
</div>
</div>
{/* Действия */}
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleTopSalesSortOrderChange(topSalesProduct, 'up')}
>
<ChevronUp className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleTopSalesSortOrderChange(topSalesProduct, 'down')}
>
<ChevronDown className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteTopSalesProduct(topSalesProduct.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Диалог добавления товара дня */}
<Dialog open={showDailyProductSelector} onOpenChange={setShowDailyProductSelector}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Добавить товар дня</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Поиск товаров */}
<div className="flex items-center space-x-2">
<Search className="w-4 h-4 text-gray-400" />
<Input
placeholder="Поиск товаров..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1"
/>
</div>
{/* Скидка */}
<div>
<Label htmlFor="daily-discount">Скидка (%)</Label>
<Input
id="daily-discount"
type="number"
min="0"
max="100"
value={dailyDiscount}
onChange={(e) => setDailyDiscount(Number(e.target.value))}
placeholder="Размер скидки"
/>
</div>
{/* Список товаров */}
<div className="max-h-96 overflow-y-auto space-y-2">
{productsLoading ? (
<div className="text-center py-4 text-gray-500">Загрузка товаров...</div>
) : products.length === 0 ? (
<div className="text-center py-4 text-gray-500">Товары не найдены</div>
) : (
products.map((product) => (
<div key={product.id} className="border rounded p-3 flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-12 h-12 bg-gray-100 rounded border flex items-center justify-center">
{product.images?.[0]?.url ? (
<img
src={product.images[0].url}
alt={product.name}
className="w-full h-full object-cover rounded"
/>
) : (
<Package className="w-4 h-4 text-gray-400" />
)}
</div>
<div>
<h4 className="font-medium">{product.name}</h4>
<div className="text-sm text-gray-500">
{product.article && <span>Артикул: {product.article} | </span>}
{product.brand && <span>Бренд: {product.brand} | </span>}
<span>Цена: {formatPrice(product.retailPrice)}</span>
</div>
</div>
</div>
<Button
onClick={() => handleAddDailyProduct(product.id)}
disabled={creatingDaily}
size="sm"
>
Добавить
</Button>
</div>
))
)}
</div>
</div>
</DialogContent>
</Dialog>
{/* Диалог добавления товара с лучшей ценой */}
<Dialog open={showBestPriceProductSelector} onOpenChange={setShowBestPriceProductSelector}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Добавить товар с лучшей ценой</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Поиск товаров */}
<div className="flex items-center space-x-2">
<Search className="w-4 h-4 text-gray-400" />
<Input
placeholder="Поиск товаров..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1"
/>
</div>
{/* Скидка */}
<div>
<Label htmlFor="best-price-discount">Скидка (%)</Label>
<Input
id="best-price-discount"
type="number"
min="0"
max="100"
value={bestPriceDiscount}
onChange={(e) => setBestPriceDiscount(Number(e.target.value))}
placeholder="Размер скидки (необязательно)"
/>
</div>
{/* Список товаров */}
<div className="max-h-96 overflow-y-auto space-y-2">
{productsLoading ? (
<div className="text-center py-4 text-gray-500">Загрузка товаров...</div>
) : products.length === 0 ? (
<div className="text-center py-4 text-gray-500">Товары не найдены</div>
) : (
products.map((product) => (
<div key={product.id} className="border rounded p-3 flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-12 h-12 bg-gray-100 rounded border flex items-center justify-center">
{product.images?.[0]?.url ? (
<img
src={product.images[0].url}
alt={product.name}
className="w-full h-full object-cover rounded"
/>
) : (
<Package className="w-4 h-4 text-gray-400" />
)}
</div>
<div>
<h4 className="font-medium">{product.name}</h4>
<div className="text-sm text-gray-500">
{product.article && <span>Артикул: {product.article} | </span>}
{product.brand && <span>Бренд: {product.brand} | </span>}
<span>Цена: {formatPrice(product.retailPrice)}</span>
{bestPriceDiscount > 0 && (
<span className="text-green-600 ml-2">
Со скидкой: {formatPrice(calculateDiscountedPrice(product.retailPrice, bestPriceDiscount))}
</span>
)}
</div>
</div>
</div>
<Button
onClick={() => handleAddBestPriceProduct(product.id)}
disabled={creatingBestPrice}
size="sm"
>
Добавить
</Button>
</div>
))
)}
</div>
</div>
</DialogContent>
</Dialog>
{/* Диалог добавления товара в топ продаж */}
<Dialog open={showTopSalesProductSelector} onOpenChange={setShowTopSalesProductSelector}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Добавить товар в топ продаж</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Поиск товаров */}
<div className="flex items-center space-x-2">
<Search className="w-4 h-4 text-gray-400" />
<Input
placeholder="Поиск товаров..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1"
/>
</div>
{/* Список товаров */}
<div className="max-h-96 overflow-y-auto space-y-2">
{productsLoading ? (
<div className="text-center py-4 text-gray-500">Загрузка товаров...</div>
) : products.length === 0 ? (
<div className="text-center py-4 text-gray-500">Товары не найдены</div>
) : (
products.map((product) => (
<div
key={product.id}
className={`border rounded p-3 flex items-center justify-between cursor-pointer transition-colors ${
selectedProduct?.id === product.id ? 'bg-blue-50 border-blue-200' : 'hover:bg-gray-50'
}`}
onClick={() => setSelectedProduct(product)}
>
<div className="flex items-center space-x-3">
<div className="w-12 h-12 bg-gray-100 rounded border flex items-center justify-center">
{product.images?.[0]?.url ? (
<img
src={product.images[0].url}
alt={product.name}
className="w-full h-full object-cover rounded"
/>
) : (
<Package className="w-4 h-4 text-gray-400" />
)}
</div>
<div>
<h4 className="font-medium">{product.name}</h4>
<div className="text-sm text-gray-500">
{product.article && <span>Артикул: {product.article} | </span>}
{product.brand && <span>Бренд: {product.brand} | </span>}
<span>Цена: {formatPrice(product.retailPrice)}</span>
</div>
</div>
</div>
{selectedProduct?.id === product.id && (
<Badge variant="secondary">Выбран</Badge>
)}
</div>
))
)}
</div>
{selectedProduct && (
<div className="pt-4 border-t">
<Button onClick={handleAddTopSalesProduct} className="w-full">
Добавить выбранный товар в топ продаж
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Диалог редактирования товара дня */}
<Dialog open={!!editingDailyProduct} onOpenChange={() => setEditingDailyProduct(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Редактировать товар дня</DialogTitle>
</DialogHeader>
{editingDailyProduct && (
<div className="space-y-4">
<div>
<h3 className="font-medium">{editingDailyProduct.product.name}</h3>
<p className="text-sm text-gray-500">
{editingDailyProduct.product.article && `Артикул: ${editingDailyProduct.product.article} | `}
{editingDailyProduct.product.brand && `Бренд: ${editingDailyProduct.product.brand} | `}
Цена: {formatPrice(editingDailyProduct.product.retailPrice)}
</p>
</div>
<div>
<Label htmlFor="edit-daily-discount">Скидка (%)</Label>
<Input
id="edit-daily-discount"
type="number"
min="0"
max="100"
value={dailyDiscount}
onChange={(e) => setDailyDiscount(Number(e.target.value))}
placeholder="Размер скидки"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingDailyProduct(null)}>
Отмена
</Button>
<Button onClick={handleUpdateDailyProduct} disabled={updatingDaily}>
Сохранить
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
{/* Диалог редактирования товара с лучшей ценой */}
<Dialog open={!!editingBestPriceProduct} onOpenChange={() => setEditingBestPriceProduct(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Редактировать товар с лучшей ценой</DialogTitle>
</DialogHeader>
{editingBestPriceProduct && (
<div className="space-y-4">
<div>
<h3 className="font-medium">{editingBestPriceProduct.product.name}</h3>
<p className="text-sm text-gray-500">
{editingBestPriceProduct.product.article && `Артикул: ${editingBestPriceProduct.product.article} | `}
{editingBestPriceProduct.product.brand && `Бренд: ${editingBestPriceProduct.product.brand} | `}
Цена: {formatPrice(editingBestPriceProduct.product.retailPrice)}
</p>
</div>
<div>
<Label htmlFor="edit-best-price-discount">Скидка (%)</Label>
<Input
id="edit-best-price-discount"
type="number"
min="0"
max="100"
value={bestPriceDiscount}
onChange={(e) => setBestPriceDiscount(Number(e.target.value))}
placeholder="Размер скидки (необязательно)"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingBestPriceProduct(null)}>
Отмена
</Button>
<Button onClick={handleUpdateBestPriceProduct} disabled={updatingBestPrice}>
Сохранить
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,332 @@
"use client"
import { useState } from 'react'
import { useQuery } from '@apollo/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import {
Search,
Shield,
Package,
Loader2,
ChevronRight,
Eye
} from 'lucide-react'
import { GET_PARTSINDEX_CATEGORIES, GET_PARTSAPI_CATEGORIES } from '@/lib/graphql/queries'
import { KrajaCategories } from '@/components/kraja/KrajaCategories'
import { KrajaCategoryItems } from '@/components/kraja/KrajaCategoryItems'
import { KrajaSavedTables } from '@/components/kraja/KrajaSavedTables'
interface Category {
id: string
name: string
image?: string
groups?: Array<{
id: string
name: string
image?: string
subgroups?: Array<{
id: string
name: string
image?: string
entityNames?: Array<{
id: string
name: string
}>
}>
entityNames?: Array<{
id: string
name: string
}>
}>
}
interface PartsAPICategory {
id: string
name: string
level: number
parentId?: string
children?: PartsAPICategory[]
}
export default function KrajaPage() {
const [activeTab, setActiveTab] = useState<'partsindex' | 'partsapi' | 'saved'>('partsindex')
const [selectedCategory, setSelectedCategory] = useState<Category | PartsAPICategory | null>(null)
const [selectedGroup, setSelectedGroup] = useState<any>(null)
const [searchQuery, setSearchQuery] = useState('')
const [viewingTable, setViewingTable] = useState<{categoryId: string, categoryType: string, tableName: string} | null>(null)
// Загрузка категорий PartsIndex
const { data: partsIndexData, loading: partsIndexLoading, error: partsIndexError } = useQuery(
GET_PARTSINDEX_CATEGORIES,
{
variables: { lang: 'ru' },
errorPolicy: 'all'
}
)
// Загрузка категорий PartsAPI
const { data: partsAPIData, loading: partsAPILoading, error: partsAPIError } = useQuery(
GET_PARTSAPI_CATEGORIES,
{
variables: { carId: 9877, carType: 'PC' },
errorPolicy: 'all'
}
)
const partsIndexCategories = partsIndexData?.partsIndexCategoriesWithGroups || []
const partsAPICategories = partsAPIData?.partsAPICategories || []
const handleCategorySelect = (category: Category | PartsAPICategory, group?: any) => {
setSelectedCategory(category)
setSelectedGroup(group || null)
}
const handleBackToCategories = () => {
setSelectedCategory(null)
setSelectedGroup(null)
setViewingTable(null)
}
const handleViewTable = (categoryId: string, categoryType: string, tableName: string) => {
setViewingTable({ categoryId, categoryType, tableName })
setActiveTab('saved')
}
const filteredPartsIndexCategories = partsIndexCategories.filter(category =>
category.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const filteredPartsAPICategories = partsAPICategories.filter(category =>
category.name.toLowerCase().includes(searchQuery.toLowerCase())
)
// Если выбрана категория, показываем её товары
if (selectedCategory) {
return (
<div className="container mx-auto py-6 px-4">
<div className="space-y-6">
{/* Заголовок с кнопкой возврата */}
<div className="flex items-center gap-4">
<Button
variant="outline"
onClick={handleBackToCategories}
className="flex items-center gap-2"
>
<ChevronRight className="h-4 w-4 rotate-180" />
Назад к категориям
</Button>
<div className="flex items-center gap-2">
<Shield className="h-6 w-6 text-blue-600" />
<h1 className="text-2xl font-bold">Кража - {selectedCategory.name}</h1>
{selectedGroup && (
<>
<ChevronRight className="h-4 w-4 text-gray-400" />
<span className="text-lg text-gray-600">{selectedGroup.name}</span>
</>
)}
</div>
</div>
{/* Товары категории */}
<KrajaCategoryItems
category={selectedCategory}
group={selectedGroup}
categoryType={activeTab === 'partsindex' ? 'partsindex' : 'partsapi'}
/>
</div>
</div>
)
}
// Если просматриваем сохраненную таблицу
if (viewingTable && activeTab === 'saved') {
return (
<div className="container mx-auto py-6 px-4">
<div className="space-y-6">
{/* Заголовок с кнопкой возврата */}
<div className="flex items-center gap-4">
<Button
variant="outline"
onClick={handleBackToCategories}
className="flex items-center gap-2"
>
<ChevronRight className="h-4 w-4 rotate-180" />
Назад к таблицам
</Button>
<div className="flex items-center gap-2">
<Shield className="h-6 w-6 text-blue-600" />
<h1 className="text-2xl font-bold">Сохраненные данные - {viewingTable.tableName}</h1>
<Badge variant="secondary">
{viewingTable.categoryType.toUpperCase()}
</Badge>
</div>
</div>
{/* Содержимое сохраненной таблицы */}
<KrajaCategoryItems
category={{ id: viewingTable.categoryId, name: viewingTable.tableName }}
categoryType={viewingTable.categoryType.toLowerCase() as 'partsindex' | 'partsapi'}
isViewingSavedData={true}
/>
</div>
</div>
)
}
return (
<div className="container mx-auto py-6 px-4">
<div className="space-y-6">
{/* Заголовок */}
<div className="flex items-center gap-3">
<Shield className="h-8 w-8 text-blue-600" />
<div>
<h1 className="text-3xl font-bold">Кража</h1>
<p className="text-gray-600">
Просмотр категорий и товаров из PartsIndex и PartsAPI
</p>
</div>
</div>
{/* Поиск */}
{activeTab !== 'saved' && (
<Card>
<CardContent className="p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Поиск по категориям..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</CardContent>
</Card>
)}
{/* Статистика */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<Package className="h-5 w-5 text-blue-600" />
<div>
<div className="text-sm text-gray-600">Категорий PartsIndex</div>
<div className="text-2xl font-bold">{partsIndexCategories.length}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<Package className="h-5 w-5 text-green-600" />
<div>
<div className="text-sm text-gray-600">Категорий PartsAPI</div>
<div className="text-2xl font-bold">{partsAPICategories.length}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<Eye className="h-5 w-5 text-purple-600" />
<div>
<div className="text-sm text-gray-600">Всего категорий</div>
<div className="text-2xl font-bold">{partsIndexCategories.length + partsAPICategories.length}</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Табы с категориями */}
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'partsindex' | 'partsapi' | 'saved')}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="partsindex" className="flex items-center gap-2">
<Package className="h-4 w-4" />
PartsIndex
<Badge variant="secondary">{partsIndexCategories.length}</Badge>
</TabsTrigger>
<TabsTrigger value="partsapi" className="flex items-center gap-2">
<Package className="h-4 w-4" />
PartsAPI
<Badge variant="secondary">{partsAPICategories.length}</Badge>
</TabsTrigger>
<TabsTrigger value="saved" className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Сохраненные
</TabsTrigger>
</TabsList>
<TabsContent value="partsindex" className="space-y-4">
{partsIndexLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
<span className="ml-2 text-gray-600">Загрузка категорий PartsIndex...</span>
</div>
) : partsIndexError ? (
<Card>
<CardContent className="p-6">
<div className="text-center text-red-600">
Ошибка загрузки категорий PartsIndex: {partsIndexError.message}
</div>
</CardContent>
</Card>
) : (
<KrajaCategories
categories={filteredPartsIndexCategories}
onCategorySelect={handleCategorySelect}
type="partsindex"
/>
)}
</TabsContent>
<TabsContent value="partsapi" className="space-y-4">
{partsAPILoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
<span className="ml-2 text-gray-600">Загрузка категорий PartsAPI...</span>
</div>
) : partsAPIError ? (
<Card>
<CardContent className="p-6">
<div className="text-center text-red-600">
Ошибка загрузки категорий PartsAPI: {partsAPIError.message}
</div>
</CardContent>
</Card>
) : (
<KrajaCategories
categories={filteredPartsAPICategories}
onCategorySelect={handleCategorySelect}
type="partsapi"
/>
)}
</TabsContent>
<TabsContent value="saved" className="space-y-4">
<KrajaSavedTables onViewTable={handleViewTable} />
</TabsContent>
</Tabs>
</div>
</div>
)
}

View File

@ -0,0 +1,11 @@
"use client"
import NavigationCategoryTree from '@/components/navigation/NavigationCategoryTree'
export default function NavigationPage() {
return (
<div className="container mx-auto py-6 px-4">
<NavigationCategoryTree />
</div>
)
}

View File

@ -0,0 +1,250 @@
"use client"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
AlertCircle,
CheckCircle,
Info,
ShoppingCart,
Users,
Settings
} from 'lucide-react'
export default function TestStylesPage() {
return (
<div className="p-6 space-y-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground mb-2">
Тест стилей Tailwind CSS + Shadcn/ui
</h1>
<p className="text-muted-foreground">
Проверка всех основных компонентов и цветов
</p>
</div>
{/* Color Test */}
<Card>
<CardHeader>
<CardTitle>Цветовая палитра</CardTitle>
<CardDescription>Проверка всех основных цветов</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-4 bg-primary text-primary-foreground rounded-lg">
<p className="font-semibold">Primary</p>
<p className="text-sm">Основной цвет</p>
</div>
<div className="p-4 bg-secondary text-secondary-foreground rounded-lg">
<p className="font-semibold">Secondary</p>
<p className="text-sm">Вторичный цвет</p>
</div>
<div className="p-4 bg-accent text-accent-foreground rounded-lg">
<p className="font-semibold">Accent</p>
<p className="text-sm">Акцентный цвет</p>
</div>
<div className="p-4 bg-muted text-muted-foreground rounded-lg">
<p className="font-semibold">Muted</p>
<p className="text-sm">Приглушенный</p>
</div>
</div>
</CardContent>
</Card>
{/* Buttons Test */}
<Card>
<CardHeader>
<CardTitle>Кнопки</CardTitle>
<CardDescription>Различные варианты кнопок</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-4">
<Button variant="default">Default</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
<Button variant="destructive">Destructive</Button>
</div>
<div className="flex flex-wrap gap-4">
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
<Button size="icon">
<Settings className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* Badges Test */}
<Card>
<CardHeader>
<CardTitle>Бейджи</CardTitle>
<CardDescription>Различные статусы и метки</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-4">
<Badge variant="default">Default</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="destructive">Destructive</Badge>
</div>
</CardContent>
</Card>
{/* Form Elements Test */}
<Card>
<CardHeader>
<CardTitle>Элементы формы</CardTitle>
<CardDescription>Поля ввода и лейблы</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" placeholder="email@example.com" />
</div>
<div className="space-y-2">
<Label htmlFor="password">Пароль</Label>
<Input id="password" type="password" placeholder="Введите пароль" />
</div>
</div>
</CardContent>
</Card>
{/* Icons Test */}
<Card>
<CardHeader>
<CardTitle>Иконки</CardTitle>
<CardDescription>Lucide React иконки</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2 p-2 bg-green-50 text-green-700 rounded-lg">
<CheckCircle className="h-5 w-5" />
<span>Успех</span>
</div>
<div className="flex items-center gap-2 p-2 bg-red-50 text-red-700 rounded-lg">
<AlertCircle className="h-5 w-5" />
<span>Ошибка</span>
</div>
<div className="flex items-center gap-2 p-2 bg-blue-50 text-blue-700 rounded-lg">
<Info className="h-5 w-5" />
<span>Информация</span>
</div>
<div className="flex items-center gap-2 p-2 bg-purple-50 text-purple-700 rounded-lg">
<ShoppingCart className="h-5 w-5" />
<span>Заказы</span>
</div>
<div className="flex items-center gap-2 p-2 bg-orange-50 text-orange-700 rounded-lg">
<Users className="h-5 w-5" />
<span>Клиенты</span>
</div>
</div>
</CardContent>
</Card>
{/* Cards Test */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShoppingCart className="h-5 w-5" />
Заказы
</CardTitle>
<CardDescription>Всего заказов</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">1,234</div>
<p className="text-xs text-muted-foreground">
+20.1% с прошлого месяца
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Клиенты
</CardTitle>
<CardDescription>Активные клиенты</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">456</div>
<p className="text-xs text-muted-foreground">
+15.3% с прошлого месяца
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
Проблемы
</CardTitle>
<CardDescription>Требуют внимания</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">12</div>
<p className="text-xs text-muted-foreground">
-5.2% с прошлого месяца
</p>
</CardContent>
</Card>
</div>
{/* Dark/Light Mode Test */}
<Card>
<CardHeader>
<CardTitle>Тема</CardTitle>
<CardDescription>Проверка адаптации к темной/светлой теме</CardDescription>
</CardHeader>
<CardContent>
<div className="p-4 border rounded-lg">
<p className="text-foreground mb-2">
Этот текст должен адаптироваться к теме
</p>
<p className="text-muted-foreground">
А этот текст должен быть приглушенным
</p>
</div>
</CardContent>
</Card>
{/* Status Message */}
<Card>
<CardHeader>
<CardTitle className="text-green-600"> Статус интеграции</CardTitle>
<CardDescription>Результат исправления Tailwind CSS</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<p className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<span>Tailwind CSS v4 правильно интегрирован</span>
</p>
<p className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<span>Shadcn/ui компоненты работают корректно</span>
</p>
<p className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<span>Цветовая схема применяется правильно</span>
</p>
<p className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<span>Поддержка темной/светлой темы активна</span>
</p>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,182 @@
"use client"
import { useCallback, useEffect, useState } from 'react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { format } from 'date-fns'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
export default function ZzapStatsPage() {
const [article, setArticle] = useState('')
const [selector, setSelector] = useState('')
const [loading, setLoading] = useState(false)
const [imgSrc, setImgSrc] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [debug, setDebug] = useState(false)
const [debugInfo, setDebugInfo] = useState<any>(null)
const [history, setHistory] = useState<any[]>([])
const [historyLoading, setHistoryLoading] = useState(false)
const [historyError, setHistoryError] = useState<string | null>(null)
const [historyPage, setHistoryPage] = useState(1)
const [historyPageSize] = useState(20)
const [historyTotal, setHistoryTotal] = useState(0)
const [query, setQuery] = useState('')
const loadHistory = useCallback(async () => {
try {
setHistoryLoading(true)
setHistoryError(null)
const params = new URLSearchParams()
params.set('page', String(historyPage))
params.set('pageSize', String(historyPageSize))
if (query.trim()) params.set('q', query.trim())
const res = await fetch(`/api/zzap/history?${params.toString()}`, { cache: 'no-store' })
if (!res.ok) throw new Error(`History error ${res.status}`)
const data = await res.json()
setHistory(data.items || [])
setHistoryTotal(data.total || 0)
} catch (e: any) {
setHistoryError(e?.message || 'Не удалось получить историю')
} finally {
setHistoryLoading(false)
}
}, [historyPage, historyPageSize, query])
// initial load
useEffect(() => { loadHistory() }, [loadHistory])
const onSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
setImgSrc(null)
try {
const params = new URLSearchParams({ article })
if (selector) params.set('selector', selector)
if (debug) params.set('debug', '1')
const res = await fetch(`/api/zzap/screenshot?${params.toString()}`)
const ct = res.headers.get('content-type') || ''
if (res.ok && ct.includes('image/png')) {
const blob = await res.blob()
const url = URL.createObjectURL(blob)
setImgSrc(url)
setDebugInfo(null)
loadHistory()
} else {
const data = await res.json().catch(() => ({}))
if (debug) {
setDebugInfo(data)
throw new Error(data?.error || `Ошибка ${res.status}`)
}
throw new Error(data?.error || `Ошибка ${res.status}`)
}
} catch (err: any) {
setError(err.message || 'Не удалось получить скриншот')
} finally {
setLoading(false)
}
}, [article, selector, debug])
return (
<div className="p-6">
<Card className="max-w-3xl">
<CardHeader>
<CardTitle>ZZAP: скриншот графика статистики</CardTitle>
<CardDescription>
Введите артикул, сервис авторизуется на zzap.ru и вернёт PNG скриншот графика.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form onSubmit={onSubmit} className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2 sm:col-span-1">
<Label htmlFor="article">Артикул</Label>
<Input id="article" value={article} onChange={(e) => setArticle(e.target.value)} placeholder="например, 06A145710P" required />
</div>
<div className="grid gap-2 sm:col-span-1">
<Label htmlFor="selector">CSS селектор (опционально)</Label>
<Input id="selector" value={selector} onChange={(e) => setSelector(e.target.value)} placeholder="например, .chart-container" />
</div>
<div className="flex items-center gap-2 sm:col-span-2">
<input id="debug" type="checkbox" checked={debug} onChange={(e) => setDebug(e.target.checked)} />
<Label htmlFor="debug">Режим отладки (вернуть детали вместо PNG)</Label>
</div>
<div className="sm:col-span-2">
<Button type="submit" disabled={loading || !article}>
{loading ? 'Получаю…' : 'Получить скрин графика'}
</Button>
</div>
</form>
{error && <p className="text-sm text-red-600">{error}</p>}
{imgSrc && (
<div className="mt-2">
<h3 className="mb-2 font-medium">Результат</h3>
<img src={imgSrc} alt="Скриншот графика ZZAP" className="max-w-full border rounded-md" />
</div>
)}
{debugInfo && (
<pre className="mt-2 whitespace-pre-wrap text-xs bg-muted p-3 rounded-md overflow-auto max-h-[50vh]">
{JSON.stringify(debugInfo, null, 2)}
</pre>
)}
</CardContent>
</Card>
<Card className="mt-6">
<CardHeader>
<CardTitle>История запросов</CardTitle>
<CardDescription>Последние 20 запросов ZZAP. Кликните по ссылке, чтобы открыть изображение.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 mb-3">
<Input placeholder="Поиск по артикулу" value={query} onChange={(e) => { setQuery(e.target.value); setHistoryPage(1); }} className="max-w-xs" />
<Button variant="outline" onClick={() => { setHistoryPage(1); loadHistory() }} disabled={historyLoading}>Найти</Button>
</div>
{historyError && <p className="text-sm text-red-600">{historyError}</p>}
<Table>
<TableHeader>
<TableRow>
<TableHead>Дата</TableHead>
<TableHead>Артикул</TableHead>
<TableHead>Статус</TableHead>
<TableHead>URL статистики</TableHead>
<TableHead>Изображение</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{historyLoading && (
<TableRow><TableCell colSpan={5}>Загрузка</TableCell></TableRow>
)}
{!historyLoading && history.length === 0 && (
<TableRow><TableCell colSpan={5}>Пусто</TableCell></TableRow>
)}
{history.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.createdAt ? format(new Date(item.createdAt), 'dd.MM.yyyy HH:mm') : '—'}</TableCell>
<TableCell>{item.article}</TableCell>
<TableCell>{item.ok ? 'OK' : 'ERR'}</TableCell>
<TableCell className="max-w-[280px] truncate">
{item.statsUrl ? <a className="text-blue-600 underline" href={item.statsUrl} target="_blank" rel="noreferrer">{item.statsUrl}</a> : '—'}
</TableCell>
<TableCell>
{item.imageUrl ? <a className="text-blue-600 underline" href={item.imageUrl} target="_blank" rel="noreferrer">Открыть</a> : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">Всего: {historyTotal}</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setHistoryPage((p) => Math.max(1, p - 1))} disabled={historyPage === 1 || historyLoading}>Назад</Button>
<div className="text-sm">Стр. {historyPage} / {Math.max(1, Math.ceil(historyTotal / historyPageSize))}</div>
<Button variant="outline" size="sm" onClick={() => setHistoryPage((p) => p + 1)} disabled={historyPage >= Math.ceil(historyTotal / historyPageSize) || historyLoading}>Вперёд</Button>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -1,50 +1,11 @@
@import "tailwindcss";
@import "tw-animate-css";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
/* Основные цвета в HSL формате */
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
@ -58,6 +19,7 @@
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: 210 40% 98%;
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
@ -74,6 +36,8 @@
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
}
.dark {
@ -92,6 +56,7 @@
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: 210 40% 98%;
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
@ -110,6 +75,53 @@
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;

25
src/app/robots.ts Normal file
View File

@ -0,0 +1,25 @@
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
disallow: '/',
},
{
userAgent: 'Googlebot',
disallow: '/',
},
{
userAgent: 'Bingbot',
disallow: '/',
},
{
userAgent: 'Yandex',
disallow: '/',
},
],
sitemap: 'https://admin.protekauto.ru/sitemap.xml',
}
}

18
src/app/sitemap.ts Normal file
View File

@ -0,0 +1,18 @@
import type { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: 'https://admin.protekauto.ru',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.1,
},
{
url: 'https://admin.protekauto.ru/login',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.1,
},
]
}

View File

@ -302,6 +302,8 @@ export const CategoryForm = ({
</div>
</div>
{/* Настройки */}
<div className="space-y-3">
<h3 className="text-sm font-medium">Настройки</h3>

View File

@ -0,0 +1,266 @@
"use client"
import React, { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Search, ChevronRight, ChevronDown, Folder, FolderOpen } from 'lucide-react'
interface Category {
id: string
name: string
slug: string
level?: number
parentId?: string | null
children?: Category[]
_count?: {
products: number
}
}
interface CategorySelectorProps {
open: boolean
onOpenChange: (open: boolean) => void
categories: Category[]
onCategorySelect: (categoryId: string, categoryName: string) => void
title?: string
description?: string
}
export const CategorySelector = ({
open,
onOpenChange,
categories,
onCategorySelect,
title = "Выберите категорию",
description = "Выберите категорию для перемещения товаров"
}: CategorySelectorProps) => {
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
// Функция для построения дерева категорий
const buildCategoryTree = (categories: Category[]): Category[] => {
const categoryMap = new Map<string, Category>()
const rootCategories: Category[] = []
// Создаем карту всех категорий
categories.forEach(category => {
categoryMap.set(category.id, { ...category, children: [] })
})
// Строим дерево
categories.forEach(category => {
const categoryWithChildren = categoryMap.get(category.id)!
if (category.parentId) {
const parent = categoryMap.get(category.parentId)
if (parent) {
parent.children = parent.children || []
parent.children.push(categoryWithChildren)
}
} else {
rootCategories.push(categoryWithChildren)
}
})
return rootCategories
}
// Фильтрация категорий по поисковому запросу
const filterCategories = (categories: Category[], query: string): Category[] => {
if (!query) return categories
const filtered: Category[] = []
categories.forEach(category => {
const matchesQuery = category.name.toLowerCase().includes(query.toLowerCase())
const filteredChildren = category.children ? filterCategories(category.children, query) : []
if (matchesQuery || filteredChildren.length > 0) {
filtered.push({
...category,
children: filteredChildren
})
}
})
return filtered
}
const toggleExpanded = (categoryId: string) => {
const newExpanded = new Set(expandedCategories)
if (newExpanded.has(categoryId)) {
newExpanded.delete(categoryId)
} else {
newExpanded.add(categoryId)
}
setExpandedCategories(newExpanded)
}
const renderCategory = (category: Category, level = 0) => {
const hasChildren = category.children && category.children.length > 0
const isExpanded = expandedCategories.has(category.id)
const isSelected = selectedCategoryId === category.id
return (
<div key={category.id}>
<div
className={`flex items-center py-2 px-3 rounded cursor-pointer hover:bg-gray-50 ${
isSelected ? 'bg-blue-50 border border-blue-200' : ''
}`}
style={{ paddingLeft: `${level * 20 + 12}px` }}
onClick={() => setSelectedCategoryId(category.id)}
>
{hasChildren && (
<button
onClick={(e) => {
e.stopPropagation()
toggleExpanded(category.id)
}}
className="mr-2 p-1 hover:bg-gray-200 rounded"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
)}
{!hasChildren && <div className="w-6 mr-2" />}
<div className="mr-2">
{hasChildren ? (
isExpanded ? <FolderOpen className="w-4 h-4 text-blue-500" /> : <Folder className="w-4 h-4 text-blue-500" />
) : (
<Folder className="w-4 h-4 text-gray-400" />
)}
</div>
<div className="flex-1">
<span className={`text-sm ${isSelected ? 'font-medium text-blue-700' : 'text-gray-900'}`}>
{category.name}
</span>
{category._count && (
<span className="text-xs text-gray-500 ml-2">
({category._count.products} товаров)
</span>
)}
</div>
</div>
{hasChildren && isExpanded && (
<div>
{category.children!.map(child => renderCategory(child, level + 1))}
</div>
)}
</div>
)
}
const handleConfirm = () => {
if (selectedCategoryId) {
const selectedCategory = categories.find(cat => cat.id === selectedCategoryId)
if (selectedCategory) {
onCategorySelect(selectedCategoryId, selectedCategory.name)
onOpenChange(false)
setSelectedCategoryId(null)
setSearchQuery('')
}
}
}
const handleCancel = () => {
onOpenChange(false)
setSelectedCategoryId(null)
setSearchQuery('')
}
const categoryTree = buildCategoryTree(categories)
const filteredCategories = filterCategories(categoryTree, searchQuery)
// Автоматически разворачиваем категории при поиске
React.useEffect(() => {
if (searchQuery) {
const expandAll = (categories: Category[]) => {
categories.forEach(category => {
if (category.children && category.children.length > 0) {
setExpandedCategories(prev => new Set([...prev, category.id]))
expandAll(category.children)
}
})
}
expandAll(filteredCategories)
}
}, [searchQuery, filteredCategories])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && (
<p className="text-sm text-gray-600">{description}</p>
)}
</DialogHeader>
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
{/* Поиск */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Поиск категорий..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{/* Список категорий */}
<div className="flex-1 overflow-y-auto border rounded-lg">
{filteredCategories.length > 0 ? (
<div className="p-2 space-y-1">
{filteredCategories.map(category => renderCategory(category))}
</div>
) : (
<div className="p-8 text-center text-gray-500">
{searchQuery ? 'Категории не найдены' : 'Нет доступных категорий'}
</div>
)}
</div>
{/* Выбранная категория */}
{selectedCategoryId && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
<Label className="text-sm font-medium text-blue-900">Выбранная категория:</Label>
<p className="text-sm text-blue-700 mt-1">
{categories.find(cat => cat.id === selectedCategoryId)?.name}
</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Отмена
</Button>
<Button
onClick={handleConfirm}
disabled={!selectedCategoryId}
style={{ cursor: selectedCategoryId ? 'pointer' : 'not-allowed' }}
>
Переместить товары
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -29,6 +29,7 @@ interface Product {
isVisible: boolean
applyDiscounts: boolean
stock: number
brand?: string
categories: Category[]
images: ProductImage[]
characteristics: ProductCharacteristic[]
@ -188,6 +189,7 @@ export const ProductForm = ({
isVisible: true,
applyDiscounts: true,
stock: '0',
brand: '',
categoryIds: selectedCategoryId ? [selectedCategoryId] : [] as string[]
})
@ -250,6 +252,7 @@ export const ProductForm = ({
isVisible: product.isVisible ?? true,
applyDiscounts: product.applyDiscounts ?? true,
stock: product.stock?.toString() || '0',
brand: product.brand || '',
categoryIds: product.categories?.map(cat => cat.id) || []
})
// Очищаем изображения от лишних полей
@ -474,6 +477,11 @@ export const ProductForm = ({
return
}
if (!formData.brand.trim()) {
alert('Введите бренд товара')
return
}
try {
const dimensions = [formData.dimensionLength, formData.dimensionWidth, formData.dimensionHeight]
.filter(d => d.trim())
@ -493,6 +501,7 @@ export const ProductForm = ({
isVisible: formData.isVisible,
applyDiscounts: formData.applyDiscounts,
stock: parseInt(formData.stock) || 0,
brand: formData.brand.trim(),
categoryIds: formData.categoryIds
}
@ -658,8 +667,8 @@ export const ProductForm = ({
</div>
{/* Основная информация */}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-3">
<Label htmlFor="name">Наименование *</Label>
<Input
id="name"
@ -680,6 +689,18 @@ export const ProductForm = ({
/>
</div>
<div>
<Label htmlFor="brand">Бренд *</Label>
<Input
id="brand"
value={formData.brand}
onChange={(e) => handleInputChange('brand', e.target.value)}
placeholder="Введите бренд товара"
required
style={{ cursor: 'pointer' }}
/>
</div>
<div>
<Label htmlFor="slug">Адрес (Slug)</Label>
<Input

View File

@ -1,12 +1,13 @@
"use client"
import React, { useState } from 'react'
import { Loader2, Package, Plus, Edit, Trash2 } from 'lucide-react'
import { Loader2, Package, Plus, Edit, Trash2, FolderOpen } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { useMutation } from '@apollo/client'
import { DELETE_PRODUCT, DELETE_PRODUCTS, UPDATE_PRODUCT_VISIBILITY, UPDATE_PRODUCTS_VISIBILITY } from '@/lib/graphql/mutations'
import { DELETE_PRODUCT, DELETE_PRODUCTS, UPDATE_PRODUCT_VISIBILITY, UPDATE_PRODUCTS_VISIBILITY, MOVE_PRODUCTS_TO_CATEGORY } from '@/lib/graphql/mutations'
import { CategorySelector } from './CategorySelector'
interface Product {
id: string
@ -20,22 +21,36 @@ interface Product {
categories: { id: string; name: string }[]
}
interface Category {
id: string
name: string
slug: string
level?: number
parentId?: string | null
_count?: {
products: number
}
}
interface ProductListProps {
products: Product[]
loading?: boolean
onProductEdit: (product: Product) => void
onProductCreated: () => void
categories?: Category[]
}
export const ProductList = ({ products, loading, onProductEdit, onProductCreated }: ProductListProps) => {
export const ProductList = ({ products, loading, onProductEdit, onProductCreated, categories = [] }: ProductListProps) => {
const [selectedProducts, setSelectedProducts] = useState<string[]>([])
const [selectAll, setSelectAll] = useState(false)
const [bulkLoading, setBulkLoading] = useState(false)
const [showCategorySelector, setShowCategorySelector] = useState(false)
const [deleteProduct] = useMutation(DELETE_PRODUCT)
const [deleteProducts] = useMutation(DELETE_PRODUCTS)
const [updateProductVisibility] = useMutation(UPDATE_PRODUCT_VISIBILITY)
const [updateProductsVisibility] = useMutation(UPDATE_PRODUCTS_VISIBILITY)
const [moveProductsToCategory] = useMutation(MOVE_PRODUCTS_TO_CATEGORY)
const handleSelectAll = (checked: boolean) => {
setSelectAll(checked)
@ -120,6 +135,30 @@ export const ProductList = ({ products, loading, onProductEdit, onProductCreated
}
}
const handleMoveToCategory = async (categoryId: string, categoryName: string) => {
if (selectedProducts.length === 0) return
setBulkLoading(true)
try {
const result = await moveProductsToCategory({
variables: {
productIds: selectedProducts,
categoryId
}
})
console.log('Результат перемещения товаров:', result)
alert(`Успешно перемещено ${result.data?.moveProductsToCategory?.count || selectedProducts.length} товаров в категорию "${categoryName}"`)
setSelectedProducts([])
setSelectAll(false)
onProductCreated() // Обновляем список
} catch (error) {
console.error('Ошибка перемещения товаров:', error)
alert('Не удалось переместить товары в категорию')
} finally {
setBulkLoading(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
@ -174,6 +213,15 @@ export const ProductList = ({ products, loading, onProductEdit, onProductCreated
{bulkLoading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
Скрыть с сайта
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowCategorySelector(true)}
disabled={bulkLoading}
>
{bulkLoading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <FolderOpen className="w-4 h-4 mr-2" />}
Переместить в категорию
</Button>
<Button
variant="outline"
size="sm"
@ -311,7 +359,15 @@ export const ProductList = ({ products, loading, onProductEdit, onProductCreated
))}
</div>
{/* Модальное окно выбора категории */}
<CategorySelector
open={showCategorySelector}
onOpenChange={setShowCategorySelector}
categories={categories}
onCategorySelect={handleMoveToCategory}
title="Переместить товары в категорию"
description={`Выберите категорию для перемещения ${selectedProducts.length} товаров`}
/>
</div>
)
}

View File

@ -0,0 +1,381 @@
'use client'
import { useState } from 'react'
import { useMutation } from '@apollo/client'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Package,
ChevronRight,
FolderOpen,
Layers,
Image as ImageIcon,
Download,
Loader2
} from 'lucide-react'
import { FETCH_CATEGORY_PRODUCTS } from '@/lib/graphql/queries'
import toast from 'react-hot-toast'
interface PartsIndexCategory {
id: string
name: string
image?: string
groups?: Array<{
id: string
name: string
image?: string
subgroups?: Array<{
id: string
name: string
image?: string
entityNames?: Array<{
id: string
name: string
}>
}>
entityNames?: Array<{
id: string
name: string
}>
}>
}
interface PartsAPICategory {
id: string
name: string
level: number
parentId?: string
children?: PartsAPICategory[]
}
interface KrajaCategoriesProps {
categories: PartsIndexCategory[] | PartsAPICategory[]
onCategorySelect: (category: PartsIndexCategory | PartsAPICategory, group?: any) => void
type: 'partsindex' | 'partsapi'
}
export const KrajaCategories = ({ categories, onCategorySelect, type }: KrajaCategoriesProps) => {
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
const [fetchingCategories, setFetchingCategories] = useState<Set<string>>(new Set())
const [fetchCategoryProducts] = useMutation(FETCH_CATEGORY_PRODUCTS, {
onCompleted: (data) => {
if (data.fetchCategoryProducts.success) {
toast.success(`${data.fetchCategoryProducts.message}`)
} else {
toast.error(`${data.fetchCategoryProducts.message}`)
}
},
onError: (error) => {
toast.error(`${error.message}`)
}
})
const toggleCategory = (categoryId: string) => {
setExpandedCategories(prev => {
const newSet = new Set(prev)
if (newSet.has(categoryId)) {
newSet.delete(categoryId)
} else {
newSet.add(categoryId)
}
return newSet
})
}
const handleCategoryClick = (category: PartsIndexCategory | PartsAPICategory, group?: any) => {
onCategorySelect(category, group)
}
const handleFetchProducts = async (
category: PartsIndexCategory | PartsAPICategory,
group?: any,
fetchAll: boolean = false
) => {
const fetchKey = group ? `${category.id}_${group.id}` : category.id
setFetchingCategories(prev => new Set(prev).add(fetchKey))
try {
await fetchCategoryProducts({
variables: {
input: {
categoryId: category.id,
categoryName: category.name,
categoryType: type.toUpperCase(),
groupId: group?.id,
groupName: group?.name,
fetchAll,
limit: fetchAll ? 1000 : 100
}
}
})
} catch (error) {
console.error('Fetch error:', error)
} finally {
setFetchingCategories(prev => {
const newSet = new Set(prev)
newSet.delete(fetchKey)
return newSet
})
}
}
if (!categories || categories.length === 0) {
return (
<Card>
<CardContent className="p-8">
<div className="text-center text-gray-500">
<Package className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>Категории не найдены</p>
</div>
</CardContent>
</Card>
)
}
if (type === 'partsindex') {
const partsIndexCategories = categories as PartsIndexCategory[]
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{partsIndexCategories.map((category) => (
<Card key={category.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="space-y-3">
{/* Заголовок категории */}
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-blue-50 rounded-lg flex items-center justify-center overflow-hidden">
{category.image ? (
<img
src={category.image}
alt={category.name}
className="w-full h-full object-cover"
/>
) : (
<Package className="h-6 w-6 text-blue-600" />
)}
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900">{category.name}</h3>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{category.groups?.length || 0} групп
</Badge>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => toggleCategory(category.id)}
className="text-gray-400 hover:text-gray-600"
>
<ChevronRight
className={`h-4 w-4 transition-transform ${
expandedCategories.has(category.id) ? 'rotate-90' : ''
}`}
/>
</Button>
</div>
{/* Группы категории */}
{expandedCategories.has(category.id) && category.groups && (
<div className="space-y-2 mt-3 border-t pt-3">
{category.groups.map((group) => (
<div key={group.id} className="space-y-2">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleCategoryClick(category, group)}
className="flex-1 justify-start text-left hover:bg-blue-50"
>
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-gray-100 rounded flex items-center justify-center overflow-hidden">
{group.image ? (
<img
src={group.image}
alt={group.name}
className="w-full h-full object-cover"
/>
) : (
<FolderOpen className="h-3 w-3 text-gray-500" />
)}
</div>
<span className="text-sm text-gray-700">{group.name}</span>
{group.entityNames && (
<Badge variant="outline" className="text-xs ml-auto">
{group.entityNames.length} товаров
</Badge>
)}
</div>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleFetchProducts(category, group, true)}
disabled={fetchingCategories.has(`${category.id}_${group.id}`)}
className="px-2"
title="Сохранить все товары группы"
>
{fetchingCategories.has(`${category.id}_${group.id}`) ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Download className="h-3 w-3" />
)}
</Button>
</div>
{/* Подгруппы */}
{group.subgroups && group.subgroups.length > 0 && (
<div className="ml-6 space-y-1">
{group.subgroups.slice(0, 3).map((subgroup) => (
<Button
key={subgroup.id}
variant="ghost"
size="sm"
onClick={() => handleCategoryClick(category, subgroup)}
className="w-full justify-start text-left text-xs hover:bg-blue-50"
>
<div className="flex items-center gap-2">
<Layers className="h-3 w-3 text-gray-400" />
<span className="text-gray-600">{subgroup.name}</span>
{subgroup.entityNames && (
<Badge variant="outline" className="text-xs ml-auto">
{subgroup.entityNames.length}
</Badge>
)}
</div>
</Button>
))}
{group.subgroups.length > 3 && (
<div className="text-xs text-gray-500 ml-6">
и ещё {group.subgroups.length - 3} подгрупп...
</div>
)}
</div>
)}
</div>
))}
</div>
)}
{/* Кнопки действий */}
<div className="space-y-2 mt-3">
<Button
variant="outline"
size="sm"
onClick={() => handleCategoryClick(category)}
className="w-full"
>
<Package className="h-4 w-4 mr-2" />
Просмотреть товары
</Button>
<Button
variant="default"
size="sm"
onClick={() => handleFetchProducts(category, null, true)}
disabled={fetchingCategories.has(category.id)}
className="w-full"
>
{fetchingCategories.has(category.id) ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Сохранить все товары
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)
}
// PartsAPI categories (tree structure)
const partsAPICategories = categories as PartsAPICategory[]
const renderPartsAPICategory = (category: PartsAPICategory, level: number = 0) => (
<div key={category.id} className={`${level > 0 ? 'ml-4' : ''}`}>
<Card className="mb-2 hover:shadow-md transition-shadow">
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-green-50 rounded flex items-center justify-center">
<Package className="h-4 w-4 text-green-600" />
</div>
<div>
<h4 className="font-medium text-gray-900">{category.name}</h4>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
Уровень {category.level}
</Badge>
{category.children && category.children.length > 0 && (
<Badge variant="secondary" className="text-xs">
{category.children.length} подкатегорий
</Badge>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleCategoryClick(category)}
>
Просмотреть
</Button>
<Button
variant="default"
size="sm"
onClick={() => handleFetchProducts(category, null, true)}
disabled={fetchingCategories.has(category.id)}
title="Сохранить все товары категории"
>
{fetchingCategories.has(category.id) ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Download className="h-3 w-3" />
)}
</Button>
{category.children && category.children.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => toggleCategory(category.id)}
>
<ChevronRight
className={`h-4 w-4 transition-transform ${
expandedCategories.has(category.id) ? 'rotate-90' : ''
}`}
/>
</Button>
)}
</div>
</div>
</CardContent>
</Card>
{/* Подкатегории */}
{expandedCategories.has(category.id) && category.children && (
<div className="ml-4 mt-2">
{category.children.map((child) => renderPartsAPICategory(child, level + 1))}
</div>
)}
</div>
)
return (
<div className="space-y-2">
{partsAPICategories.map((category) => renderPartsAPICategory(category))}
</div>
)
}

View File

@ -0,0 +1,538 @@
'use client'
import { useState, useEffect } from 'react'
import { useQuery } from '@apollo/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs'
import {
Package,
Search,
Loader2,
AlertCircle,
Eye,
Filter,
Grid,
List
} from 'lucide-react'
import { GET_PARTSINDEX_CATALOG_ENTITIES, GET_PARTSAPI_ARTICLES, GET_CATEGORY_PRODUCTS } from '@/lib/graphql/queries'
interface PartsIndexCategory {
id: string
name: string
image?: string
groups?: Array<{
id: string
name: string
image?: string
subgroups?: Array<{
id: string
name: string
image?: string
entityNames?: Array<{
id: string
name: string
}>
}>
entityNames?: Array<{
id: string
name: string
}>
}>
}
interface PartsAPICategory {
id: string
name: string
level: number
parentId?: string
children?: PartsAPICategory[]
}
interface KrajaCategoryItemsProps {
category: PartsIndexCategory | PartsAPICategory
group?: any
categoryType: 'partsindex' | 'partsapi'
isViewingSavedData?: boolean
}
interface PartsIndexEntity {
id: string
name: string
image?: string
brand?: string
description?: string
price?: number
}
interface PartsAPIArticle {
supBrand: string
supId: number
productGroup: string
ptId: number
artSupBrand: string
artArticleNr: string
artId: string
}
export const KrajaCategoryItems = ({ category, group, categoryType, isViewingSavedData = false }: KrajaCategoryItemsProps) => {
const [searchQuery, setSearchQuery] = useState('')
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = isViewingSavedData ? 100 : 20
// Для PartsIndex
const {
data: partsIndexData,
loading: partsIndexLoading,
error: partsIndexError,
refetch: refetchPartsIndex
} = useQuery(GET_PARTSINDEX_CATALOG_ENTITIES, {
variables: {
catalogId: categoryType === 'partsindex' ? category.id : undefined,
groupId: group?.id || undefined,
lang: 'ru',
limit: itemsPerPage,
page: currentPage,
q: searchQuery || undefined
},
skip: categoryType !== 'partsindex' || !category.id,
errorPolicy: 'all'
})
// Для PartsAPI - используем strId (нужно преобразовать id в число)
const {
data: partsAPIData,
loading: partsAPILoading,
error: partsAPIError,
refetch: refetchPartsAPI
} = useQuery(GET_PARTSAPI_ARTICLES, {
variables: {
strId: categoryType === 'partsapi' ? parseInt(category.id) : undefined,
carId: 9877,
carType: 'PC'
},
skip: categoryType !== 'partsapi' || !category.id || isViewingSavedData,
errorPolicy: 'all'
})
// Для просмотра сохраненных данных
const {
data: savedData,
loading: savedLoading,
error: savedError,
refetch: refetchSaved
} = useQuery(GET_CATEGORY_PRODUCTS, {
variables: {
categoryId: category.id,
categoryType: categoryType.toUpperCase(),
search: searchQuery || undefined,
limit: itemsPerPage,
offset: (currentPage - 1) * itemsPerPage
},
skip: !isViewingSavedData,
errorPolicy: 'all'
})
// Обновляем поиск с задержкой
useEffect(() => {
const timeoutId = setTimeout(() => {
if (isViewingSavedData) {
refetchSaved()
} else if (categoryType === 'partsindex') {
refetchPartsIndex()
} else {
refetchPartsAPI()
}
}, 500)
return () => clearTimeout(timeoutId)
}, [searchQuery, categoryType, isViewingSavedData, refetchPartsIndex, refetchPartsAPI, refetchSaved])
const isLoading = isViewingSavedData
? savedLoading
: (categoryType === 'partsindex' ? partsIndexLoading : partsAPILoading)
const error = isViewingSavedData
? savedError
: (categoryType === 'partsindex' ? partsIndexError : partsAPIError)
const items = isViewingSavedData
? savedData?.getCategoryProducts?.products || []
: (categoryType === 'partsindex'
? partsIndexData?.partsIndexCatalogEntities?.list || []
: partsAPIData?.partsAPIArticles || [])
const renderPartsIndexItem = (item: PartsIndexEntity) => (
<Card key={item.id} className={`hover:shadow-md transition-shadow ${viewMode === 'list' ? 'mb-2' : ''}`}>
<CardContent className={`${viewMode === 'grid' ? 'p-4' : 'p-3'}`}>
{viewMode === 'grid' ? (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center overflow-hidden">
{item.image ? (
<img
src={item.image}
alt={item.name}
className="w-full h-full object-cover"
/>
) : (
<Package className="h-6 w-6 text-gray-400" />
)}
</div>
<div className="flex-1">
<h4 className="font-medium text-gray-900 line-clamp-2">{item.name}</h4>
{item.brand && (
<Badge variant="outline" className="text-xs mt-1">
{item.brand}
</Badge>
)}
</div>
</div>
{item.description && (
<p className="text-sm text-gray-600 line-clamp-2">{item.description}</p>
)}
{item.price && (
<div className="text-lg font-semibold text-blue-600">
{item.price.toLocaleString('ru-RU')}
</div>
)}
<Button variant="outline" size="sm" className="w-full">
<Eye className="h-4 w-4 mr-2" />
Подробнее
</Button>
</div>
) : (
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-gray-100 rounded flex items-center justify-center overflow-hidden">
{item.image ? (
<img
src={item.image}
alt={item.name}
className="w-full h-full object-cover"
/>
) : (
<Package className="h-5 w-5 text-gray-400" />
)}
</div>
<div className="flex-1">
<h4 className="font-medium text-gray-900">{item.name}</h4>
<div className="flex items-center gap-2 mt-1">
{item.brand && (
<Badge variant="outline" className="text-xs">
{item.brand}
</Badge>
)}
{item.price && (
<span className="text-sm font-semibold text-blue-600">
{item.price.toLocaleString('ru-RU')}
</span>
)}
</div>
</div>
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-2" />
Подробнее
</Button>
</div>
)}
</CardContent>
</Card>
)
const renderSavedItem = (item: any) => (
<Card key={item.id} className={`hover:shadow-md transition-shadow ${viewMode === 'list' ? 'mb-2' : ''}`}>
<CardContent className={`${viewMode === 'grid' ? 'p-4' : 'p-3'}`}>
{viewMode === 'grid' ? (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center overflow-hidden">
{item.image_url ? (
<img
src={item.image_url}
alt={item.name}
className="w-full h-full object-cover"
/>
) : (
<Package className="h-6 w-6 text-gray-400" />
)}
</div>
<div className="flex-1">
<h4 className="font-medium text-gray-900 line-clamp-2">{item.name}</h4>
{item.brand && (
<Badge variant="outline" className="text-xs mt-1">
{item.brand}
</Badge>
)}
</div>
</div>
{item.description && (
<p className="text-sm text-gray-600 line-clamp-2">{item.description}</p>
)}
{item.price && (
<div className="text-lg font-semibold text-blue-600">
{parseFloat(item.price).toLocaleString('ru-RU')}
</div>
)}
<div className="text-xs text-gray-500">
Сохранено: {new Date(item.created_at).toLocaleDateString('ru-RU')}
</div>
<Button variant="outline" size="sm" className="w-full">
<Eye className="h-4 w-4 mr-2" />
Подробнее
</Button>
</div>
) : (
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-gray-100 rounded flex items-center justify-center overflow-hidden">
{item.image_url ? (
<img
src={item.image_url}
alt={item.name}
className="w-full h-full object-cover"
/>
) : (
<Package className="h-5 w-5 text-gray-400" />
)}
</div>
<div className="flex-1">
<h4 className="font-medium text-gray-900">{item.name}</h4>
<div className="flex items-center gap-2 mt-1">
{item.brand && (
<Badge variant="outline" className="text-xs">
{item.brand}
</Badge>
)}
{item.price && (
<span className="text-sm font-semibold text-blue-600">
{parseFloat(item.price).toLocaleString('ru-RU')}
</span>
)}
<span className="text-xs text-gray-500">
{new Date(item.created_at).toLocaleDateString('ru-RU')}
</span>
</div>
</div>
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-2" />
Подробнее
</Button>
</div>
)}
</CardContent>
</Card>
)
const renderPartsAPIItem = (item: PartsAPIArticle, index: number) => (
<Card key={`${item.artId}-${index}`} className={`hover:shadow-md transition-shadow ${viewMode === 'list' ? 'mb-2' : ''}`}>
<CardContent className={`${viewMode === 'grid' ? 'p-4' : 'p-3'}`}>
{viewMode === 'grid' ? (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<Package className="h-6 w-6 text-green-600" />
</div>
<div className="flex-1">
<h4 className="font-medium text-gray-900">{item.artArticleNr}</h4>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{item.artSupBrand}
</Badge>
</div>
</div>
</div>
<div className="space-y-1">
<p className="text-sm text-gray-600">Группа: {item.productGroup}</p>
<p className="text-xs text-gray-500">Поставщик: {item.supBrand}</p>
<p className="text-xs text-gray-500">ID: {item.artId}</p>
</div>
<Button variant="outline" size="sm" className="w-full">
<Eye className="h-4 w-4 mr-2" />
Подробнее
</Button>
</div>
) : (
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-green-100 rounded flex items-center justify-center">
<Package className="h-5 w-5 text-green-600" />
</div>
<div className="flex-1">
<h4 className="font-medium text-gray-900">{item.artArticleNr}</h4>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{item.artSupBrand}
</Badge>
<span className="text-xs text-gray-500">{item.productGroup}</span>
</div>
</div>
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-2" />
Подробнее
</Button>
</div>
)}
</CardContent>
</Card>
)
return (
<div className="space-y-6">
{/* Панель управления */}
<Card>
<CardContent className="p-4">
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between">
{/* Поиск */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Поиск товаров..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{/* Элементы управления */}
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-sm">
{items.length} товаров
</Badge>
<div className="flex items-center border rounded-md">
<Button
variant={viewMode === 'grid' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('grid')}
className="rounded-r-none"
>
<Grid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
className="rounded-l-none"
>
<List className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Содержимое */}
{isLoading ? (
<Card>
<CardContent className="p-12">
<div className="flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-gray-400 mr-3" />
<span className="text-gray-600">Загрузка товаров...</span>
</div>
</CardContent>
</Card>
) : error ? (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-center text-red-600">
<AlertCircle className="h-6 w-6 mr-2" />
<span>Ошибка загрузки: {error.message}</span>
</div>
</CardContent>
</Card>
) : items.length === 0 ? (
<Card>
<CardContent className="p-12">
<div className="text-center text-gray-500">
<Package className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p className="text-lg mb-2">Товары не найдены</p>
<p className="text-sm">Попробуйте изменить критерии поиска</p>
</div>
</CardContent>
</Card>
) : (
<div className={
viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4'
: 'space-y-2'
}>
{isViewingSavedData
? items.map((item: any) => renderSavedItem(item))
: (categoryType === 'partsindex'
? items.map((item: PartsIndexEntity) => renderPartsIndexItem(item))
: items.map((item: PartsAPIArticle, index: number) => renderPartsAPIItem(item, index))
)
}
</div>
)}
{/* Пагинация и статистика */}
{isViewingSavedData && savedData?.getCategoryProducts && (
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600">
Показано {((currentPage - 1) * itemsPerPage) + 1}-{Math.min(currentPage * itemsPerPage, savedData.getCategoryProducts.total)} из {savedData.getCategoryProducts.total.toLocaleString()} сохраненных товаров
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
>
Предыдущая
</Button>
<span className="text-sm text-gray-600 px-2">
Страница {currentPage}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={items.length < itemsPerPage}
>
Следующая
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Пагинация для обычного просмотра */}
{!isViewingSavedData && items.length >= itemsPerPage && (
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
>
Предыдущая
</Button>
<span className="text-sm text-gray-600 px-4">
Страница {currentPage}
</span>
<Button
variant="outline"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={items.length < itemsPerPage}
>
Следующая
</Button>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@ -0,0 +1,172 @@
'use client'
import { useState } from 'react'
import { useQuery, useMutation } from '@apollo/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Database,
Trash2,
Eye,
RefreshCw,
AlertCircle
} from 'lucide-react'
import { GET_CATEGORY_TABLES, DELETE_CATEGORY_TABLE } from '@/lib/graphql/queries'
import toast from 'react-hot-toast'
interface CategoryTable {
tableName: string
categoryId: string
categoryType: string
recordCount: number
}
interface KrajaSavedTablesProps {
onViewTable: (categoryId: string, categoryType: string, tableName: string) => void
}
export const KrajaSavedTables = ({ onViewTable }: KrajaSavedTablesProps) => {
const { data, loading, error, refetch } = useQuery(GET_CATEGORY_TABLES, {
errorPolicy: 'all',
fetchPolicy: 'cache-and-network'
})
const [deleteCategoryTable] = useMutation(DELETE_CATEGORY_TABLE, {
onCompleted: () => {
toast.success('✅ Таблица удалена')
refetch()
},
onError: (error) => {
toast.error(`${error.message}`)
}
})
const tables: CategoryTable[] = data?.getCategoryTables || []
const handleDeleteTable = async (categoryId: string, categoryType: string) => {
if (!confirm('Вы уверены, что хотите удалить эту таблицу? Все данные будут потеряны.')) {
return
}
try {
await deleteCategoryTable({
variables: {
categoryId,
categoryType: categoryType.toUpperCase()
}
})
} catch (error) {
console.error('Delete error:', error)
}
}
const getCategoryTypeColor = (type: string) => {
return type.toLowerCase() === 'partsindex' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'
}
const getCategoryTypeLabel = (type: string) => {
return type.toLowerCase() === 'partsindex' ? 'PartsIndex' : 'PartsAPI'
}
if (loading) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-center">
<RefreshCw className="h-6 w-6 animate-spin text-gray-400 mr-2" />
<span className="text-gray-600">Загрузка сохраненных таблиц...</span>
</div>
</CardContent>
</Card>
)
}
if (error) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-center text-red-600">
<AlertCircle className="h-6 w-6 mr-2" />
<span>Ошибка загрузки: {error.message}</span>
</div>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5 text-blue-600" />
Сохраненные таблицы
</CardTitle>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Обновить
</Button>
</div>
</CardHeader>
<CardContent>
{tables.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Database className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p className="text-lg mb-2">Нет сохраненных таблиц</p>
<p className="text-sm">Используйте кнопки &quot;Сохранить&quot; в категориях для создания таблиц</p>
</div>
) : (
<div className="space-y-3">
{tables.map((table) => (
<div
key={table.tableName}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
<Database className="h-5 w-5 text-gray-600" />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium text-gray-900">{table.tableName}</h4>
<Badge className={getCategoryTypeColor(table.categoryType)}>
{getCategoryTypeLabel(table.categoryType)}
</Badge>
</div>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span>ID: {table.categoryId}</span>
<Badge variant="secondary" className="text-xs">
{table.recordCount.toLocaleString()} записей
</Badge>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onViewTable(table.categoryId, table.categoryType, table.tableName)}
>
<Eye className="h-4 w-4 mr-2" />
Просмотреть
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteTable(table.categoryId, table.categoryType)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,381 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useQuery } from '@apollo/client'
import { GET_PARTSINDEX_CATEGORIES } from '@/lib/graphql/queries'
import { Loader2, Upload, X, Image as ImageIcon, Folder, FolderOpen } from 'lucide-react'
interface PartsIndexCategory {
id: string
name: string
image?: string
groups?: PartsIndexGroup[]
}
interface PartsIndexGroup {
id: string
name: string
image?: string
subgroups?: PartsIndexGroup[]
}
interface NavigationCategory {
id?: string
partsIndexCatalogId: string
partsIndexGroupId?: string
icon?: string
isHidden: boolean
sortOrder: number
}
interface NavigationCategoryFormProps {
category?: NavigationCategory | null
onSubmit: (data: any) => void
onCancel: () => void
isLoading?: boolean
}
export default function NavigationCategoryForm({
category,
onSubmit,
onCancel,
isLoading = false
}: NavigationCategoryFormProps) {
const [formData, setFormData] = useState({
partsIndexCatalogId: '',
partsIndexGroupId: '',
icon: '',
isHidden: false,
sortOrder: 0
})
const [selectedCatalog, setSelectedCatalog] = useState<PartsIndexCategory | null>(null)
const [selectedGroup, setSelectedGroup] = useState<PartsIndexGroup | null>(null)
// Загрузка категорий PartsIndex
const { data: categoriesData, loading: categoriesLoading, error: categoriesError } = useQuery(
GET_PARTSINDEX_CATEGORIES,
{
variables: { lang: 'ru' },
errorPolicy: 'all'
}
)
const categories = categoriesData?.partsIndexCategoriesWithGroups || []
// Заполнение формы при редактировании
useEffect(() => {
if (category) {
setFormData({
partsIndexCatalogId: category.partsIndexCatalogId || '',
partsIndexGroupId: category.partsIndexGroupId || '',
icon: category.icon || '',
isHidden: category.isHidden || false,
sortOrder: category.sortOrder || 0
})
// Находим выбранный каталог и группу
const catalog = categories.find(c => c.id === category.partsIndexCatalogId)
if (catalog) {
setSelectedCatalog(catalog)
if (category.partsIndexGroupId && catalog.groups) {
const group = findGroupById(catalog.groups, category.partsIndexGroupId)
setSelectedGroup(group || null)
}
}
}
}, [category, categories])
// Рекурсивный поиск группы по ID
const findGroupById = (groups: PartsIndexGroup[], groupId: string): PartsIndexGroup | null => {
for (const group of groups) {
if (group.id === groupId) {
return group
}
if (group.subgroups && group.subgroups.length > 0) {
const found = findGroupById(group.subgroups, groupId)
if (found) return found
}
}
return null
}
// Получение всех групп из каталога (включая подгруппы)
const getAllGroups = (groups: PartsIndexGroup[], level = 0): Array<PartsIndexGroup & { level: number }> => {
const result: Array<PartsIndexGroup & { level: number }> = []
groups.forEach(group => {
result.push({ ...group, level })
if (group.subgroups && group.subgroups.length > 0) {
result.push(...getAllGroups(group.subgroups, level + 1))
}
})
return result
}
const handleInputChange = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }))
}
const handleCatalogSelect = (catalogId: string) => {
const catalog = categories.find(c => c.id === catalogId)
setSelectedCatalog(catalog || null)
setSelectedGroup(null)
handleInputChange('partsIndexCatalogId', catalogId)
handleInputChange('partsIndexGroupId', '')
}
const handleGroupSelect = (groupId: string) => {
if (groupId === '__CATALOG_ROOT__') {
setSelectedGroup(null)
handleInputChange('partsIndexGroupId', '')
} else if (selectedCatalog?.groups) {
const group = findGroupById(selectedCatalog.groups, groupId)
setSelectedGroup(group || null)
handleInputChange('partsIndexGroupId', groupId)
}
}
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
const reader = new FileReader()
reader.onload = (e) => {
const result = e.target?.result as string
handleInputChange('icon', result)
}
reader.readAsDataURL(file)
}
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!formData.partsIndexCatalogId) {
alert('Выберите каталог')
return
}
onSubmit(formData)
}
const getDisplayName = () => {
if (selectedGroup) {
return `${selectedCatalog?.name}${selectedGroup.name}`
}
return selectedCatalog?.name || 'Выберите категорию'
}
if (categoriesLoading) {
return (
<Card>
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
<span className="ml-2">Загрузка категорий PartsIndex...</span>
</CardContent>
</Card>
)
}
if (categoriesError) {
return (
<Card>
<CardContent className="py-8">
<div className="text-center text-red-600">
Ошибка загрузки категорий PartsIndex: {categoriesError.message}
</div>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>
{category ? 'Редактировать иконку категории' : 'Добавить иконку для категории'}
</CardTitle>
<p className="text-sm text-gray-600">
Выберите категорию из PartsIndex и загрузите иконку для отображения в навигации сайта
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Выбор каталога */}
<div>
<Label htmlFor="catalog">Каталог PartsIndex</Label>
<Select value={formData.partsIndexCatalogId} onValueChange={handleCatalogSelect}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Выберите каталог из PartsIndex" />
</SelectTrigger>
<SelectContent>
{categories.map((catalog) => (
<SelectItem key={catalog.id} value={catalog.id}>
<div className="flex items-center gap-2">
<Folder className="h-4 w-4 text-blue-600" />
{catalog.name}
{catalog.groups && (
<Badge variant="secondary" className="ml-2">
{catalog.groups.length} групп
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Выбор группы (если есть группы в каталоге) */}
{selectedCatalog?.groups && selectedCatalog.groups.length > 0 && (
<div>
<Label htmlFor="group">Группа (необязательно)</Label>
<p className="text-xs text-gray-500 mb-2">
Оставьте пустым для добавления иконки всему каталогу
</p>
<Select value={formData.partsIndexGroupId || '__CATALOG_ROOT__'} onValueChange={handleGroupSelect}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Выберите группу или оставьте пустым" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__CATALOG_ROOT__">
<div className="flex items-center gap-2">
<FolderOpen className="h-4 w-4 text-gray-400" />
Весь каталог &quot;{selectedCatalog.name}&quot;
</div>
</SelectItem>
{getAllGroups(selectedCatalog.groups).map((group) => (
<SelectItem key={group.id} value={group.id}>
<div className="flex items-center gap-2" style={{ paddingLeft: `${group.level * 16}px` }}>
<Folder className="h-4 w-4 text-orange-600" />
{group.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Предварительный просмотр выбранной категории */}
{formData.partsIndexCatalogId && (
<div className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center gap-2 text-sm font-medium">
<ImageIcon className="h-4 w-4 text-blue-600" />
Выбранная категория:
</div>
<div className="mt-1 text-sm text-gray-600">
{getDisplayName()}
</div>
</div>
)}
{/* Загрузка иконки */}
<div>
<Label htmlFor="icon">Иконка категории</Label>
<p className="text-xs text-gray-500 mb-2">
Небольшая иконка для отображения в навигационном меню (рекомендуется 32x32 пикселя)
</p>
<div className="space-y-2">
{formData.icon && (
<div className="relative inline-block">
<img
src={formData.icon}
alt="Превью иконки"
className="w-16 h-16 object-cover rounded-lg border"
/>
<Button
type="button"
variant="destructive"
size="sm"
className="absolute -top-2 -right-2 h-6 w-6 p-0"
onClick={() => handleInputChange('icon', '')}
>
<X className="h-3 w-3" />
</Button>
</div>
)}
<div className="flex items-center gap-2">
<Input
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
id="icon-upload"
/>
<Button
type="button"
variant="outline"
onClick={() => document.getElementById('icon-upload')?.click()}
>
<Upload className="h-4 w-4 mr-2" />
Загрузить иконку
</Button>
</div>
</div>
</div>
{/* Настройки отображения */}
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Switch
id="isHidden"
checked={formData.isHidden}
onCheckedChange={(checked) => handleInputChange('isHidden', checked)}
/>
<Label htmlFor="isHidden" className="text-sm font-medium">
Скрыть категорию в навигации
</Label>
</div>
<div>
<Label htmlFor="sortOrder">Порядок сортировки</Label>
<Input
type="number"
id="sortOrder"
value={formData.sortOrder}
onChange={(e) => handleInputChange('sortOrder', parseInt(e.target.value) || 0)}
className="mt-1"
placeholder="0"
/>
<p className="text-xs text-gray-500 mt-1">
Меньшее число = выше в списке
</p>
</div>
</div>
{/* Кнопки */}
<div className="flex gap-2 pt-4">
<Button
type="submit"
disabled={isLoading || !formData.partsIndexCatalogId}
className="flex-1"
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
{category ? 'Сохранить изменения' : 'Добавить иконку'}
</Button>
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isLoading}
>
Отмена
</Button>
</div>
</form>
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,377 @@
'use client'
import { useState } from 'react'
import { useQuery, useMutation } from '@apollo/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
GET_NAVIGATION_CATEGORIES,
GET_PARTSINDEX_CATEGORIES
} from '@/lib/graphql/queries'
import {
CREATE_NAVIGATION_CATEGORY,
UPDATE_NAVIGATION_CATEGORY,
DELETE_NAVIGATION_CATEGORY
} from '@/lib/graphql/mutations'
import { Loader2, Plus, Edit, Trash2, Image as ImageIcon, Folder, Settings, Eye, EyeOff } from 'lucide-react'
import NavigationCategoryForm from './NavigationCategoryForm'
interface NavigationCategory {
id: string
partsIndexCatalogId: string
partsIndexGroupId?: string
icon?: string
isHidden: boolean
sortOrder: number
name: string
catalogName: string
groupName?: string
}
interface PartsIndexCategory {
id: string
name: string
image?: string
groups?: PartsIndexGroup[]
}
interface PartsIndexGroup {
id: string
name: string
image?: string
subgroups?: PartsIndexGroup[]
}
export default function NavigationCategoryTree() {
const [editingCategory, setEditingCategory] = useState<NavigationCategory | null>(null)
const [showForm, setShowForm] = useState(false)
// Загрузка навигационных категорий (с иконками)
const {
data: navigationData,
loading: navigationLoading,
error: navigationError,
refetch: refetchNavigation
} = useQuery(GET_NAVIGATION_CATEGORIES, {
errorPolicy: 'all'
})
// Загрузка категорий PartsIndex
const {
data: partsIndexData,
loading: partsIndexLoading,
error: partsIndexError
} = useQuery(GET_PARTSINDEX_CATEGORIES, {
variables: { lang: 'ru' },
errorPolicy: 'all'
})
// Мутации
const [createCategory, { loading: creating }] = useMutation(CREATE_NAVIGATION_CATEGORY, {
onCompleted: () => {
refetchNavigation()
handleCloseForm()
},
onError: (error) => {
console.error('Ошибка создания категории:', error)
alert('Не удалось создать иконку для категории')
}
})
const [updateCategory, { loading: updating }] = useMutation(UPDATE_NAVIGATION_CATEGORY, {
onCompleted: () => {
refetchNavigation()
handleCloseForm()
},
onError: (error) => {
console.error('Ошибка обновления категории:', error)
alert('Не удалось обновить иконку категории')
}
})
const [deleteCategory, { loading: deleting }] = useMutation(DELETE_NAVIGATION_CATEGORY, {
onCompleted: () => {
refetchNavigation()
},
onError: (error) => {
console.error('Ошибка удаления категории:', error)
alert('Не удалось удалить иконку категории')
}
})
const navigationCategories = navigationData?.navigationCategories || []
const partsIndexCategories = partsIndexData?.partsIndexCategoriesWithGroups || []
const handleSubmit = async (formData: any) => {
try {
if (editingCategory) {
await updateCategory({
variables: {
id: editingCategory.id,
input: formData
}
})
} else {
await createCategory({
variables: {
input: formData
}
})
}
} catch (error) {
console.error('Ошибка сохранения:', error)
}
}
const handleEdit = (category: NavigationCategory) => {
setEditingCategory(category)
setShowForm(true)
}
const handleDelete = async (category: NavigationCategory) => {
if (confirm(`Удалить иконку для категории "${category.name}"?`)) {
await deleteCategory({
variables: { id: category.id }
})
}
}
const handleCloseForm = () => {
setEditingCategory(null)
setShowForm(false)
}
// Функция для получения полного пути категории
const getCategoryPath = (catalogId: string, groupId?: string) => {
const catalog = partsIndexCategories.find(c => c.id === catalogId)
if (!catalog) return 'Неизвестная категория'
if (!groupId) return catalog.name
// Рекурсивный поиск группы
const findGroup = (groups: PartsIndexGroup[]): PartsIndexGroup | null => {
for (const group of groups) {
if (group.id === groupId) return group
if (group.subgroups) {
const found = findGroup(group.subgroups)
if (found) return found
}
}
return null
}
const group = catalog.groups ? findGroup(catalog.groups) : null
return group ? `${catalog.name}${group.name}` : catalog.name
}
// Проверка есть ли иконка для категории
const hasIcon = (catalogId: string, groupId?: string) => {
return navigationCategories.some(nav =>
nav.partsIndexCatalogId === catalogId &&
nav.partsIndexGroupId === groupId
)
}
// Получение иконки для категории
const getIcon = (catalogId: string, groupId?: string) => {
return navigationCategories.find(nav =>
nav.partsIndexCatalogId === catalogId &&
nav.partsIndexGroupId === groupId
)
}
if (showForm) {
return (
<NavigationCategoryForm
category={editingCategory}
onSubmit={handleSubmit}
onCancel={handleCloseForm}
isLoading={creating || updating}
/>
)
}
if (navigationLoading || partsIndexLoading) {
return (
<Card>
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
<span className="ml-2">Загрузка категорий...</span>
</CardContent>
</Card>
)
}
if (navigationError || partsIndexError) {
return (
<Alert variant="destructive">
<AlertDescription>
Ошибка загрузки категорий: {navigationError?.message || partsIndexError?.message}
</AlertDescription>
</Alert>
)
}
return (
<div className="space-y-6">
{/* Заголовок */}
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold">Иконки навигации</h2>
<p className="text-gray-600">
Привязка иконок к категориям PartsIndex для отображения в навигации сайта
</p>
</div>
<Button onClick={() => setShowForm(true)}>
<Plus className="h-4 w-4 mr-2" />
Добавить иконку
</Button>
</div>
{/* Статистика */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<Folder className="h-5 w-5 text-blue-600" />
<div>
<div className="text-sm text-gray-600">Каталогов PartsIndex</div>
<div className="text-2xl font-bold">{partsIndexCategories.length}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<ImageIcon className="h-5 w-5 text-green-600" />
<div>
<div className="text-sm text-gray-600">С иконками</div>
<div className="text-2xl font-bold">{navigationCategories.length}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-orange-600" />
<div>
<div className="text-sm text-gray-600">Активных</div>
<div className="text-2xl font-bold">
{navigationCategories.filter(cat => !cat.isHidden).length}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Список категорий с иконками */}
{navigationCategories.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Категории с иконками</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{navigationCategories
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((navCategory) => (
<div
key={navCategory.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50"
>
<div className="flex items-center gap-3">
{/* Иконка */}
<div className="w-12 h-12 border rounded-lg flex items-center justify-center overflow-hidden">
{navCategory.icon ? (
<img
src={navCategory.icon}
alt={navCategory.name}
className="w-full h-full object-cover"
/>
) : (
<ImageIcon className="h-6 w-6 text-gray-400" />
)}
</div>
{/* Информация */}
<div>
<div className="font-medium">{navCategory.name}</div>
<div className="text-sm text-gray-600">
{getCategoryPath(navCategory.partsIndexCatalogId, navCategory.partsIndexGroupId)}
</div>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
Сортировка: {navCategory.sortOrder}
</Badge>
{navCategory.isHidden && (
<Badge variant="destructive" className="text-xs">
<EyeOff className="h-3 w-3 mr-1" />
Скрыта
</Badge>
)}
{!navCategory.isHidden && (
<Badge variant="default" className="text-xs">
<Eye className="h-3 w-3 mr-1" />
Видима
</Badge>
)}
</div>
</div>
</div>
{/* Действия */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(navCategory)}
disabled={deleting}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(navCategory)}
disabled={deleting}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Инструкции */}
<Card>
<CardHeader>
<CardTitle>Как использовать</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-gray-600">
<p>
<strong>1. Выберите каталог:</strong> Из списка каталогов PartsIndex выберите тот, для которого хотите добавить иконку.
</p>
<p>
<strong>2. Выберите группу (необязательно):</strong> Если хотите добавить иконку для конкретной группы внутри каталога, выберите её. Иначе иконка будет применена ко всему каталогу.
</p>
<p>
<strong>3. Загрузите иконку:</strong> Выберите небольшое изображение (рекомендуется 32x32 пикселя) которое будет отображаться в навигации сайта.
</p>
<p>
<strong>4. Настройте отображение:</strong> Установите порядок сортировки и видимость категории в навигации.
</p>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@ -12,7 +12,13 @@ import {
Package,
UserCheck,
ShoppingCart,
Receipt
Receipt,
Palette,
Star,
Image,
BarChart3,
Shield,
Bot
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useAuth } from '@/components/providers/AuthProvider'
@ -27,11 +33,41 @@ const navigationItems = [
href: '/dashboard',
icon: Home,
},
{
title: 'ZZAP статистика',
href: '/dashboard/zzap',
icon: BarChart3,
},
{
title: 'Каталог',
href: '/dashboard/catalog',
icon: Package,
},
{
title: 'Кража',
href: '/dashboard/kraja',
icon: Shield,
},
{
title: 'Чат с ИИ',
href: '/dashboard/ai',
icon: Bot,
},
{
title: 'Навигация сайта',
href: '/dashboard/navigation',
icon: Star,
},
{
title: 'Товары главной',
href: '/dashboard/homepage-products',
icon: Star,
},
{
title: 'Баннеры героя',
href: '/dashboard/hero-banners',
icon: Image,
},
{
title: 'Заказы',
href: '/dashboard/orders',
@ -62,6 +98,11 @@ const navigationItems = [
href: '/dashboard/settings',
icon: Settings,
},
{
title: 'Тест стилей',
href: '/dashboard/test-styles',
icon: Palette,
},
]
export const Sidebar = ({ className }: SidebarProps) => {
@ -124,4 +165,4 @@ export const Sidebar = ({ className }: SidebarProps) => {
</div>
</div>
)
}
}

View File

@ -17,11 +17,16 @@ export const createToken = (payload: JWTPayload): string => {
// Верификация JWT токена
export const verifyToken = (token: string): JWTPayload | null => {
// Быстрый фильтр: клиентские токены и не-JWT (без двух точек) не проверяем
if (!token || token.startsWith('client_') || token.split('.').length !== 3) {
return null
}
try {
const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload
return decoded
} catch (error) {
console.error('Ошибка верификации токена:', error)
} catch (_error) {
// Токен выглядел как JWT, но не прошёл проверку — тихо возвращаем null без шума в логах
return null
}
}
@ -51,4 +56,4 @@ export const extractTokenFromHeaders = (headers: Headers): string | null => {
export const getUserFromToken = (token: string | null): JWTPayload | null => {
if (!token) return null
return verifyToken(token)
}
}

View File

@ -17,6 +17,7 @@ export const CREATE_PRODUCT = gql`
isVisible
applyDiscounts
stock
brand
createdAt
updatedAt
categories {
@ -72,6 +73,7 @@ export const UPDATE_PRODUCT = gql`
isVisible
applyDiscounts
stock
brand
createdAt
updatedAt
categories {
@ -135,6 +137,7 @@ export const CREATE_CATEGORY = gql`
seoTitle
seoDescription
image
icon
isHidden
includeSubcategoryProducts
parentId
@ -158,6 +161,7 @@ export const UPDATE_CATEGORY = gql`
seoTitle
seoDescription
image
icon
isHidden
includeSubcategoryProducts
parentId
@ -177,6 +181,49 @@ export const DELETE_CATEGORY = gql`
}
`
// Навигационные категории
export const CREATE_NAVIGATION_CATEGORY = gql`
mutation CreateNavigationCategory($input: NavigationCategoryInput!) {
createNavigationCategory(input: $input) {
id
partsIndexCatalogId
partsIndexGroupId
icon
isHidden
sortOrder
createdAt
updatedAt
name
catalogName
groupName
}
}
`
export const UPDATE_NAVIGATION_CATEGORY = gql`
mutation UpdateNavigationCategory($id: ID!, $input: NavigationCategoryInput!) {
updateNavigationCategory(id: $id, input: $input) {
id
partsIndexCatalogId
partsIndexGroupId
icon
isHidden
sortOrder
createdAt
updatedAt
name
catalogName
groupName
}
}
`
export const DELETE_NAVIGATION_CATEGORY = gql`
mutation DeleteNavigationCategory($id: ID!) {
deleteNavigationCategory(id: $id)
}
`
export const DELETE_PRODUCTS = gql`
mutation DeleteProducts($ids: [ID!]!) {
deleteProducts(ids: $ids) {
@ -193,6 +240,22 @@ export const UPDATE_PRODUCTS_VISIBILITY = gql`
}
`
export const MOVE_PRODUCTS_TO_CATEGORY = gql`
mutation MoveProductsToCategory($productIds: [ID!]!, $categoryId: ID!) {
moveProductsToCategory(productIds: $productIds, categoryId: $categoryId) {
count
movedProducts {
id
name
categories {
id
name
}
}
}
}
`
export const EXPORT_PRODUCTS = gql`
mutation ExportProducts($categoryId: String, $search: String, $format: String) {
exportProducts(categoryId: $categoryId, search: $search, format: $format) {
@ -1165,4 +1228,218 @@ export const GET_DELIVERY_OFFERS = gql`
}
}
}
`
// Daily Products mutations
export const CREATE_DAILY_PRODUCT = gql`
mutation CreateDailyProduct($input: DailyProductInput!) {
createDailyProduct(input: $input) {
id
productId
displayDate
discount
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const UPDATE_DAILY_PRODUCT = gql`
mutation UpdateDailyProduct($id: ID!, $input: DailyProductUpdateInput!) {
updateDailyProduct(id: $id, input: $input) {
id
productId
displayDate
discount
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const DELETE_DAILY_PRODUCT = gql`
mutation DeleteDailyProduct($id: ID!) {
deleteDailyProduct(id: $id)
}
`
export const CREATE_BEST_PRICE_PRODUCT = gql`
mutation CreateBestPriceProduct($input: BestPriceProductInput!) {
createBestPriceProduct(input: $input) {
id
productId
discount
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const UPDATE_BEST_PRICE_PRODUCT = gql`
mutation UpdateBestPriceProduct($id: ID!, $input: BestPriceProductInput!) {
updateBestPriceProduct(id: $id, input: $input) {
id
productId
discount
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const DELETE_BEST_PRICE_PRODUCT = gql`
mutation DeleteBestPriceProduct($id: ID!) {
deleteBestPriceProduct(id: $id)
}
`
export const CREATE_TOP_SALES_PRODUCT = gql`
mutation CreateTopSalesProduct($input: TopSalesProductInput!) {
createTopSalesProduct(input: $input) {
id
productId
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const UPDATE_TOP_SALES_PRODUCT = gql`
mutation UpdateTopSalesProduct($id: ID!, $input: TopSalesProductUpdateInput!) {
updateTopSalesProduct(id: $id, input: $input) {
id
productId
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const DELETE_TOP_SALES_PRODUCT = gql`
mutation DeleteTopSalesProduct($id: ID!) {
deleteTopSalesProduct(id: $id)
}
`
// Hero Banners mutations
export const CREATE_HERO_BANNER = gql`
mutation CreateHeroBanner($input: HeroBannerInput!) {
createHeroBanner(input: $input) {
id
title
subtitle
imageUrl
linkUrl
isActive
sortOrder
createdAt
updatedAt
}
}
`
export const UPDATE_HERO_BANNER = gql`
mutation UpdateHeroBanner($id: String!, $input: HeroBannerUpdateInput!) {
updateHeroBanner(id: $id, input: $input) {
id
title
subtitle
imageUrl
linkUrl
isActive
sortOrder
createdAt
updatedAt
}
}
`
export const DELETE_HERO_BANNER = gql`
mutation DeleteHeroBanner($id: String!) {
deleteHeroBanner(id: $id)
}
`

View File

@ -200,6 +200,169 @@ export const ADMIN_CHANGE_PASSWORD = gql`
}
`
// Daily Products queries
export const GET_DAILY_PRODUCTS = gql`
query GetDailyProducts($displayDate: String!) {
dailyProducts(displayDate: $displayDate) {
id
productId
displayDate
discount
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const GET_DAILY_PRODUCT = gql`
query GetDailyProduct($id: ID!) {
dailyProduct(id: $id) {
id
productId
displayDate
discount
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const GET_BEST_PRICE_PRODUCTS = gql`
query GetBestPriceProducts {
bestPriceProducts {
id
productId
discount
isActive
sortOrder
product {
id
name
slug
article
brand
retailPrice
wholesalePrice
images {
id
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const GET_BEST_PRICE_PRODUCT = gql`
query GetBestPriceProduct($id: ID!) {
bestPriceProduct(id: $id) {
id
productId
discount
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const GET_TOP_SALES_PRODUCTS = gql`
query GetTopSalesProducts {
topSalesProducts {
id
productId
isActive
sortOrder
product {
id
name
slug
article
brand
retailPrice
wholesalePrice
images {
id
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const GET_TOP_SALES_PRODUCT = gql`
query GetTopSalesProduct($id: ID!) {
topSalesProduct(id: $id) {
id
productId
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const UPLOAD_AVATAR = gql`
mutation UploadAvatar($file: String!) {
uploadAvatar(file: $file) {
@ -1238,4 +1401,235 @@ export const GET_PAYMENT = gql`
}
`
// Навигационные категории
export const GET_NAVIGATION_CATEGORIES = gql`
query GetNavigationCategories {
navigationCategories {
id
partsIndexCatalogId
partsIndexGroupId
icon
isHidden
sortOrder
createdAt
updatedAt
name
catalogName
groupName
}
}
`;
export const GET_NAVIGATION_CATEGORY = gql`
query GetNavigationCategory($id: ID!) {
navigationCategory(id: $id) {
id
partsIndexCatalogId
partsIndexGroupId
icon
isHidden
sortOrder
createdAt
updatedAt
name
catalogName
groupName
}
}
`;
// PartsIndex категории автотоваров
export const GET_PARTSINDEX_CATEGORIES = gql`
query GetPartsIndexCategories($lang: String) {
partsIndexCategoriesWithGroups(lang: $lang) {
id
name
image
groups {
id
name
image
subgroups {
id
name
image
entityNames {
id
name
}
}
entityNames {
id
name
}
}
}
}
`;
// PartsAPI категории
export const GET_PARTSAPI_CATEGORIES = gql`
query GetPartsAPICategories($carId: Int!, $carType: CarType) {
partsAPICategories(carId: $carId, carType: $carType) {
id
name
level
parentId
children {
id
name
level
parentId
children {
id
name
level
parentId
}
}
}
}
`;
// PartsIndex товары каталога
export const GET_PARTSINDEX_CATALOG_ENTITIES = gql`
query GetPartsIndexCatalogEntities(
$catalogId: String!
$groupId: String!
$lang: String
$limit: Int
$page: Int
$q: String
$engineId: String
$generationId: String
$params: String
) {
partsIndexCatalogEntities(
catalogId: $catalogId
groupId: $groupId
lang: $lang
limit: $limit
page: $page
q: $q
engineId: $engineId
generationId: $generationId
params: $params
) {
list {
id
name
image
brand
description
price
}
totalCount
page
limit
}
}
`;
// PartsAPI артикулы
export const GET_PARTSAPI_ARTICLES = gql`
query GetPartsAPIArticles($strId: Int!, $carId: Int!, $carType: CarType) {
partsAPIArticles(strId: $strId, carId: $carId, carType: $carType) {
supBrand
supId
productGroup
ptId
artSupBrand
artArticleNr
artId
}
}
`;
// Кража - мутации для работы с базой данных запчастей
export const FETCH_CATEGORY_PRODUCTS = gql`
mutation FetchCategoryProducts($input: FetchCategoryProductsInput!) {
fetchCategoryProducts(input: $input) {
success
message
insertedCount
tableName
}
}
`;
export const GET_CATEGORY_TABLES = gql`
query GetCategoryTables {
getCategoryTables {
tableName
categoryId
categoryType
recordCount
}
}
`;
export const DELETE_CATEGORY_TABLE = gql`
mutation DeleteCategoryTable($categoryId: String!, $categoryType: CategoryType!) {
deleteCategoryTable(categoryId: $categoryId, categoryType: $categoryType)
}
`;
export const GET_CATEGORY_PRODUCTS = gql`
query GetCategoryProducts($categoryId: String!, $categoryType: CategoryType!, $search: String, $limit: Int, $offset: Int) {
getCategoryProducts(categoryId: $categoryId, categoryType: $categoryType, search: $search, limit: $limit, offset: $offset) {
products {
id
external_id
name
brand
article
description
image_url
price
category_id
category_name
category_type
group_id
group_name
created_at
updated_at
}
total
}
}
`;
// Hero Banners queries
export const GET_HERO_BANNERS = gql`
query GetHeroBanners {
heroBanners {
id
title
subtitle
imageUrl
linkUrl
isActive
sortOrder
createdAt
updatedAt
}
}
`
export const GET_HERO_BANNER = gql`
query GetHeroBanner($id: String!) {
heroBanner(id: $id) {
id
title
subtitle
imageUrl
linkUrl
isActive
sortOrder
createdAt
updatedAt
}
}
`

View File

@ -10,6 +10,7 @@ import { autoEuroService } from '../autoeuro-service'
import { yooKassaService } from '../yookassa-service'
import { partsAPIService } from '../partsapi-service'
import { partsIndexService } from '../partsindex-service'
// Removed static import - will use dynamic import for server-only package
import { yandexDeliveryService, YandexPickupPoint, getAddressSuggestions } from '../yandex-delivery-service'
import { InvoiceService } from '../invoice-service'
import * as csvWriter from 'csv-writer'
@ -132,6 +133,15 @@ interface CategoryInput {
parentId?: string
}
// Интерфейсы для навигационных категорий
interface NavigationCategoryInput {
partsIndexCatalogId: string
partsIndexGroupId?: string
icon?: string
isHidden?: boolean
sortOrder?: number
}
interface ProductInput {
name: string
slug?: string
@ -146,6 +156,7 @@ interface ProductInput {
isVisible?: boolean
applyDiscounts?: boolean
stock?: number
brand: string
categoryIds?: string[]
}
@ -408,6 +419,62 @@ interface FavoriteInput {
image?: string
}
interface DailyProductInput {
productId: string
displayDate: string
discount?: number
isActive?: boolean
sortOrder?: number
}
interface DailyProductUpdateInput {
discount?: number
isActive?: boolean
sortOrder?: number
}
interface BestPriceProductInput {
productId: string
discount?: number
isActive?: boolean
sortOrder?: number
}
interface BestPriceProductUpdateInput {
discount?: number
isActive?: boolean
sortOrder?: number
}
interface TopSalesProductInput {
productId: string
isActive?: boolean
sortOrder?: number
}
interface TopSalesProductUpdateInput {
isActive?: boolean
sortOrder?: number
}
interface HeroBannerInput {
title: string
subtitle?: string
imageUrl: string
linkUrl?: string
isActive?: boolean
sortOrder?: number
}
interface HeroBannerUpdateInput {
title?: string
subtitle?: string
imageUrl?: string
linkUrl?: string
isActive?: boolean
sortOrder?: number
}
// Утилиты
const createSlug = (text: string): string => {
return text
@ -537,18 +604,30 @@ export const resolvers = {
})
},
relatedProducts: async (parent: { id: string }) => {
const product = await prisma.product.findUnique({
where: { id: parent.id },
include: { relatedProducts: { include: { images: { orderBy: { order: 'asc' } } } } }
})
return product?.relatedProducts || []
const product = await prisma.product.findUnique({
where: { id: parent.id },
include: {
products_RelatedProducts_A: { include: { images: { orderBy: { order: 'asc' } } } },
products_RelatedProducts_B: { include: { images: { orderBy: { order: 'asc' } } } }
}
})
// Объединяем связанные товары из обеих сторон связи
const relatedA = product?.products_RelatedProducts_A || []
const relatedB = product?.products_RelatedProducts_B || []
return [...relatedA, ...relatedB]
},
accessoryProducts: async (parent: { id: string }) => {
const product = await prisma.product.findUnique({
where: { id: parent.id },
include: { accessoryProducts: { include: { images: { orderBy: { order: 'asc' } } } } }
})
return product?.accessoryProducts || []
const product = await prisma.product.findUnique({
where: { id: parent.id },
include: {
products_AccessoryProducts_A: { include: { images: { orderBy: { order: 'asc' } } } },
products_AccessoryProducts_B: { include: { images: { orderBy: { order: 'asc' } } } }
}
})
// Объединяем аксессуары из обеих сторон связи
const accessoryA = product?.products_AccessoryProducts_A || []
const accessoryB = product?.products_AccessoryProducts_B || []
return [...accessoryA, ...accessoryB]
}
},
@ -817,8 +896,10 @@ export const resolvers = {
}
},
characteristics: { include: { characteristic: true } },
relatedProducts: { include: { images: { orderBy: { order: 'asc' } } } },
accessoryProducts: { include: { images: { orderBy: { order: 'asc' } } } }
products_RelatedProducts_A: { include: { images: { orderBy: { order: 'asc' } } } },
products_RelatedProducts_B: { include: { images: { orderBy: { order: 'asc' } } } },
products_AccessoryProducts_A: { include: { images: { orderBy: { order: 'asc' } } } },
products_AccessoryProducts_B: { include: { images: { orderBy: { order: 'asc' } } } }
}
})
} catch (error) {
@ -841,8 +922,10 @@ export const resolvers = {
}
},
characteristics: { include: { characteristic: true } },
relatedProducts: { include: { images: { orderBy: { order: 'asc' } } } },
accessoryProducts: { include: { images: { orderBy: { order: 'asc' } } } }
products_RelatedProducts_A: { include: { images: { orderBy: { order: 'asc' } } } },
products_RelatedProducts_B: { include: { images: { orderBy: { order: 'asc' } } } },
products_AccessoryProducts_A: { include: { images: { orderBy: { order: 'asc' } } } },
products_AccessoryProducts_B: { include: { images: { orderBy: { order: 'asc' } } } }
}
})
} catch (error) {
@ -1152,7 +1235,8 @@ export const resolvers = {
try {
const actualContext = context || getContext()
if (!actualContext.clientId) {
throw new Error('Клиент не авторизован')
// Для неавторизованных пользователей возвращаем пустой массив
return []
}
// Удаляем префикс client_ если он есть
@ -1678,9 +1762,36 @@ export const resolvers = {
laximoVehicleInfo: async (_: unknown, { catalogCode, vehicleId, ssd, localized }: { catalogCode: string; vehicleId: string; ssd?: string; localized: boolean }) => {
try {
return await laximoService.getVehicleInfo(catalogCode, vehicleId, ssd, localized)
console.log('🔍 GraphQL laximoVehicleInfo resolver - входные параметры:', {
catalogCode,
vehicleId,
ssd: ssd ? `${ssd.substring(0, 50)}...` : 'отсутствует',
localized,
ssdLength: ssd?.length
})
const result = await laximoService.getVehicleInfo(catalogCode, vehicleId, ssd, localized)
console.log('📋 GraphQL laximoVehicleInfo resolver - результат:', {
inputVehicleId: vehicleId,
returnedVehicleId: result?.vehicleid,
vehicleName: result?.name,
brand: result?.brand,
catalog: result?.catalog,
hasResult: !!result,
vehicleIdChanged: result?.vehicleid !== vehicleId
})
if (result && result.vehicleid !== vehicleId) {
console.log('🚨 BACKEND: Vehicle ID изменился!')
console.log(`📍 Запрошенный: ${vehicleId}`)
console.log(`📍 Полученный: ${result.vehicleid}`)
console.log(`📍 SSD: ${ssd?.substring(0, 50)}...`)
}
return result
} catch (error) {
console.error('Ошибка получения информации об автомобиле:', error)
console.error('Ошибка получения информации об автомобиле:', error)
return null
}
},
@ -1787,6 +1898,90 @@ export const resolvers = {
}
},
// Навигационные категории
navigationCategories: async () => {
try {
const categories = await prisma.navigationCategory.findMany({
where: { isHidden: false },
orderBy: { sortOrder: 'asc' }
})
// Получаем данные из PartsIndex для каждой категории
const categoriesWithData = await Promise.all(
categories.map(async (category) => {
try {
// Получаем каталоги PartsIndex
const catalogs = await partsIndexService.getCatalogs('ru')
const catalog = catalogs.find(c => c.id === category.partsIndexCatalogId)
let groupName: string | null = null
// Если есть groupId, получаем группы
if (category.partsIndexGroupId && catalog) {
const groups = await partsIndexService.getCatalogGroups(category.partsIndexCatalogId, 'ru')
const group = groups.find(g => g.id === category.partsIndexGroupId)
groupName = group?.name || null
}
return {
...category,
name: groupName || catalog?.name || 'Неизвестная категория',
catalogName: catalog?.name || 'Неизвестный каталог',
groupName
}
} catch (error) {
console.error('Ошибка получения данных PartsIndex для категории:', category.id, error)
return {
...category,
name: 'Ошибка загрузки',
catalogName: 'Ошибка загрузки',
groupName: null
}
}
})
)
return categoriesWithData
} catch (error) {
console.error('Ошибка получения навигационных категорий:', error)
return []
}
},
navigationCategory: async (_: unknown, { id }: { id: string }) => {
try {
const category = await prisma.navigationCategory.findUnique({
where: { id }
})
if (!category) {
throw new Error('Навигационная категория не найдена')
}
// Получаем данные из PartsIndex
const catalogs = await partsIndexService.getCatalogs('ru')
const catalog = catalogs.find(c => c.id === category.partsIndexCatalogId)
let groupName: string | null = null
if (category.partsIndexGroupId && catalog) {
const groups = await partsIndexService.getCatalogGroups(category.partsIndexCatalogId, 'ru')
const group = groups.find(g => g.id === category.partsIndexGroupId)
groupName = group?.name || null
}
return {
...category,
name: groupName || catalog?.name || 'Неизвестная категория',
catalogName: catalog?.name || 'Неизвестный каталог',
groupName
}
} catch (error) {
console.error('Ошибка получения навигационной категории:', error)
throw error
}
},
laximoUnits: async (_: unknown, { catalogCode, vehicleId, ssd, categoryId }: { catalogCode: string; vehicleId?: string; ssd?: string; categoryId?: string }) => {
try {
console.log('🔍 GraphQL Resolver - запрос узлов каталога:', {
@ -1797,7 +1992,79 @@ export const resolvers = {
ssdLength: ssd?.length
})
const result = await laximoService.getListUnits(catalogCode, vehicleId, ssd, categoryId)
let result: any[] = []
// Если есть categoryId, то мы ищем узлы в конкретной категории
if (categoryId) {
console.log('🔧 Поиск узлов в категории:', categoryId)
// ИСПРАВЛЕНИЕ: Разные каталоги поддерживают разные параметры для ListUnits
try {
console.log('🔧 Пробуем ListUnits с SSD для категории...')
result = await laximoService.getListUnits(catalogCode, vehicleId, ssd, categoryId)
console.log('✅ Получено узлов в категории:', result.length)
} catch (error: any) {
console.log('⚠️ Ошибка ListUnits с SSD:', error.message)
// Если ошибка E_INVALIDPARAMETER:ssd - значит данная категория/каталог не поддерживает SSD
if (error.message.includes('E_INVALIDPARAMETER:ssd')) {
console.log('🔧 Каталог/категория не поддерживает SSD, пробуем без SSD...')
try {
result = await laximoService.getListUnits(catalogCode, vehicleId, undefined, categoryId)
console.log('✅ Получено узлов в категории (без SSD):', result.length)
} catch (noSsdError: any) {
console.log('⚠️ Ошибка ListUnits без SSD:', noSsdError.message)
// Если и без SSD не работает, значит данная категория не содержит узлов
// Возвращаем пустой массив вместо ошибки
console.log('🔧 Категория не содержит узлов, возвращаем пустой результат')
result = []
}
} else {
// Для других ошибок также пробуем без SSD
try {
console.log('🔧 Пробуем ListUnits без SSD для обычной категории...')
result = await laximoService.getListUnits(catalogCode, vehicleId, undefined, categoryId)
console.log('✅ Получено узлов в категории (без SSD):', result.length)
} catch (noSsdError: any) {
console.log('⚠️ Ошибка ListUnits без SSD:', noSsdError.message)
result = []
}
}
}
} else {
// Если categoryId нет, получаем список всех категорий
console.log('🔧 Получаем список всех категорий...')
try {
result = await laximoService.getListCategories(catalogCode, vehicleId, ssd)
// Если получили категории, используем SSD из первой категории для получения узлов
if (result.length > 0 && result[0].ssd) {
console.log('🔧 Найден SSD в категориях, пробуем получить узлы...')
const categorySsd = result[0].ssd
console.log('🔑 SSD из категории:', categorySsd.substring(0, 30) + '...')
// Пробуем получить узлы для первой категории с найденным SSD
try {
const unitsResult = await laximoService.getListUnits(catalogCode, vehicleId, categorySsd, result[0].quickgroupid)
if (unitsResult.length > 0) {
console.log('✅ Получены узлы с SSD из категории:', unitsResult.length)
result = unitsResult
}
} catch (error: any) {
console.log('⚠️ Ошибка получения узлов с SSD из категории:', error.message)
}
}
} catch (error: any) {
console.log('⚠️ Ошибка ListCategories:', error.message)
// Пробуем без SSD
if (ssd) {
console.log('🔧 Пробуем ListCategories без SSD...')
result = await laximoService.getListCategories(catalogCode, vehicleId, undefined)
}
}
}
console.log('✅ GraphQL Resolver - получено узлов каталога:', result?.length || 0)
if (result && result.length > 0) {
@ -1819,24 +2086,52 @@ export const resolvers = {
laximoQuickDetail: async (_: unknown, { catalogCode, vehicleId, quickGroupId, ssd }: { catalogCode: string; vehicleId: string; quickGroupId: string; ssd: string }) => {
try {
console.log('🔍 Запрос деталей группы быстрого поиска:', {
catalogCode,
vehicleId,
quickGroupId,
console.log('🔍 Запрос деталей группы быстрого поиска - RAW PARAMS:', {
catalogCode: catalogCode,
catalogCodeType: typeof catalogCode,
catalogCodeLength: catalogCode?.length,
vehicleId: vehicleId,
vehicleIdType: typeof vehicleId,
vehicleIdLength: vehicleId?.length,
quickGroupId: quickGroupId,
quickGroupIdType: typeof quickGroupId,
quickGroupIdLength: quickGroupId?.length,
ssd: ssd ? `${ssd.substring(0, 30)}...` : 'отсутствует'
ssd: ssd ? `${ssd.substring(0, 50)}...` : 'отсутствует',
ssdType: typeof ssd,
ssdLength: ssd?.length
})
// Валидация параметров
// Валидация параметров с детальными логами
console.log('🔍 Проверка catalogCode:', { catalogCode, isEmpty: !catalogCode, isTrimEmpty: catalogCode?.trim() === '' })
if (!catalogCode || catalogCode.trim() === '') {
console.error('❌ Пустой catalogCode:', catalogCode)
throw new Error(`Пустой код каталога: "${catalogCode}"`)
}
console.log('🔍 Проверка vehicleId:', { vehicleId, isUndefined: vehicleId === undefined, isNull: vehicleId === null, isEmpty: vehicleId === '' })
if (vehicleId === undefined || vehicleId === null) {
console.error('❌ Пустой vehicleId:', vehicleId)
throw new Error(`Пустой ID автомобиля: "${vehicleId}"`)
}
console.log('🔍 Проверка quickGroupId:', { quickGroupId, isEmpty: !quickGroupId, isTrimEmpty: quickGroupId?.trim() === '' })
if (!quickGroupId || quickGroupId.trim() === '') {
console.error('❌ Пустой quickGroupId:', quickGroupId)
throw new Error(`Пустой ID группы: "${quickGroupId}"`)
}
return await laximoService.getListQuickDetail(catalogCode, vehicleId, quickGroupId, ssd)
console.log('🔍 Проверка ssd:', { ssd: ssd ? `${ssd.substring(0, 30)}...` : ssd, isEmpty: !ssd, isTrimEmpty: ssd?.trim() === '' })
if (!ssd || ssd.trim() === '') {
console.error('❌ Пустой ssd:', ssd)
throw new Error(`Пустой SSD: "${ssd}"`)
}
console.log('✅ Все параметры валидны, вызываем laximoService.getListQuickDetail')
const result = await laximoService.getListQuickDetail(catalogCode, vehicleId, quickGroupId, ssd)
console.log('✅ Результат от laximoService:', result ? 'получен' : 'null')
return result
} catch (error) {
console.error('Ошибка получения деталей группы быстрого поиска:', error)
console.error('Ошибка получения деталей группы быстрого поиска:', error)
throw error // Пробрасываем ошибку наверх
}
},
@ -1899,6 +2194,29 @@ export const resolvers = {
result.details.length
)
}
// Мапим данные для GraphQL схемы, добавляя отсутствующие поля
if (result) {
return {
...result,
details: result.details.map(detail => ({
detailid: null, // Полнотекстовый поиск не возвращает detailid
oem: detail.oem,
formattedoem: detail.oem, // Используем oem как formattedoem
name: detail.name,
brand: detail.brand || null,
description: detail.description || null,
codeonimage: null,
code: null,
note: null,
filter: null,
parttype: null,
price: null,
availability: null,
attributes: []
}))
}
}
return result
} catch (err) {
@ -1980,10 +2298,18 @@ export const resolvers = {
// Поиск товаров и предложений
searchProductOffers: async (_: unknown, {
articleNumber,
brand
brand,
cartItems = []
}: {
articleNumber: string;
brand: string;
cartItems?: Array<{
productId?: string;
offerKey?: string;
article: string;
brand: string;
quantity: number;
}>;
}, context: Context) => {
try {
// Проверяем входные параметры
@ -2006,6 +2332,18 @@ export const resolvers = {
const cleanBrand = brand.trim()
console.log('🔍 GraphQL Resolver - поиск предложений для товара:', { articleNumber: cleanArticleNumber, brand: cleanBrand })
console.log('🛒 Получено товаров в корзине:', cartItems.length)
// Функция для проверки, находится ли товар в корзине
const isItemInCart = (productId?: string, offerKey?: string, article?: string, brand?: string): boolean => {
return cartItems.some(cartItem => {
// Проверяем по разным комбинациям идентификаторов
if (productId && cartItem.productId === productId) return true;
if (offerKey && cartItem.offerKey === offerKey) return true;
if (article && brand && cartItem.article === article && cartItem.brand === brand) return true;
return false;
});
};
// 1. Поиск в нашей базе данных
const internalProducts = await prisma.product.findMany({
@ -2059,7 +2397,8 @@ export const resolvers = {
warehouseName: offer.warehouse_name || null,
rejects: offer.rejects || 0,
supplier: 'AutoEuro',
canPurchase: true
canPurchase: true,
isInCart: isItemInCart(undefined, offer.offer_key, offer.code, offer.brand)
}))
console.log('🎯 GraphQL Resolver - создано внешних предложений:', externalOffers.length)
@ -2073,7 +2412,7 @@ export const resolvers = {
console.log(`🌐 Найдено ${externalOffers.length} предложений в AutoEuro`)
console.log('📦 Первые 3 внешних предложения:', externalOffers.slice(0, 3))
// 3. Поиск в PartsIndex для получения дополнительных характеристик и изображений
// 3. Поиск в PartsIndex для получения дополнительных характеристик и изображений (может быть отключён)
let partsIndexData: any = null
try {
@ -2082,11 +2421,14 @@ export const resolvers = {
brand: cleanBrand
})
// Используем прямой поиск по артикулу и бренду
partsIndexData = await partsIndexService.searchEntityByCode(
cleanArticleNumber,
cleanBrand
)
// Используем прямой поиск по артикулу и бренду, только если сервис включён
const partsIndexEnabled = (process.env.PARTSINDEX_ENABLED === 'true') || false
if (partsIndexEnabled) {
partsIndexData = await partsIndexService.searchEntityByCode(
cleanArticleNumber,
cleanBrand
)
}
if (partsIndexData) {
console.log('✅ GraphQL Resolver - найден товар в PartsIndex:', {
@ -2170,7 +2512,9 @@ export const resolvers = {
deliveryDays: 1,
available: (product.stock || 0) > 0,
rating: 4.8,
supplier: 'Protek'
supplier: 'Protek',
canPurchase: true,
isInCart: isItemInCart(product.id, undefined, cleanArticleNumber, cleanBrand)
}))
// 6. Определяем название товара и собираем данные
@ -2232,6 +2576,24 @@ export const resolvers = {
productName = `${cleanBrand} ${cleanArticleNumber}`
}
// Расчет детализированной информации о наличии
const stockCalculation = {
totalInternalStock: internalOffers.reduce((sum, offer) => sum + (offer.quantity || 0), 0),
totalExternalStock: externalOffers.reduce((sum, offer) => sum + (offer.quantity || 0), 0),
availableInternalOffers: internalOffers.filter(offer => offer.available && offer.quantity > 0).length,
availableExternalOffers: externalOffers.filter(offer => offer.quantity > 0).length,
hasInternalStock: internalOffers.some(offer => offer.available && offer.quantity > 0),
hasExternalStock: externalOffers.some(offer => offer.quantity > 0),
totalStock: 0,
hasAnyStock: false
}
stockCalculation.totalStock = stockCalculation.totalInternalStock + stockCalculation.totalExternalStock
stockCalculation.hasAnyStock = stockCalculation.hasInternalStock || stockCalculation.hasExternalStock
// Проверяем, находится ли основной товар в корзине
const isMainProductInCart = isItemInCart(undefined, undefined, cleanArticleNumber, cleanBrand);
const result = {
articleNumber: cleanArticleNumber,
brand: cleanBrand,
@ -2244,25 +2606,49 @@ export const resolvers = {
internalOffers,
externalOffers,
analogs,
hasInternalStock: internalOffers.some(offer => offer.available),
totalOffers: internalOffers.length + externalOffers.length
hasInternalStock: stockCalculation.hasInternalStock,
totalOffers: internalOffers.length + externalOffers.length,
stockCalculation,
isInCart: isMainProductInCart
}
// Детализированное логирование результатов поиска
console.log('✅ Результат поиска предложений:', {
articleNumber: cleanArticleNumber,
brand: cleanBrand,
internalOffers: result.internalOffers.length,
externalOffers: result.externalOffers.length,
analogs: result.analogs.length,
hasInternalStock: result.hasInternalStock
totalOffers: result.totalOffers,
stockStatus: {
hasAnyStock: stockCalculation.hasAnyStock,
totalStock: stockCalculation.totalStock,
internalStock: stockCalculation.totalInternalStock,
externalStock: stockCalculation.totalExternalStock,
availableInternalOffers: stockCalculation.availableInternalOffers,
availableExternalOffers: stockCalculation.availableExternalOffers
}
})
console.log('🔍 Детали результата:')
console.log('- Внутренние предложения:', result.internalOffers)
console.log('- Внешние предложения:', result.externalOffers.slice(0, 3))
console.log('- Аналоги:', result.analogs.length)
console.log('📊 Детализация по предложениям:')
console.log(`- Внутренние предложения: ${result.internalOffers.length} (доступно: ${stockCalculation.availableInternalOffers}, общий сток: ${stockCalculation.totalInternalStock})`)
console.log(`- Внешние предложения: ${result.externalOffers.length} (доступно: ${stockCalculation.availableExternalOffers}, общий сток: ${stockCalculation.totalExternalStock})`)
console.log(`- Аналоги: ${result.analogs.length}`)
console.log(`- Итого в наличии: ${stockCalculation.hasAnyStock ? 'ДА' : 'НЕТ'} (${stockCalculation.totalStock} шт.)`)
// Сохраняем в историю поиска
// Логирование каждого предложения с деталями
if (result.internalOffers.length > 0) {
console.log('🏪 Внутренние предложения:')
result.internalOffers.forEach((offer, index) => {
console.log(` ${index + 1}. ${offer.productId} - ${offer.quantity} шт. (доступно: ${offer.available ? 'ДА' : 'НЕТ'}) - ${offer.price}₽ - склад: ${offer.warehouse}`)
})
}
if (result.externalOffers.length > 0) {
console.log('🌐 Внешние предложения (первые 5):')
result.externalOffers.slice(0, 5).forEach((offer, index) => {
console.log(` ${index + 1}. ${offer.code} (${offer.brand}) - ${offer.quantity} шт. - ${offer.price}₽ - поставщик: ${offer.supplier}`)
})
}
// Сохраняем в историю поиска с расширенной информацией
await saveSearchHistory(
context,
`${cleanBrand} ${cleanArticleNumber}`,
@ -2318,7 +2704,6 @@ export const resolvers = {
if (analogAutoEuroResult.success && analogAutoEuroResult.data) {
analogExternalOffers = analogAutoEuroResult.data
.slice(0, 3) // Ограничиваем до 3 лучших предложений
.map((offer) => ({
offerKey: offer.offer_key,
brand: offer.brand,
@ -2702,7 +3087,9 @@ export const resolvers = {
lang,
limit,
page,
q
q,
params,
hasParams: !!params
})
// Преобразуем строку params в объект если передан
@ -2710,9 +3097,12 @@ export const resolvers = {
if (params) {
try {
parsedParams = JSON.parse(params);
console.log('📝 Разобранные параметры фильтрации:', parsedParams);
} catch (error) {
console.warn('⚠️ Не удалось разобрать параметры фильтрации:', params);
}
} else {
console.log('📝 Параметры фильтрации отсутствуют');
}
const entities = await partsIndexService.getCatalogEntities(catalogId, groupId, {
@ -2748,8 +3138,102 @@ export const resolvers = {
}
console.log('✅ Получены товары каталога:', entities.list.length)
console.log('🔍 Начинаем серверную фильтрацию по ценам...')
return entities
// Глобальный кэш для результатов проверки цен (персистентный между запросами)
if (!global.priceCache) {
global.priceCache = new Map<string, { hasPrice: boolean, timestamp: number }>()
}
const priceCache = global.priceCache as Map<string, { hasPrice: boolean, timestamp: number }>
const CACHE_TTL = 5 * 60 * 1000 // 5 минут
const getCachedPriceResult = (code: string, brand: string): boolean | null => {
const key = `${code}_${brand}`
const cached = priceCache.get(key)
if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) {
return cached.hasPrice
}
return null
}
const cachePriceResult = (code: string, brand: string, hasPrice: boolean): void => {
const key = `${code}_${brand}`
priceCache.set(key, { hasPrice, timestamp: Date.now() })
}
// Фильтруем товары на сервере - проверяем наличие цен в AutoEuro
const filteredEntities: any[] = []
const batchSize = 20 // Увеличенный размер батча для скорости
for (let i = 0; i < entities.list.length; i += batchSize) {
const batch = entities.list.slice(i, i + batchSize)
// Проверяем цены для каждого товара в батче параллельно
const priceCheckPromises = batch.map(async (entity) => {
try {
// Сначала проверяем кэш
const cachedResult = getCachedPriceResult(entity.code, entity.brand.name);
if (cachedResult !== null) {
if (cachedResult) {
console.log(`💨 Кэш: товар ${entity.code} (${entity.brand.name}) имеет цену`);
return entity;
} else {
console.log(`💨 Кэш: товар ${entity.code} (${entity.brand.name}) не имеет цены`);
return null;
}
}
const searchResult = await autoEuroService.searchItems({
code: entity.code,
brand: entity.brand.name,
with_crosses: false,
with_offers: true
})
// Проверяем есть ли предложения с валидной ценой
const hasValidPrice: boolean = Boolean(searchResult.success &&
searchResult.data &&
searchResult.data.length > 0 &&
searchResult.data.some(offer =>
offer.price &&
parseFloat(offer.price.toString()) > 0
))
// Кэшируем результат
cachePriceResult(entity.code, entity.brand.name, hasValidPrice);
if (hasValidPrice) {
console.log(`✅ Товар ${entity.code} (${entity.brand.name}) имеет цену`);
return entity;
} else {
console.log(`❌ Товар ${entity.code} (${entity.brand.name}) не имеет цены`);
return null;
}
} catch (error) {
console.error(`❌ Ошибка проверки цены для ${entity.code}:`, error);
return null // Исключаем товары с ошибками
}
})
// Ждем результаты для текущего батча
const batchResults = await Promise.all(priceCheckPromises)
// Добавляем только товары с ценами
filteredEntities.push(...batchResults.filter(entity => entity !== null))
// Убираем задержку между батчами для максимальной скорости
// if (i + batchSize < entities.list.length) {
// await new Promise(resolve => setTimeout(resolve, 50))
// }
}
console.log(`✅ Серверная фильтрация завершена. Товаров с ценами: ${filteredEntities.length} из ${entities.list.length}`)
// Возвращаем отфильтрованный результат
return {
...entities,
list: filteredEntities
}
} catch (error) {
console.error('❌ Ошибка в GraphQL resolver partsIndexCatalogEntities:', error)
throw new Error('Не удалось получить товары каталога')
@ -2875,6 +3359,67 @@ export const resolvers = {
}
},
// Получить параметры каталога PartsIndex для фильтрации
partsIndexCatalogParams: async (_: unknown, {
catalogId,
groupId,
lang = 'ru',
engineId,
generationId,
params,
q
}: {
catalogId: string;
groupId: string;
lang?: 'ru' | 'en';
engineId?: string;
generationId?: string;
params?: string;
q?: string;
}) => {
try {
console.log('🔍 GraphQL resolver partsIndexCatalogParams вызван с параметрами:', {
catalogId,
groupId,
lang,
q
})
// Преобразуем строку params в объект если передан
let parsedParams: Record<string, any> | undefined;
if (params) {
try {
parsedParams = JSON.parse(params);
} catch (error) {
console.warn('⚠️ Не удалось разобрать параметры фильтрации:', params);
}
}
const paramsData = await partsIndexService.getCatalogParams(catalogId, groupId, {
lang,
engineId,
generationId,
params: parsedParams,
q
})
if (!paramsData) {
console.warn('⚠️ Не удалось получить параметры каталога')
return {
list: [],
paramsQuery: {}
}
}
console.log('✅ Получены параметры каталога:', paramsData.list.length)
return paramsData
} catch (error) {
console.error('❌ Ошибка в GraphQL resolver partsIndexCatalogParams:', error)
throw new Error('Не удалось получить параметры каталога')
}
},
// PartsAPI артикулы
partsAPIArticles: async (_: unknown, { strId, carId, carType = 'PC' }: { strId: number; carId: number; carType?: 'PC' | 'CV' | 'Motorcycle' }) => {
try {
@ -3350,6 +3895,355 @@ export const resolvers = {
console.error('Ошибка получения предложений адресов:', error)
return []
}
},
// Daily Products queries
dailyProducts: async (_: unknown, { displayDate }: { displayDate: string }) => {
try {
return await prisma.dailyProduct.findMany({
where: {
displayDate: new Date(displayDate),
isActive: true
},
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
},
orderBy: { sortOrder: 'asc' }
})
} catch (error) {
console.error('Ошибка получения товаров дня:', error)
throw new Error('Не удалось получить товары дня')
}
},
dailyProduct: async (_: unknown, { id }: { id: string }) => {
try {
return await prisma.dailyProduct.findUnique({
where: { id },
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
}
})
} catch (error) {
console.error('Ошибка получения товара дня:', error)
throw new Error('Не удалось получить товар дня')
}
},
// Best Price Products queries
bestPriceProducts: async () => {
try {
const bestPriceProducts = await prisma.bestPriceProduct.findMany({
where: { isActive: true },
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
},
orderBy: { sortOrder: 'asc' }
})
// Для товаров без изображений пытаемся получить их из PartsIndex
const productsWithImages = await Promise.all(
bestPriceProducts.map(async (bestPriceProduct) => {
const product = bestPriceProduct.product
// Если у товара уже есть изображения, возвращаем как есть
if (product.images && product.images.length > 0) {
return bestPriceProduct
}
// Если нет изображений и есть артикул и бренд, пытаемся получить из PartsIndex
if (product.article && product.brand) {
try {
const partsIndexEnabled = (process.env.PARTSINDEX_ENABLED === 'true') || false
const partsIndexEntity = partsIndexEnabled
? await partsIndexService.searchEntityByCode(
product.article,
product.brand
)
: null
if (partsIndexEntity && partsIndexEntity.images && partsIndexEntity.images.length > 0) {
// Создаем временные изображения для отображения (не сохраняем в БД)
const partsIndexImages = partsIndexEntity.images.slice(0, 3).map((imageUrl, index) => ({
id: `partsindex-${product.id}-${index}`,
url: imageUrl,
alt: product.name,
order: index,
productId: product.id
}))
return {
...bestPriceProduct,
product: {
...product,
images: partsIndexImages
}
}
}
} catch (error) {
console.error(`Ошибка получения изображений из PartsIndex для товара ${product.id}:`, error)
}
}
return bestPriceProduct
})
)
return productsWithImages
} catch (error) {
console.error('Ошибка получения товаров с лучшей ценой:', error)
throw new Error('Не удалось получить товары с лучшей ценой')
}
},
bestPriceProduct: async (_: unknown, { id }: { id: string }) => {
try {
return await prisma.bestPriceProduct.findUnique({
where: { id },
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
}
})
} catch (error) {
console.error('Ошибка получения товара с лучшей ценой:', error)
throw new Error('Не удалось получить товар с лучшей ценой')
}
},
// Top Sales Products queries
topSalesProducts: async () => {
try {
const topSalesProducts = await prisma.topSalesProduct.findMany({
where: { isActive: true },
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
},
orderBy: { sortOrder: 'asc' }
})
// Для товаров без изображений пытаемся получить их из PartsIndex
const productsWithImages = await Promise.all(
topSalesProducts.map(async (topSalesProduct) => {
const product = topSalesProduct.product
// Если у товара уже есть изображения, возвращаем как есть
if (product.images && product.images.length > 0) {
return topSalesProduct
}
// Если нет изображений и есть артикул и бренд, пытаемся получить из PartsIndex
if (product.article && product.brand) {
try {
const partsIndexEnabled = (process.env.PARTSINDEX_ENABLED === 'true') || false
const partsIndexEntity = partsIndexEnabled
? await partsIndexService.searchEntityByCode(
product.article,
product.brand
)
: null
if (partsIndexEntity && partsIndexEntity.images && partsIndexEntity.images.length > 0) {
// Создаем временные изображения для отображения (не сохраняем в БД)
const partsIndexImages = partsIndexEntity.images.slice(0, 3).map((imageUrl, index) => ({
id: `partsindex-${product.id}-${index}`,
url: imageUrl,
alt: product.name,
order: index,
productId: product.id
}))
return {
...topSalesProduct,
product: {
...product,
images: partsIndexImages
}
}
}
} catch (error) {
console.error(`Ошибка получения изображений из PartsIndex для товара ${product.id}:`, error)
}
}
return topSalesProduct
})
)
return productsWithImages
} catch (error) {
console.error('Ошибка получения топ продаж:', error)
throw new Error('Не удалось получить топ продаж')
}
},
topSalesProduct: async (_: unknown, { id }: { id: string }) => {
try {
return await prisma.topSalesProduct.findUnique({
where: { id },
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
}
})
} catch (error) {
console.error('Ошибка получения товара из топ продаж:', error)
throw new Error('Не удалось получить товар из топ продаж')
}
},
// Новые поступления
newArrivals: async (_: unknown, { limit = 8 }: { limit?: number }) => {
try {
const products = await prisma.product.findMany({
where: {
isVisible: true,
AND: [
{
OR: [
{ article: { not: null } },
{ brand: { not: null } }
]
}
]
},
include: {
images: {
orderBy: { order: 'asc' }
},
categories: true
},
orderBy: { createdAt: 'desc' },
take: limit
})
// Для товаров без изображений пытаемся получить их из PartsIndex
const productsWithImages = await Promise.all(
products.map(async (product) => {
// Если у товара уже есть изображения, возвращаем как есть
if (product.images && product.images.length > 0) {
return product
}
// Если нет изображений и есть артикул и бренд, пытаемся получить из PartsIndex
if (product.article && product.brand) {
try {
const partsIndexEnabled = (process.env.PARTSINDEX_ENABLED === 'true') || false
const partsIndexEntity = partsIndexEnabled
? await partsIndexService.searchEntityByCode(
product.article,
product.brand
)
: null
if (partsIndexEntity && partsIndexEntity.images && partsIndexEntity.images.length > 0) {
// Создаем временные изображения для отображения (не сохраняем в БД)
const partsIndexImages = partsIndexEntity.images.slice(0, 3).map((imageUrl, index) => ({
id: `partsindex-${product.id}-${index}`,
url: imageUrl,
alt: product.name,
order: index,
productId: product.id
}))
return {
...product,
images: partsIndexImages
}
}
} catch (error) {
console.error(`Ошибка получения изображений из PartsIndex для товара ${product.id}:`, error)
}
}
return product
})
)
return productsWithImages
} catch (error) {
console.error('Ошибка получения новых поступлений:', error)
throw new Error('Не удалось получить новые поступления')
}
},
// Hero Banners queries
heroBanners: async () => {
try {
return await prisma.heroBanner.findMany({
where: { isActive: true },
orderBy: { sortOrder: 'asc' }
})
} catch (error) {
console.error('Ошибка получения баннеров героя:', error)
throw new Error('Не удалось получить баннеры героя')
}
},
heroBanner: async (_: unknown, { id }: { id: string }) => {
try {
return await prisma.heroBanner.findUnique({
where: { id }
})
} catch (error) {
console.error('Ошибка получения баннера героя:', error)
throw new Error('Не удалось получить баннер героя')
}
},
// Корзина
getCart: async (_: unknown, {}, context: Context) => {
try {
const clientId = context.clientId;
if (!clientId) {
return null;
}
const cart = await prisma.cart.findUnique({
where: { clientId },
include: { items: true }
});
return cart;
} catch (error) {
console.error('❌ Error getting cart:', error);
return null;
}
}
},
@ -3372,6 +4266,18 @@ export const resolvers = {
}
},
DailyProduct: {
product: async (parent: { productId: string }) => {
return await prisma.product.findUnique({
where: { id: parent.productId },
include: {
images: { orderBy: { order: 'asc' } },
categories: true
}
})
}
},
Mutation: {
createUser: async (_: unknown, { input }: { input: CreateUserInput }, context: Context) => {
try {
@ -3930,6 +4836,223 @@ export const resolvers = {
}
},
// Навигационные категории
createNavigationCategory: async (_: unknown, { input }: { input: NavigationCategoryInput }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Проверяем что такой комбинации еще нет
const existing = await prisma.navigationCategory.findFirst({
where: {
partsIndexCatalogId: input.partsIndexCatalogId,
partsIndexGroupId: input.partsIndexGroupId ?? null
}
})
if (existing) {
throw new Error('Иконка для этой категории уже существует')
}
// Загружаем иконку в S3 если есть
let iconUrl: string | null = null
if (input.icon) {
try {
const iconData = input.icon.replace(/^data:image\/[a-z]+;base64,/, '')
const buffer = Buffer.from(iconData, 'base64')
const fileKey = generateFileKey('navigation-icons', 'png')
const uploadResult = await uploadBuffer(buffer, fileKey, 'image/png')
iconUrl = uploadResult.url
} catch (error) {
console.error('Ошибка загрузки иконки:', error)
throw new Error('Не удалось загрузить иконку')
}
}
const category = await prisma.navigationCategory.create({
data: {
partsIndexCatalogId: input.partsIndexCatalogId,
partsIndexGroupId: input.partsIndexGroupId ?? null,
icon: iconUrl,
isHidden: input.isHidden || false,
sortOrder: input.sortOrder || 0
}
})
// Получаем данные из PartsIndex для ответа
const catalogs = await partsIndexService.getCatalogs('ru')
const catalog = catalogs.find(c => c.id === category.partsIndexCatalogId)
let groupName: string | null = null
if (category.partsIndexGroupId && catalog) {
const groups = await partsIndexService.getCatalogGroups(category.partsIndexCatalogId, 'ru')
const group = groups.find(g => g.id === category.partsIndexGroupId)
groupName = group?.name || null
}
const result = {
...category,
name: groupName || catalog?.name || 'Неизвестная категория',
catalogName: catalog?.name || 'Неизвестный каталог',
groupName
}
// Логируем действие
if (context.headers) {
const { ipAddress, userAgent } = getClientInfo(context.headers)
await createAuditLog({
userId: context.userId,
action: AuditAction.CATEGORY_CREATE,
details: `Навигационная категория: ${result.name}`,
ipAddress,
userAgent
})
}
return result
} catch (error) {
console.error('Ошибка создания навигационной категории:', error)
throw error
}
},
updateNavigationCategory: async (_: unknown, { id, input }: { id: string; input: NavigationCategoryInput }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
const existingCategory = await prisma.navigationCategory.findUnique({
where: { id }
})
if (!existingCategory) {
throw new Error('Навигационная категория не найдена')
}
// Проверяем уникальность если изменяются partsIndex поля
if (input.partsIndexCatalogId || input.partsIndexGroupId !== undefined) {
const catalogId = input.partsIndexCatalogId || existingCategory.partsIndexCatalogId
const groupId = input.partsIndexGroupId !== undefined ? input.partsIndexGroupId : existingCategory.partsIndexGroupId
const conflicting = await prisma.navigationCategory.findFirst({
where: {
partsIndexCatalogId: catalogId,
partsIndexGroupId: groupId
}
})
if (conflicting && conflicting.id !== id) {
throw new Error('Иконка для этой категории уже существует')
}
}
// Загружаем новую иконку если есть
let iconUrl = existingCategory.icon
if (input.icon) {
try {
const iconData = input.icon.replace(/^data:image\/[a-z]+;base64,/, '')
const buffer = Buffer.from(iconData, 'base64')
const fileKey = generateFileKey('navigation-icons', 'png')
const uploadResult = await uploadBuffer(buffer, fileKey, 'image/png')
iconUrl = uploadResult.url
} catch (error) {
console.error('Ошибка загрузки иконки:', error)
throw new Error('Не удалось загрузить иконку')
}
}
const category = await prisma.navigationCategory.update({
where: { id },
data: {
partsIndexCatalogId: input.partsIndexCatalogId || existingCategory.partsIndexCatalogId,
partsIndexGroupId: input.partsIndexGroupId !== undefined ? (input.partsIndexGroupId ?? null) : existingCategory.partsIndexGroupId,
icon: iconUrl,
isHidden: input.isHidden !== undefined ? input.isHidden : existingCategory.isHidden,
sortOrder: input.sortOrder !== undefined ? input.sortOrder : existingCategory.sortOrder
}
})
// Получаем данные из PartsIndex для ответа
const catalogs = await partsIndexService.getCatalogs('ru')
const catalog = catalogs.find(c => c.id === category.partsIndexCatalogId)
let groupName: string | null = null
if (category.partsIndexGroupId && catalog) {
const groups = await partsIndexService.getCatalogGroups(category.partsIndexCatalogId, 'ru')
const group = groups.find(g => g.id === category.partsIndexGroupId)
groupName = group?.name || null
}
const result = {
...category,
name: groupName || catalog?.name || 'Неизвестная категория',
catalogName: catalog?.name || 'Неизвестный каталог',
groupName
}
// Логируем действие
if (context.headers) {
const { ipAddress, userAgent } = getClientInfo(context.headers)
await createAuditLog({
userId: context.userId,
action: AuditAction.CATEGORY_UPDATE,
details: `Навигационная категория: ${result.name}`,
ipAddress,
userAgent
})
}
return result
} catch (error) {
console.error('Ошибка обновления навигационной категории:', error)
throw error
}
},
deleteNavigationCategory: async (_: unknown, { id }: { id: string }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
const category = await prisma.navigationCategory.findUnique({
where: { id }
})
if (!category) {
throw new Error('Навигационная категория не найдена')
}
await prisma.navigationCategory.delete({
where: { id }
})
// Логируем действие
if (context.headers) {
const { ipAddress, userAgent } = getClientInfo(context.headers)
await createAuditLog({
userId: context.userId,
action: AuditAction.CATEGORY_DELETE,
details: `Навигационная категория ID: ${category.id}`,
ipAddress,
userAgent
})
}
return true
} catch (error) {
console.error('Ошибка удаления навигационной категории:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось удалить навигационную категорию')
}
},
// Товары
createProduct: async (_: unknown, {
input,
@ -4069,8 +5192,10 @@ export const resolvers = {
}
},
characteristics: { include: { characteristic: true } },
relatedProducts: { include: { images: { orderBy: { order: 'asc' } } } },
accessoryProducts: { include: { images: { orderBy: { order: 'asc' } } } }
products_RelatedProducts_A: { include: { images: { orderBy: { order: 'asc' } } } },
products_RelatedProducts_B: { include: { images: { orderBy: { order: 'asc' } } } },
products_AccessoryProducts_A: { include: { images: { orderBy: { order: 'asc' } } } },
products_AccessoryProducts_B: { include: { images: { orderBy: { order: 'asc' } } } }
}
})
@ -4120,6 +5245,233 @@ export const resolvers = {
}
},
updateProduct: async (_: unknown, {
id,
input,
images = [],
characteristics = [],
options = []
}: {
id: string;
input: ProductInput;
images?: ProductImageInput[];
characteristics?: CharacteristicInput[];
options?: ProductOptionInput[]
}, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Получаем текущий товар для логирования изменений
const existingProduct = await prisma.product.findUnique({
where: { id },
include: {
categories: true,
images: true,
characteristics: { include: { characteristic: true } },
options: { include: { option: true, optionValue: true } }
}
})
if (!existingProduct) {
throw new Error('Товар не найден')
}
// Проверяем уникальность slug если он изменился
if (input.slug && input.slug !== existingProduct.slug) {
const existingBySlug = await prisma.product.findUnique({
where: { slug: input.slug }
})
if (existingBySlug) {
throw new Error('Товар с таким адресом уже существует')
}
}
// Проверяем уникальность артикула если он изменился
if (input.article && input.article !== existingProduct.article) {
const existingByArticle = await prisma.product.findUnique({
where: { article: input.article }
})
if (existingByArticle) {
throw new Error('Товар с таким артикулом уже существует')
}
}
const { categoryIds, ...productData } = input
// Обновляем основные данные товара
await prisma.product.update({
where: { id },
data: {
...productData,
categories: categoryIds ? {
set: categoryIds.map(categoryId => ({ id: categoryId }))
} : undefined
}
})
// Удаляем старые изображения и добавляем новые
await prisma.productImage.deleteMany({
where: { productId: id }
})
if (images.length > 0) {
await prisma.productImage.createMany({
data: images.map((img, index) => ({
productId: id,
url: img.url,
alt: img.alt || '',
order: img.order ?? index
}))
})
}
// Удаляем старые характеристики и добавляем новые
await prisma.productCharacteristic.deleteMany({
where: { productId: id }
})
for (const char of characteristics) {
let characteristic = await prisma.characteristic.findUnique({
where: { name: char.name }
})
if (!characteristic) {
characteristic = await prisma.characteristic.create({
data: { name: char.name }
})
}
await prisma.productCharacteristic.create({
data: {
productId: id,
characteristicId: characteristic.id,
value: char.value
}
})
}
// Удаляем старые опции и добавляем новые
await prisma.productOption.deleteMany({
where: { productId: id }
})
for (const optionInput of options) {
// Создаём или находим опцию
let option = await prisma.option.findUnique({
where: { name: optionInput.name }
})
if (!option) {
option = await prisma.option.create({
data: {
name: optionInput.name,
type: optionInput.type
}
})
}
// Создаём значения опции и связываем с товаром
for (const valueInput of optionInput.values) {
// Создаём или находим значение опции
let optionValue = await prisma.optionValue.findFirst({
where: {
optionId: option.id,
value: valueInput.value
}
})
if (!optionValue) {
optionValue = await prisma.optionValue.create({
data: {
optionId: option.id,
value: valueInput.value,
price: valueInput.price || 0
}
})
}
// Связываем товар с опцией и значением
await prisma.productOption.create({
data: {
productId: id,
optionId: option.id,
optionValueId: optionValue.id
}
})
}
}
// Получаем обновленный товар со всеми связанными данными
const updatedProduct = await prisma.product.findUnique({
where: { id },
include: {
categories: true,
images: { orderBy: { order: 'asc' } },
options: {
include: {
option: { include: { values: true } },
optionValue: true
}
},
characteristics: { include: { characteristic: true } },
products_RelatedProducts_A: { include: { images: { orderBy: { order: 'asc' } } } },
products_RelatedProducts_B: { include: { images: { orderBy: { order: 'asc' } } } },
products_AccessoryProducts_A: { include: { images: { orderBy: { order: 'asc' } } } },
products_AccessoryProducts_B: { include: { images: { orderBy: { order: 'asc' } } } }
}
})
// Создаем запись в истории товара
if (context.userId) {
await prisma.productHistory.create({
data: {
productId: id,
action: 'UPDATE',
changes: JSON.stringify({
name: input.name,
article: input.article,
description: input.description,
brand: input.brand,
wholesalePrice: input.wholesalePrice,
retailPrice: input.retailPrice,
stock: input.stock,
isVisible: input.isVisible,
categories: categoryIds,
images: images.length,
characteristics: characteristics.length,
options: options.length
}),
userId: context.userId
}
})
}
// Логируем действие
if (context.headers) {
const { ipAddress, userAgent } = getClientInfo(context.headers)
await createAuditLog({
userId: context.userId,
action: AuditAction.PRODUCT_UPDATE,
details: `Товар "${input.name}"`,
ipAddress,
userAgent
})
}
return updatedProduct
} catch (error) {
console.error('Ошибка обновления товара:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось обновить товар')
}
},
updateProductVisibility: async (_: unknown, { id, isVisible }: { id: string; isVisible: boolean }, context: Context) => {
try {
if (!context.userId) {
@ -4139,8 +5491,10 @@ export const resolvers = {
}
},
characteristics: { include: { characteristic: true } },
relatedProducts: { include: { images: { orderBy: { order: 'asc' } } } },
accessoryProducts: { include: { images: { orderBy: { order: 'asc' } } } }
products_RelatedProducts_A: { include: { images: { orderBy: { order: 'asc' } } } },
products_RelatedProducts_B: { include: { images: { orderBy: { order: 'asc' } } } },
products_AccessoryProducts_A: { include: { images: { orderBy: { order: 'asc' } } } },
products_AccessoryProducts_B: { include: { images: { orderBy: { order: 'asc' } } } }
}
})
@ -4256,6 +5610,105 @@ export const resolvers = {
}
},
moveProductsToCategory: async (_: unknown, { productIds, categoryId }: { productIds: string[]; categoryId: string }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
if (!productIds || productIds.length === 0) {
throw new Error('Не указаны товары для перемещения')
}
if (!categoryId) {
throw new Error('Не указана целевая категория')
}
// Проверяем существование категории
const targetCategory = await prisma.category.findUnique({
where: { id: categoryId },
select: { id: true, name: true }
})
if (!targetCategory) {
throw new Error('Целевая категория не найдена')
}
// Получаем информацию о товарах для логирования
const products = await prisma.product.findMany({
where: { id: { in: productIds } },
select: {
id: true,
name: true,
categories: { select: { id: true, name: true } }
}
})
if (products.length === 0) {
throw new Error('Товары не найдены')
}
// Обновляем категории для каждого товара в транзакции
const updatePromises = productIds.map(async (productId) => {
// Сначала отключаем товар от всех категорий
await prisma.product.update({
where: { id: productId },
data: {
categories: {
set: []
}
}
})
// Затем подключаем к новой категории
return prisma.product.update({
where: { id: productId },
data: {
categories: {
connect: { id: categoryId }
}
}
})
})
await Promise.all(updatePromises)
// Получаем обновленные товары для ответа
const updatedProducts = await prisma.product.findMany({
where: { id: { in: productIds } },
select: {
id: true,
name: true,
categories: { select: { id: true, name: true } }
}
})
// Логируем действие
if (context.headers) {
const { ipAddress, userAgent } = getClientInfo(context.headers)
await createAuditLog({
userId: context.userId,
action: AuditAction.PRODUCT_UPDATE,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
details: `Массовое перемещение товаров в категорию "${targetCategory.name}": ${products.map((p: any) => p.name).join(', ')} (${products.length} шт.)`,
ipAddress,
userAgent
})
}
return {
count: products.length,
movedProducts: updatedProducts
}
} catch (error) {
console.error('Ошибка перемещения товаров в категорию:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось переместить товары в категорию')
}
},
exportProducts: async (_: unknown, { categoryId, search }: {
categoryId?: string; search?: string; format?: string
}, context: Context) => {
@ -7849,6 +9302,893 @@ export const resolvers = {
offers: fallbackOffers
}
}
},
// Daily Products mutations
createDailyProduct: async (_: unknown, { input }: { input: DailyProductInput }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Проверяем, существует ли товар
const product = await prisma.product.findUnique({
where: { id: input.productId }
})
if (!product) {
throw new Error('Товар не найден')
}
// Создаем товар дня
const dailyProduct = await prisma.dailyProduct.create({
data: {
productId: input.productId,
displayDate: new Date(input.displayDate),
discount: input.discount,
isActive: input.isActive ?? true,
sortOrder: input.sortOrder ?? 0
},
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
}
})
return dailyProduct
} catch (error) {
console.error('Ошибка создания товара дня:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось создать товар дня')
}
},
updateDailyProduct: async (_: unknown, { id, input }: { id: string; input: DailyProductUpdateInput }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Проверяем, существует ли товар дня
const existingDailyProduct = await prisma.dailyProduct.findUnique({
where: { id }
})
if (!existingDailyProduct) {
throw new Error('Товар дня не найден')
}
// Обновляем товар дня
const dailyProduct = await prisma.dailyProduct.update({
where: { id },
data: {
...(input.discount !== undefined && { discount: input.discount }),
...(input.isActive !== undefined && { isActive: input.isActive }),
...(input.sortOrder !== undefined && { sortOrder: input.sortOrder })
},
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
}
})
return dailyProduct
} catch (error) {
console.error('Ошибка обновления товара дня:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось обновить товар дня')
}
},
deleteDailyProduct: async (_: unknown, { id }: { id: string }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Проверяем, существует ли товар дня
const existingDailyProduct = await prisma.dailyProduct.findUnique({
where: { id }
})
if (!existingDailyProduct) {
throw new Error('Товар дня не найден')
}
// Удаляем товар дня
await prisma.dailyProduct.delete({
where: { id }
})
return true
} catch (error) {
console.error('Ошибка удаления товара дня:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось удалить товар дня')
}
},
// Best Price Products mutations
createBestPriceProduct: async (_: unknown, { input }: { input: BestPriceProductInput }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Проверяем, существует ли товар
const product = await prisma.product.findUnique({
where: { id: input.productId }
})
if (!product) {
throw new Error('Товар не найден')
}
// Проверяем, что товар еще не добавлен в список лучших цен
const existingBestPriceProduct = await prisma.bestPriceProduct.findUnique({
where: { productId: input.productId }
})
if (existingBestPriceProduct) {
throw new Error('Товар уже добавлен в список лучших цен')
}
// Создаем товар с лучшей ценой
const bestPriceProduct = await prisma.bestPriceProduct.create({
data: {
productId: input.productId,
discount: input.discount || 0,
isActive: input.isActive ?? true,
sortOrder: input.sortOrder ?? 0
},
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
}
})
return bestPriceProduct
} catch (error) {
console.error('Ошибка создания товара с лучшей ценой:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось создать товар с лучшей ценой')
}
},
updateBestPriceProduct: async (_: unknown, { id, input }: { id: string; input: BestPriceProductUpdateInput }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Проверяем, существует ли товар с лучшей ценой
const existingBestPriceProduct = await prisma.bestPriceProduct.findUnique({
where: { id }
})
if (!existingBestPriceProduct) {
throw new Error('Товар с лучшей ценой не найден')
}
// Обновляем товар с лучшей ценой
const bestPriceProduct = await prisma.bestPriceProduct.update({
where: { id },
data: {
...(input.discount !== undefined && { discount: input.discount }),
...(input.isActive !== undefined && { isActive: input.isActive }),
...(input.sortOrder !== undefined && { sortOrder: input.sortOrder })
},
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
}
})
return bestPriceProduct
} catch (error) {
console.error('Ошибка обновления товара с лучшей ценой:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось обновить товар с лучшей ценой')
}
},
deleteBestPriceProduct: async (_: unknown, { id }: { id: string }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Проверяем, существует ли товар с лучшей ценой
const existingBestPriceProduct = await prisma.bestPriceProduct.findUnique({
where: { id }
})
if (!existingBestPriceProduct) {
throw new Error('Товар с лучшей ценой не найден')
}
// Удаляем товар с лучшей ценой
await prisma.bestPriceProduct.delete({
where: { id }
})
return true
} catch (error) {
console.error('Ошибка удаления товара с лучшей ценой:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось удалить товар с лучшей ценой')
}
},
// Top Sales Products mutations
createTopSalesProduct: async (_: unknown, { input }: { input: TopSalesProductInput }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Проверяем, существует ли товар
const product = await prisma.product.findUnique({
where: { id: input.productId }
})
if (!product) {
throw new Error('Товар не найден')
}
// Проверяем, что товар еще не добавлен в топ продаж
const existingTopSalesProduct = await prisma.topSalesProduct.findUnique({
where: { productId: input.productId }
})
if (existingTopSalesProduct) {
throw new Error('Товар уже добавлен в топ продаж')
}
// Создаем товар в топ продаж
const topSalesProduct = await prisma.topSalesProduct.create({
data: {
productId: input.productId,
isActive: input.isActive ?? true,
sortOrder: input.sortOrder ?? 0
},
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
}
})
return topSalesProduct
} catch (error) {
console.error('Ошибка создания товара в топ продаж:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось создать товар в топ продаж')
}
},
updateTopSalesProduct: async (_: unknown, { id, input }: { id: string; input: TopSalesProductUpdateInput }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Проверяем, существует ли товар в топ продаж
const existingTopSalesProduct = await prisma.topSalesProduct.findUnique({
where: { id }
})
if (!existingTopSalesProduct) {
throw new Error('Товар в топ продаж не найден')
}
// Обновляем товар в топ продаж
const topSalesProduct = await prisma.topSalesProduct.update({
where: { id },
data: {
...(input.isActive !== undefined && { isActive: input.isActive }),
...(input.sortOrder !== undefined && { sortOrder: input.sortOrder })
},
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
}
})
return topSalesProduct
} catch (error) {
console.error('Ошибка обновления товара в топ продаж:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось обновить товар в топ продаж')
}
},
deleteTopSalesProduct: async (_: unknown, { id }: { id: string }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Проверяем, существует ли товар в топ продаж
const existingTopSalesProduct = await prisma.topSalesProduct.findUnique({
where: { id }
})
if (!existingTopSalesProduct) {
throw new Error('Товар в топ продаж не найден')
}
// Удаляем товар из топ продаж
await prisma.topSalesProduct.delete({
where: { id }
})
return true
} catch (error) {
console.error('Ошибка удаления товара из топ продаж:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось удалить товар из топ продаж')
}
},
// Hero Banner mutations
createHeroBanner: async (_: unknown, { input }: { input: HeroBannerInput }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
const heroBanner = await prisma.heroBanner.create({
data: {
title: input.title,
subtitle: input.subtitle,
imageUrl: input.imageUrl,
linkUrl: input.linkUrl,
isActive: input.isActive ?? true,
sortOrder: input.sortOrder ?? 0
}
})
return heroBanner
} catch (error) {
console.error('Ошибка создания баннера героя:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось создать баннер героя')
}
},
updateHeroBanner: async (_: unknown, { id, input }: { id: string; input: HeroBannerUpdateInput }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
const existingBanner = await prisma.heroBanner.findUnique({
where: { id }
})
if (!existingBanner) {
throw new Error('Баннер героя не найден')
}
const heroBanner = await prisma.heroBanner.update({
where: { id },
data: {
...(input.title !== undefined && { title: input.title }),
...(input.subtitle !== undefined && { subtitle: input.subtitle }),
...(input.imageUrl !== undefined && { imageUrl: input.imageUrl }),
...(input.linkUrl !== undefined && { linkUrl: input.linkUrl }),
...(input.isActive !== undefined && { isActive: input.isActive }),
...(input.sortOrder !== undefined && { sortOrder: input.sortOrder })
}
})
return heroBanner
} catch (error) {
console.error('Ошибка обновления баннера героя:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось обновить баннер героя')
}
},
deleteHeroBanner: async (_: unknown, { id }: { id: string }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
const existingBanner = await prisma.heroBanner.findUnique({
where: { id }
})
if (!existingBanner) {
throw new Error('Баннер героя не найден')
}
await prisma.heroBanner.delete({
where: { id }
})
return true
} catch (error) {
console.error('Ошибка удаления баннера героя:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось удалить баннер героя')
}
},
// Кража - мутации для работы с базой данных запчастей
fetchCategoryProducts: async (_: unknown, { input }: { input: any }, context: Context) => {
try {
if (!context.userId || context.userRole !== 'ADMIN') {
throw new Error('Недостаточно прав для выполнения операции')
}
const { categoryId, categoryName, categoryType, groupId, groupName, limit = 100, fetchAll = false } = input
console.log('🔍 Fetching products for category:', {
categoryId,
categoryName,
categoryType,
groupId,
groupName,
limit,
fetchAll
})
let products: any[] = []
if (categoryType === 'PARTSINDEX') {
if (!groupId) {
// If no groupId, try to fetch all groups for this category
console.log('🔍 No groupId provided, fetching all groups for category:', categoryId)
const catalogGroups = await partsIndexService.getCatalogGroups(categoryId, 'ru')
console.log('✅ Found groups for category:', catalogGroups.length)
if (catalogGroups.length === 0) {
return {
success: false,
message: 'No groups found for this PartsIndex category',
insertedCount: 0,
tableName: null
}
}
// Fetch products from all groups (limit per group to avoid too much data)
const allProducts: any[] = []
const maxProductsPerGroup = fetchAll
? Math.max(5000, Math.floor(50000 / catalogGroups.length)) // Гораздо более щедрый лимит при fetchAll
: Math.max(1, Math.floor(limit / catalogGroups.length))
for (const group of catalogGroups.slice(0, 10)) { // Limit to first 10 groups
try {
let groupProducts: any[] = []
if (fetchAll) {
// Используем новый метод для получения ВСЕХ товаров группы
groupProducts = await partsIndexService.getAllCatalogEntities(categoryId, group.id, {
lang: 'ru',
maxItems: maxProductsPerGroup
})
} else {
// Обычный метод с лимитом
const entitiesData = await partsIndexService.getCatalogEntities(categoryId, group.id, {
lang: 'ru',
limit: maxProductsPerGroup,
page: 1
})
groupProducts = entitiesData?.list || []
}
// Add group info to each product
const productsWithGroup = groupProducts.map(product => ({
...product,
groupId: group.id,
groupName: group.name
}))
allProducts.push(...productsWithGroup)
console.log(`✅ Fetched ${groupProducts.length} products from group: ${group.name}`)
} catch (error) {
console.error(`❌ Error fetching products from group ${group.id}:`, error)
}
}
products = allProducts
console.log('✅ Fetched total PartsIndex products:', products.length)
} else {
// Fetch from specific group
if (fetchAll) {
// Используем новый метод для получения ВСЕХ товаров группы
products = await partsIndexService.getAllCatalogEntities(categoryId, groupId, {
lang: 'ru',
maxItems: 50000 // Максимум товаров для одной группы
})
} else {
// Обычный метод с лимитом
const entitiesData = await partsIndexService.getCatalogEntities(categoryId, groupId, {
lang: 'ru',
limit,
page: 1
})
products = entitiesData?.list || []
}
console.log('✅ Fetched PartsIndex products from group:', products.length)
}
} else if (categoryType === 'PARTSAPI') {
const articlesData = await partsAPIService.getArticles(parseInt(categoryId), 9877, 'PC')
products = articlesData || []
console.log('✅ Fetched PartsAPI products:', products.length)
} else {
throw new Error('Invalid category type')
}
if (products.length === 0) {
return {
success: false,
message: 'No products found for this category',
insertedCount: 0,
tableName: null
}
}
console.log(`📊 About to insert ${products.length} products into database`)
console.log(`📋 Sample product data:`, products.slice(0, 3))
// Insert products into parts database
const { getPartsDb } = await import('../parts-db-wrapper')
const partsDb = await getPartsDb()
const insertedCount = await partsDb.insertProducts(
categoryId,
categoryName,
categoryType.toLowerCase() as 'partsindex' | 'partsapi',
products,
groupId,
groupName
)
console.log(`✅ Database insertion result: ${insertedCount} of ${products.length} products saved`)
const tableName = `category_${categoryType.toLowerCase()}_${categoryId.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase()}`
return {
success: true,
message: `Successfully fetched and saved ${insertedCount} products`,
insertedCount,
tableName
}
} catch (error) {
console.error('❌ Error fetching category products:', error)
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error occurred',
insertedCount: 0,
tableName: null
}
}
},
getCategoryTables: async (_: unknown, __: unknown, context: Context) => {
try {
if (!context.userId || context.userRole !== 'ADMIN') {
throw new Error('Недостаточно прав для выполнения операции')
}
const { getPartsDb } = await import('../parts-db-wrapper')
const partsDb = await getPartsDb()
const tables = await partsDb.getCategoryTables()
return tables
} catch (error) {
console.error('❌ Error getting category tables:', error)
return []
}
},
deleteCategoryTable: async (_: unknown, { categoryId, categoryType }: { categoryId: string, categoryType: string }, context: Context) => {
try {
if (!context.userId || context.userRole !== 'ADMIN') {
throw new Error('Недостаточно прав для выполнения операции')
}
const { getPartsDb } = await import('../parts-db-wrapper')
const partsDb = await getPartsDb()
await partsDb.deleteCategoryTable(categoryId, categoryType.toLowerCase() as 'partsindex' | 'partsapi')
return true
} catch (error) {
console.error('❌ Error deleting category table:', error)
throw new Error('Failed to delete category table')
}
},
getCategoryProducts: async (_: unknown, {
categoryId,
categoryType,
search,
limit = 50,
offset = 0
}: {
categoryId: string,
categoryType: string,
search?: string,
limit?: number,
offset?: number
}, context: Context) => {
try {
if (!context.userId || context.userRole !== 'ADMIN') {
throw new Error('Недостаточно прав для выполнения операции')
}
const { getPartsDb } = await import('../parts-db-wrapper')
const partsDb = await getPartsDb()
const result = await partsDb.getProducts(categoryId, categoryType.toLowerCase() as 'partsindex' | 'partsapi', {
search,
limit,
offset
})
return {
products: result.products,
total: result.total
}
} catch (error) {
console.error('❌ Error getting category products:', error)
return {
products: [],
total: 0
}
}
},
// Корзина
addToCart: async (_: unknown, { input }: { input: any }, context: Context) => {
try {
const clientId = context.clientId;
if (!clientId) {
return {
success: false,
error: 'Клиент не идентифицирован'
};
}
console.log('🛒 Adding to cart for client:', clientId);
// Находим или создаем корзину
let cart = await prisma.cart.findUnique({
where: { clientId },
include: { items: true }
});
if (!cart) {
cart = await prisma.cart.create({
data: { clientId },
include: { items: true }
});
}
// Проверяем, есть ли уже такой товар в корзине
const existingItem = cart.items.find(item =>
(item.productId && input.productId && item.productId === input.productId) ||
(item.offerKey && input.offerKey && item.offerKey === input.offerKey) ||
(item.article === input.article && item.brand === input.brand)
);
if (existingItem) {
// Увеличиваем количество
await prisma.cartItem.update({
where: { id: existingItem.id },
data: { quantity: existingItem.quantity + input.quantity }
});
} else {
// Добавляем новый товар
await prisma.cartItem.create({
data: {
cartId: cart.id,
productId: input.productId,
offerKey: input.offerKey,
name: input.name,
description: input.description,
brand: input.brand,
article: input.article,
price: input.price,
currency: input.currency,
quantity: input.quantity,
stock: input.stock,
deliveryTime: input.deliveryTime,
warehouse: input.warehouse,
supplier: input.supplier,
isExternal: input.isExternal,
image: input.image
}
});
}
// Получаем обновленную корзину
const updatedCart = await prisma.cart.findUnique({
where: { clientId },
include: { items: true }
});
return {
success: true,
message: 'Товар добавлен в корзину',
cart: updatedCart
};
} catch (error) {
console.error('❌ Error adding to cart:', error);
return {
success: false,
error: 'Ошибка добавления товара в корзину'
};
}
},
removeFromCart: async (_: unknown, { itemId }: { itemId: string }, context: Context) => {
try {
const clientId = context.clientId;
if (!clientId) {
return {
success: false,
error: 'Клиент не идентифицирован'
};
}
await prisma.cartItem.delete({
where: { id: itemId }
});
const updatedCart = await prisma.cart.findUnique({
where: { clientId },
include: { items: true }
});
return {
success: true,
message: 'Товар удален из корзины',
cart: updatedCart
};
} catch (error) {
console.error('❌ Error removing from cart:', error);
return {
success: false,
error: 'Ошибка удаления товара из корзины'
};
}
},
updateCartItemQuantity: async (_: unknown, { itemId, quantity }: { itemId: string; quantity: number }, context: Context) => {
try {
const clientId = context.clientId;
if (!clientId) {
return {
success: false,
error: 'Клиент не идентифицирован'
};
}
await prisma.cartItem.update({
where: { id: itemId },
data: { quantity: Math.max(1, quantity) }
});
const updatedCart = await prisma.cart.findUnique({
where: { clientId },
include: { items: true }
});
return {
success: true,
message: 'Количество товара обновлено',
cart: updatedCart
};
} catch (error) {
console.error('❌ Error updating cart item quantity:', error);
return {
success: false,
error: 'Ошибка обновления количества товара'
};
}
},
clearCart: async (_: unknown, {}, context: Context) => {
try {
const clientId = context.clientId;
if (!clientId) {
return {
success: false,
error: 'Клиент не идентифицирован'
};
}
await prisma.cartItem.deleteMany({
where: {
cart: {
clientId
}
}
});
const updatedCart = await prisma.cart.findUnique({
where: { clientId },
include: { items: true }
});
return {
success: true,
message: 'Корзина очищена',
cart: updatedCart
};
} catch (error) {
console.error('❌ Error clearing cart:', error);
return {
success: false,
error: 'Ошибка очистки корзины'
};
}
}
}
}
}

View File

@ -73,12 +73,29 @@ export const typeDefs = gql`
updatedAt: DateTime!
}
type NavigationCategory {
id: ID!
partsIndexCatalogId: String!
partsIndexGroupId: String
icon: String
isHidden: Boolean!
sortOrder: Int!
createdAt: DateTime!
updatedAt: DateTime!
# Виртуальные поля - получаем данные из PartsIndex API
name: String!
catalogName: String!
groupName: String
}
type Product {
id: ID!
name: String!
slug: String!
article: String
description: String
brand: String
videoUrl: String
wholesalePrice: Float
retailPrice: Float
@ -599,11 +616,20 @@ export const typeDefs = gql`
parentId: String
}
input NavigationCategoryInput {
partsIndexCatalogId: String!
partsIndexGroupId: String
icon: String
isHidden: Boolean
sortOrder: Int
}
input ProductInput {
name: String!
slug: String
article: String
description: String
brand: String
videoUrl: String
wholesalePrice: Float
retailPrice: Float
@ -922,6 +948,10 @@ export const typeDefs = gql`
laximoQuickGroups(catalogCode: String!, vehicleId: String, ssd: String): [LaximoQuickGroup!]!
laximoQuickGroupsWithXML(catalogCode: String!, vehicleId: String, ssd: String): LaximoQuickGroupsResponse!
laximoCategories(catalogCode: String!, vehicleId: String, ssd: String): [LaximoQuickGroup!]!
# Навигационные категории
navigationCategories: [NavigationCategory!]!
navigationCategory(id: ID!): NavigationCategory
laximoUnits(catalogCode: String!, vehicleId: String, ssd: String, categoryId: String): [LaximoQuickGroup!]!
laximoQuickDetail(catalogCode: String!, vehicleId: String!, quickGroupId: String!, ssd: String!): LaximoQuickDetail
laximoOEMSearch(catalogCode: String!, vehicleId: String!, oemNumber: String!, ssd: String!): LaximoOEMResult
@ -936,7 +966,8 @@ export const typeDefs = gql`
# Поиск товаров и предложений
searchProductOffers(
articleNumber: String!,
brand: String!
brand: String!,
cartItems: [CartItemInput!]
): ProductOffersResult!
getAnalogOffers(analogs: [AnalogOfferInput!]!): [AnalogProduct!]
getBrandsByCode(code: String!): BrandsByCodeResponse!
@ -1011,6 +1042,28 @@ export const typeDefs = gql`
# Автокомплит адресов
addressSuggestions(query: String!): [String!]!
# Товары дня
dailyProducts(displayDate: String!): [DailyProduct!]!
dailyProduct(id: ID!): DailyProduct
# Товары с лучшей ценой
bestPriceProducts: [BestPriceProduct!]!
bestPriceProduct(id: ID!): BestPriceProduct
# Топ продаж
topSalesProducts: [TopSalesProduct!]!
topSalesProduct(id: ID!): TopSalesProduct
# Баннеры героя
heroBanners: [HeroBanner!]!
heroBanner(id: String!): HeroBanner
# Новые поступления
newArrivals(limit: Int = 8): [Product!]!
# Корзина
getCart: Cart
}
type AuthPayload {
@ -1048,6 +1101,11 @@ export const typeDefs = gql`
count: Int!
}
type MoveProductsResult {
count: Int!
movedProducts: [Product!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
login(input: LoginInput!): AuthPayload!
@ -1066,6 +1124,11 @@ export const typeDefs = gql`
updateCategory(id: ID!, input: CategoryInput!): Category!
deleteCategory(id: ID!): Boolean!
# Навигационные категории
createNavigationCategory(input: NavigationCategoryInput!): NavigationCategory!
updateNavigationCategory(id: ID!, input: NavigationCategoryInput!): NavigationCategory!
deleteNavigationCategory(id: ID!): Boolean!
# Товары
createProduct(input: ProductInput!, images: [ProductImageInput!], characteristics: [CharacteristicInput!], options: [ProductOptionInput!]): Product!
updateProduct(id: ID!, input: ProductInput!, images: [ProductImageInput!], characteristics: [CharacteristicInput!], options: [ProductOptionInput!]): Product!
@ -1075,6 +1138,7 @@ export const typeDefs = gql`
# Массовые операции с товарами
deleteProducts(ids: [ID!]!): BulkOperationResult!
updateProductsVisibility(ids: [ID!]!, isVisible: Boolean!): BulkOperationResult!
moveProductsToCategory(productIds: [ID!]!, categoryId: ID!): MoveProductsResult!
exportProducts(categoryId: String, search: String, format: String): ExportResult!
importProducts(input: ImportProductsInput!): ImportResult!
@ -1191,6 +1255,39 @@ export const typeDefs = gql`
# Доставка Яндекс
getDeliveryOffers(input: DeliveryOffersInput!): DeliveryOffersResponse!
# Товары дня
createDailyProduct(input: DailyProductInput!): DailyProduct!
updateDailyProduct(id: ID!, input: DailyProductUpdateInput!): DailyProduct!
deleteDailyProduct(id: ID!): Boolean!
# Товары с лучшей ценой
createBestPriceProduct(input: BestPriceProductInput!): BestPriceProduct!
updateBestPriceProduct(id: ID!, input: BestPriceProductUpdateInput!): BestPriceProduct!
deleteBestPriceProduct(id: ID!): Boolean!
# Топ продаж
createTopSalesProduct(input: TopSalesProductInput!): TopSalesProduct!
updateTopSalesProduct(id: ID!, input: TopSalesProductUpdateInput!): TopSalesProduct!
deleteTopSalesProduct(id: ID!): Boolean!
# Баннеры героя
createHeroBanner(input: HeroBannerInput!): HeroBanner!
updateHeroBanner(id: String!, input: HeroBannerUpdateInput!): HeroBanner!
deleteHeroBanner(id: String!): Boolean!
# Кража - работа с базой данных запчастей
fetchCategoryProducts(input: FetchCategoryProductsInput!): FetchCategoryProductsResult!
getCategoryTables: [CategoryTable!]!
deleteCategoryTable(categoryId: String!, categoryType: CategoryType!): Boolean!
getCategoryProducts(categoryId: String!, categoryType: CategoryType!, search: String, limit: Int, offset: Int): CategoryProductsResult!
# Корзина
addToCart(input: AddToCartInput!): AddToCartResult!
removeFromCart(itemId: ID!): AddToCartResult!
updateCartItemQuantity(itemId: ID!, quantity: Int!): AddToCartResult!
clearCart: AddToCartResult!
getCart: Cart
}
input LoginInput {
@ -1368,6 +1465,7 @@ export const typeDefs = gql`
options: String
description: String
grade: String
attributes: [LaximoVehicleAttribute!]!
}
type LaximoVehicleInfo {
@ -1393,6 +1491,7 @@ export const typeDefs = gql`
code: String
imageurl: String
largeimageurl: String
ssd: String
}
type LaximoQuickGroupsResponse {
@ -1413,6 +1512,7 @@ export const typeDefs = gql`
description: String
imageurl: String
largeimageurl: String
ssd: String
details: [LaximoDetail!]
attributes: [LaximoDetailAttribute!]
}
@ -1421,10 +1521,19 @@ export const typeDefs = gql`
detailid: String!
name: String!
oem: String!
formattedoem: String
parttype: String
filter: String
brand: String
description: String
applicablemodels: String
note: String
amount: String
range: String
codeonimage: String
match: Boolean
dateRange: String
ssd: String
attributes: [LaximoDetailAttribute!]
}
@ -1470,10 +1579,20 @@ export const typeDefs = gql`
}
type LaximoFulltextDetail {
detailid: String
oem: String!
formattedoem: String
name: String!
brand: String
description: String
codeonimage: String
code: String
note: String
filter: String
parttype: String
price: String
availability: String
attributes: [LaximoDetailAttribute!]
}
# Типы для Doc FindOEM
@ -1534,6 +1653,7 @@ export const typeDefs = gql`
description: String
imageurl: String
largeimageurl: String
ssd: String
attributes: [LaximoDetailAttribute!]
}
@ -1604,6 +1724,69 @@ export const typeDefs = gql`
category: String
}
# Типы для корзины
input CartItemInput {
productId: String
offerKey: String
article: String!
brand: String!
quantity: Int!
}
input AddToCartInput {
productId: String
offerKey: String
name: String!
description: String!
brand: String!
article: String!
price: Float!
currency: String!
quantity: Int!
stock: Int
deliveryTime: String
warehouse: String
supplier: String
isExternal: Boolean!
image: String
}
type CartItem {
id: ID!
productId: String
offerKey: String
name: String!
description: String!
brand: String!
article: String!
price: Float!
currency: String!
quantity: Int!
stock: Int
deliveryTime: String
warehouse: String
supplier: String
isExternal: Boolean!
image: String
createdAt: String!
updatedAt: String!
}
type Cart {
id: ID!
clientId: String!
items: [CartItem!]!
createdAt: String!
updatedAt: String!
}
type AddToCartResult {
success: Boolean!
message: String
cart: Cart
error: String
}
# Типы для поиска товаров и предложений
type ProductOffersResult {
articleNumber: String!
@ -1619,6 +1802,19 @@ export const typeDefs = gql`
analogs: [AnalogInfo!]!
hasInternalStock: Boolean!
totalOffers: Int!
stockCalculation: StockCalculation!
isInCart: Boolean!
}
type StockCalculation {
totalInternalStock: Int!
totalExternalStock: Int!
availableInternalOffers: Int!
availableExternalOffers: Int!
hasInternalStock: Boolean!
hasExternalStock: Boolean!
totalStock: Int!
hasAnyStock: Boolean!
}
type PartsIndexImage {
@ -1645,6 +1841,7 @@ export const typeDefs = gql`
rating: Float
supplier: String!
canPurchase: Boolean!
isInCart: Boolean!
}
type ExternalOffer {
@ -1657,6 +1854,7 @@ export const typeDefs = gql`
deliveryTime: Int!
deliveryTimeMax: Int!
quantity: Int!
isInCart: Boolean!
warehouse: String!
warehouseName: String
rejects: Float
@ -2134,4 +2332,163 @@ export const typeDefs = gql`
minPrice: Float
hasOffers: Boolean!
}
# Типы для товаров дня
type DailyProduct {
id: ID!
productId: String!
product: Product!
displayDate: String!
discount: Float
isActive: Boolean!
sortOrder: Int!
createdAt: DateTime!
updatedAt: DateTime!
}
input DailyProductInput {
productId: String!
displayDate: String!
discount: Float
isActive: Boolean
sortOrder: Int
}
input DailyProductUpdateInput {
discount: Float
isActive: Boolean
sortOrder: Int
}
# Типы для товаров с лучшей ценой
type BestPriceProduct {
id: ID!
productId: String!
product: Product!
discount: Float!
isActive: Boolean!
sortOrder: Int!
createdAt: DateTime!
updatedAt: DateTime!
}
input BestPriceProductInput {
productId: String!
discount: Float!
isActive: Boolean
sortOrder: Int
}
input BestPriceProductUpdateInput {
discount: Float
isActive: Boolean
sortOrder: Int
}
# Типы для топ продаж
type TopSalesProduct {
id: ID!
productId: String!
product: Product!
isActive: Boolean!
sortOrder: Int!
createdAt: DateTime!
updatedAt: DateTime!
}
input TopSalesProductInput {
productId: String!
isActive: Boolean
sortOrder: Int
}
input TopSalesProductUpdateInput {
isActive: Boolean
sortOrder: Int
}
# Типы для баннеров героя
type HeroBanner {
id: ID!
title: String!
subtitle: String
imageUrl: String!
linkUrl: String
isActive: Boolean!
sortOrder: Int!
createdAt: DateTime!
updatedAt: DateTime!
}
input HeroBannerInput {
title: String!
subtitle: String
imageUrl: String!
linkUrl: String
isActive: Boolean
sortOrder: Int
}
input HeroBannerUpdateInput {
title: String
subtitle: String
imageUrl: String
linkUrl: String
isActive: Boolean
sortOrder: Int
}
# Кража - типы для работы с базой данных запчастей
enum CategoryType {
PARTSINDEX
PARTSAPI
}
input FetchCategoryProductsInput {
categoryId: String!
categoryName: String!
categoryType: CategoryType!
groupId: String
groupName: String
limit: Int
fetchAll: Boolean
}
type FetchCategoryProductsResult {
success: Boolean!
message: String!
insertedCount: Int
tableName: String
}
type CategoryTable {
tableName: String!
categoryId: String!
categoryType: String!
recordCount: Int!
}
type CategoryProductsResult {
products: [CategoryProduct!]!
total: Int!
}
type CategoryProduct {
id: Int!
external_id: String!
name: String!
brand: String
article: String
description: String
image_url: String
price: Float
category_id: String!
category_name: String!
category_type: String!
group_id: String
group_name: String
raw_data: JSON
created_at: DateTime!
updated_at: DateTime!
}
`

View File

@ -86,6 +86,26 @@ export interface LaximoVehicleSearchResult {
engine: string
notes?: string
ssd?: string
transmission?: string
date?: string
manufactured?: string
framecolor?: string
trimcolor?: string
engine_info?: string
engineno?: string
market?: string
prodRange?: string
prodPeriod?: string
destinationregion?: string
creationregion?: string
datefrom?: string
dateto?: string
modelyearfrom?: string
modelyearto?: string
options?: string
description?: string
grade?: string
attributes: LaximoVehicleAttribute[]
}
export interface LaximoVehicleInfo {
@ -111,6 +131,7 @@ export interface LaximoQuickGroup {
code?: string
imageurl?: string
largeimageurl?: string
ssd?: string // ИСПРАВЛЕНИЕ: Добавляем поле SSD для узлов
}
export interface LaximoQuickDetail {
@ -126,6 +147,7 @@ export interface LaximoUnit {
description?: string
imageurl?: string
largeimageurl?: string
ssd?: string // ИСПРАВЛЕНИЕ: Добавляем поле SSD для узла
details?: LaximoDetail[]
attributes?: LaximoDetailAttribute[]
}
@ -138,6 +160,12 @@ export interface LaximoDetail {
description?: string
applicablemodels?: string
note?: string
amount?: string
range?: string
codeonimage?: string
match?: boolean
dateRange?: string
ssd?: string
attributes?: LaximoDetailAttribute[]
}
@ -1079,12 +1107,30 @@ class LaximoService {
if (vehicleId) {
command += `|VehicleId=${vehicleId}`
}
if (ssd && ssd.trim() !== '') {
const escapedSsd = this.escapeSsdForXML(ssd)
command += `|ssd=${escapedSsd}`
}
// 🎯 ИСПРАВЛЕНИЕ: Для категорий каталога требуется SSD согласно API Laximo
// Без SSD API возвращает ошибку E_INVALIDPARAMETER:ssd
if (categoryId) {
command += `|CategoryId=${categoryId}`
// SSD обязателен для ListUnits даже с CategoryId
if (ssd && ssd.trim() !== '') {
const escapedSsd = this.escapeSsdForXML(ssd)
command += `|ssd=${escapedSsd}`
console.log('🔧 Режим "От производителя": используем CategoryId с SSD')
} else {
// Если SSD отсутствует, используем пустой SSD
command += `|ssd=`
console.log('🔧 Режим "От производителя": используем CategoryId с пустым SSD')
}
} else if (ssd && ssd.trim() !== '') {
// SSD добавляем для QuickGroup'ов
const escapedSsd = this.escapeSsdForXML(ssd)
command += `|ssd=${escapedSsd}`
console.log('🔧 Режим "Общие": используем SSD для получения узлов автомобиля')
} else {
// Всегда добавляем SSD параметр, даже если он пустой
command += `|ssd=`
console.log('🔧 Добавляем пустой SSD параметр')
}
const hmac = this.createHMAC(command)
@ -1161,6 +1207,7 @@ class LaximoService {
const code = this.extractAttribute(attributes, 'code')
const imageurl = this.extractAttribute(attributes, 'imageurl')
const largeimageurl = this.extractAttribute(attributes, 'largeimageurl')
const ssd = this.extractAttribute(attributes, 'ssd') // ИСПРАВЛЕНИЕ: Извлекаем SSD узла
const hasDetails = this.extractAttribute(attributes, 'hasdetails') === 'true'
if (unitid && name) {
@ -1170,7 +1217,8 @@ class LaximoService {
link: hasDetails,
code: code || undefined,
imageurl: imageurl || undefined,
largeimageurl: largeimageurl || undefined
largeimageurl: largeimageurl || undefined,
ssd: ssd || undefined // ИСПРАВЛЕНИЕ: Добавляем SSD узла для дальнейшего использования
}
console.log('📦 Найден узел каталога:', {
@ -1178,6 +1226,7 @@ class LaximoService {
name,
code,
imageurl: imageurl ? imageurl.substring(0, 50) + '...' : 'отсутствует',
ssd: ssd ? `${ssd.substring(0, 50)}...` : 'отсутствует', // ИСПРАВЛЕНИЕ: Показываем SSD узла
hasDetails
})
groups.push(group)
@ -1452,6 +1501,9 @@ class LaximoService {
const xmlText = await response.text()
console.log('📥 RAW XML ответ длиной:', xmlText.length, 'символов')
console.log('📄 ПОЛНЫЙ XML ОТВЕТ:')
console.log(xmlText)
console.log('📄 ======= КОНЕЦ XML =======')
console.log('📄 Первые 1000 символов XML:')
console.log(xmlText.substring(0, 1000))
@ -1684,7 +1736,6 @@ class LaximoService {
// Дополнительные атрибуты из документации Laximo
grade: attributeMap.get('grade') || undefined,
transmission: attributeMap.get('transmission') || undefined,
doors: attributeMap.get('doors') || undefined,
creationregion: attributeMap.get('creationregion') || undefined,
destinationregion: attributeMap.get('destinationregion') || undefined,
date: attributeMap.get('date') || undefined,
@ -1693,12 +1744,6 @@ class LaximoService {
trimcolor: attributeMap.get('trimcolor') || undefined,
datefrom: attributeMap.get('datefrom') || undefined,
dateto: attributeMap.get('dateto') || undefined,
frame: attributeMap.get('frame') || undefined,
frames: attributeMap.get('frames') || undefined,
framefrom: attributeMap.get('framefrom') || undefined,
frameto: attributeMap.get('frameto') || undefined,
engine1: attributeMap.get('engine1') || undefined,
engine2: attributeMap.get('engine2') || undefined,
engine_info: attributeMap.get('engine_info') || undefined,
engineno: attributeMap.get('engineno') || undefined,
options: attributeMap.get('options') || undefined,
@ -1710,6 +1755,45 @@ class LaximoService {
prodPeriod: attributeMap.get('prodPeriod') || undefined,
carpet_color: attributeMap.get('carpet_color') || undefined,
seat_combination_code: attributeMap.get('seat_combination_code') || undefined,
attributes: Array.from(attributeMap.entries()).map(([key, value]) => {
// Маппинг ключей к человеко-читаемым названиям
const keyNameMap: Record<string, string> = {
'model': 'Модель',
'MVS': 'Код в каталоге',
'MVSDesc': 'Описание',
'specialVersion': 'Версия',
'engine': 'Двигатель',
'variant': 'Исполнение',
'transmission': 'КПП',
'bodytype': 'Тип кузова',
'year': 'Год',
'manufactured': 'Год выпуска',
'date': 'Дата',
'market': 'Рынок',
'grade': 'Класс',
'framecolor': 'Цвет кузова',
'trimcolor': 'Цвет салона',
'engine_info': 'Информация о двигателе',
'engineno': 'Номер двигателя',
'options': 'Опции',
'description': 'Описание',
'notes': 'Примечания',
'creationregion': 'Регион производства',
'destinationregion': 'Регион назначения',
'datefrom': 'Дата с',
'dateto': 'Дата по',
'modelyearfrom': 'Модельный год с',
'modelyearto': 'Модельный год по',
'prodRange': 'Диапазон производства',
'prodPeriod': 'Период производства'
};
return {
key,
name: keyNameMap[key] || key,
value
};
})
}
console.log('🚗 Найден автомобиль:', {
@ -1748,19 +1832,49 @@ class LaximoService {
* Парсит ответ информации об автомобиле
*/
private parseVehicleInfoResponse(xmlText: string): LaximoVehicleInfo | null {
console.log('🔍 parseVehicleInfoResponse - начинаем парсинг...')
console.log('📄 XML длина:', xmlText.length)
console.log('📄 XML первые 500 символов:', xmlText.substring(0, 500))
const resultData = this.extractResultData(xmlText)
if (!resultData) return null
if (!resultData) {
console.log('❌ Не удалось извлечь resultData')
return null
}
console.log('📋 resultData первые 500 символов:', resultData.substring(0, 500))
const rowMatch = resultData.match(/<row([^>]*)>([\s\S]*?)<\/row>/)
if (!rowMatch) return null
if (!rowMatch) {
console.log('❌ Не найден тег <row>')
return null
}
console.log('✅ Найден тег <row>')
const attributes = rowMatch[1]
const content = rowMatch[2]
console.log('📋 Атрибуты row:', attributes)
const getAttribute = (name: string): string => {
const match = attributes.match(new RegExp(`${name}="([^"]*)"`, 'i'))
return match ? match[1] : ''
}
const vehicleid = getAttribute('vehicleid')
const name = getAttribute('name')
const ssd = getAttribute('ssd')
const brand = getAttribute('brand')
const catalog = getAttribute('catalog')
console.log('🔍 Извлеченные атрибуты:', {
vehicleid,
name,
brand,
catalog,
ssd: ssd ? `${ssd.substring(0, 50)}...` : 'отсутствует',
ssdLength: ssd?.length
})
// Парсим атрибуты автомобиля
const vehicleAttributes: LaximoVehicleAttribute[] = []
@ -2087,7 +2201,7 @@ class LaximoService {
throw new Error('SSD parameter is required for ListQuickDetail')
}
const command = `ListQuickDetail:Locale=ru_RU|Catalog=${catalogCode}|VehicleId=${vehicleId}|QuickGroupId=${quickGroupId}|ssd=${ssd}`
const command = `ListQuickDetail:Locale=ru_RU|Catalog=${catalogCode}|VehicleId=${vehicleId}|QuickGroupId=${quickGroupId}|ssd=${ssd}|Localized=true|All=1`
const hmac = this.createHMAC(command)
console.log('📝 ListQuickDetail Command:', command)
@ -2115,9 +2229,19 @@ class LaximoService {
return null
}
// Ищем секцию ListQuickDetail
const quickDetailMatch = resultData.match(/<ListQuickDetail[^>]*>([\s\S]*?)<\/ListQuickDetail>/) ||
resultData.match(/<response[^>]*>([\s\S]*?)<\/response>/)
// Декодируем HTML entities в XML для правильного парсинга
const decodedXML = resultData
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
// Ищем секцию ListQuickDetail в декодированном XML
const quickDetailMatch = decodedXML.match(/<ListQuickDetail[^>]*>([\s\S]*?)<\/ListQuickDetail>/) ||
decodedXML.match(/<response[^>]*>([\s\S]*?)<\/response>/)
if (!quickDetailMatch) {
console.log('❌ Не найдена секция ListQuickDetail')
@ -2152,6 +2276,7 @@ class LaximoService {
const unitId = this.extractAttribute(unitAttributes, 'unitid')
const unitName = this.extractAttribute(unitAttributes, 'name')
const unitCode = this.extractAttribute(unitAttributes, 'code')
const unitSsd = this.extractAttribute(unitAttributes, 'ssd') // ИСПРАВЛЕНИЕ: Извлекаем SSD узла
let imageUrl = this.extractAttribute(unitAttributes, 'imageurl')
let largeImageUrl = this.extractAttribute(unitAttributes, 'largeimageurl')
@ -2163,41 +2288,136 @@ class LaximoService {
largeImageUrl = largeImageUrl.replace(/&amp;/g, '&')
}
console.log('🔧 Найден узел:', { unitId, unitName, unitCode, imageUrl, largeImageUrl })
console.log('🔧 Найден узел:', {
unitId,
unitName,
unitCode,
unitSsd: unitSsd ? `${unitSsd.substring(0, 50)}...` : 'отсутствует',
unitSsdLength: unitSsd?.length,
imageUrl,
largeImageUrl
})
const unit: LaximoUnit = {
unitid: unitId,
name: unitName,
code: unitCode,
ssd: unitSsd, // ИСПРАВЛЕНИЕ: Добавляем SSD узла в объект
imageurl: imageUrl || undefined,
largeimageurl: largeImageUrl || undefined,
details: []
}
// В каждом узле ищем детали (Detail)
const detailPattern = /<Detail([^>]*?)(?:\s*\/>|>([\s\S]*?)<\/Detail>)/g
let detailMatch
console.log('🔍 Содержимое узла (первые 1000 символов):')
console.log(unitContent.substring(0, 1000))
console.log('🔍 Ищем детали в содержимом узла...')
// ВАЖНО: Детали могут быть как внутри Unit content, так и в атрибутах самого Unit
// Сначала ищем в содержимом узла
const detailTagCount = (unitContent.match(/<Detail/g) || []).length
console.log(`🔍 Общее количество тегов <Detail в содержимом: ${detailTagCount}`)
while ((detailMatch = detailPattern.exec(unitContent)) !== null) {
const detailAttributes = detailMatch[1]
const detailContent = detailMatch[2] || ''
// Также проверяем весь блок Unit на предмет деталей
const fullUnitBlock = unitMatch[0] // Полный блок Unit включая все его содержимое
const fullUnitDetailCount = (fullUnitBlock.match(/<Detail/g) || []).length
console.log(`🔍 Общее количество тегов <Detail в полном блоке Unit: ${fullUnitDetailCount}`)
// Будем искать детали в полном блоке Unit, а не только в unitContent
const searchContent = fullUnitBlock
console.log('🔍 Поиск деталей в полном блоке Unit (первые 500 символов):')
console.log(searchContent.substring(0, 500))
const detailMatches: Array<{attributes: string, content: string, fullMatch: string}> = []
// ИСПРАВЛЕНО: Ищем все теги Detail (самозакрывающиеся и полные) в одном регулярном выражении
const detailPattern = /<Detail([^>]*?)(?:\s*\/>|>([\s\S]*?)<\/Detail>)/g
let match
while ((match = detailPattern.exec(searchContent)) !== null) {
detailMatches.push({
attributes: match[1] || '',
content: match[2] || '',
fullMatch: match[0]
})
}
console.log(`🔍 Найдено ${detailMatches.length} элементов Detail в узле`)
let detailCount = 0
for (const detailMatch of detailMatches) {
detailCount++
const detailAttributes = detailMatch.attributes || ''
const detailContent = detailMatch.content || ''
console.log(`🔍 Raw detail match #${detailCount}:`)
console.log('Attributes:', detailAttributes.substring(0, 200))
if (detailContent) {
console.log('Content:', detailContent.substring(0, 200))
}
const detailId = this.extractAttribute(detailAttributes, 'detailid')
const detailName = this.extractAttribute(detailAttributes, 'name')
const oem = this.extractAttribute(detailAttributes, 'oem')
const brand = this.extractAttribute(detailAttributes, 'brand')
const codeonimage = this.extractAttribute(detailAttributes, 'codeonimage')
console.log('🔩 Найдена деталь:', { detailId, detailName, oem, brand })
console.log(`🔩 Найдена деталь #${detailCount}: ${detailName} (${oem})`)
const detail: LaximoDetail = {
detailid: detailId,
detailid: detailId || codeonimage || oem,
name: detailName,
oem: oem,
brand: brand,
attributes: []
}
// Парсим атрибуты детали
// Парсим атрибуты детали из тега Detail
const amount = this.extractAttribute(detailAttributes, 'amount')
const dateRange = this.extractAttribute(detailAttributes, 'date_range')
const matchAttr = this.extractAttribute(detailAttributes, 'match')
const range = this.extractAttribute(detailAttributes, 'range')
const ssdAttr = this.extractAttribute(detailAttributes, 'ssd')
// Добавляем все найденные атрибуты в деталь
if (amount) {
detail.amount = amount
detail.attributes?.push({
key: 'amount',
name: 'Количество',
value: amount
})
}
if (dateRange) {
detail.dateRange = dateRange
detail.attributes?.push({
key: 'date_range',
name: 'date_range',
value: dateRange
})
}
if (matchAttr) {
detail.match = matchAttr === 't' || matchAttr === 'true'
}
if (range) {
detail.range = range
detail.attributes?.push({
key: 'range',
name: 'Диапазон',
value: range
})
}
if (ssdAttr) {
detail.ssd = ssdAttr
}
if (codeonimage) {
detail.codeonimage = codeonimage
}
// Парсим дополнительные атрибуты из содержимого детали
const attributePattern = /<attribute([^>]*?)(?:\s*\/>)/g
let attrMatch
@ -2224,6 +2444,60 @@ class LaximoService {
unit.details!.push(detail)
}
console.log(`📊 Найдено деталей в узле "${unitName}": ${detailCount}`)
// Если детали не найдены, попробуем альтернативный подход
if (detailCount === 0) {
console.log('🔍 Детали не найдены стандартным способом, пробуем альтернативный парсинг...')
// Поиск деталей в исходном XML блоке (до декодирования)
const originalUnitBlock = unitMatch[0]
console.log('🔍 Исходный XML блок (первые 1000 символов):')
console.log(originalUnitBlock.substring(0, 1000))
// Ищем детали в исходном формате
const originalDetailPattern = /<Detail[^>]*(?:\s*\/>|>[\s\S]*?<\/Detail>)/g
let originalMatch
let altDetailCount = 0
while ((originalMatch = originalDetailPattern.exec(originalUnitBlock)) !== null) {
altDetailCount++
const detailTag = originalMatch[0]
console.log(`🔍 Альтернативный парсинг - найдена деталь #${altDetailCount}:`)
console.log(detailTag.substring(0, 300))
// Простой парсинг атрибутов из тега
const nameMatch = detailTag.match(/name="([^"]*)"/)
const oemMatch = detailTag.match(/oem="([^"]*)"/)
const codeMatch = detailTag.match(/codeonimage="([^"]*)"/)
const amountMatch = detailTag.match(/amount="([^"]*)"/)
if (nameMatch || oemMatch) {
const altDetail: LaximoDetail = {
detailid: codeMatch?.[1] || oemMatch?.[1] || `alt_${altDetailCount}`,
name: nameMatch?.[1] || 'Неизвестная деталь',
oem: oemMatch?.[1] || '',
attributes: []
}
if (amountMatch) {
altDetail.attributes?.push({
key: 'amount',
name: 'Количество',
value: amountMatch[1]
})
}
unit.details!.push(altDetail)
console.log(`🔩 Альтернативный парсинг - добавлена деталь: ${altDetail.name} (${altDetail.oem})`)
}
}
if (altDetailCount > 0) {
console.log(`✅ Альтернативный парсинг нашел ${altDetailCount} деталей`)
}
}
quickDetail.units!.push(unit)
}
}
@ -2232,7 +2506,7 @@ class LaximoService {
if (quickDetail.units!.length === 0) {
console.log('🔄 Пробуем альтернативный формат парсинга...')
// Ищем узлы напрямую
// Ищем узлы напрямую в декодированном XML
const directUnitPattern = /<row([^>]*?)(?:\s*\/>|>([\s\S]*?)<\/row>)/g
let directUnitMatch
@ -2261,6 +2535,31 @@ class LaximoService {
console.log(`✅ Обработано ${quickDetail.units!.length} узлов в группе ${quickGroupId}`)
// Подсчитываем общее количество деталей
const totalDetails = quickDetail.units!.reduce((total, unit) => total + (unit.details?.length || 0), 0)
console.log(`📊 Общее количество деталей: ${totalDetails}`)
// Выводим детальную информацию о каждом узле
quickDetail.units!.forEach((unit, index) => {
console.log(`📦 Узел #${index + 1}: ${unit.name} (${unit.details?.length || 0} деталей)`)
unit.details?.forEach((detail, detailIndex) => {
const amountAttr = detail.attributes?.find(attr => attr.key === 'amount')
const amountStr = amountAttr ? ` - ${amountAttr.value}` : ''
console.log(` 🔩 Деталь #${detailIndex + 1}: ${detail.name} (${detail.oem})${amountStr}`)
})
})
// Дополнительная диагностика
if (totalDetails === 0) {
console.log('⚠️ НЕ НАЙДЕНО НИ ОДНОЙ ДЕТАЛИ!')
console.log('🔍 Оригинальные данные (первые 1000 символов):')
console.log(resultData?.substring(0, 1000))
console.log('🔍 Декодированные данные (первые 1000 символов):')
console.log(decodedXML.substring(0, 1000))
} else {
console.log(`✅ Успешно найдено ${totalDetails} деталей`)
}
if (quickDetail.units!.length === 0) {
return null
}
@ -2479,52 +2778,58 @@ class LaximoService {
details: []
}
// В каждом узле ищем детали (Detail)
const detailPattern = /<Detail([^>]*?)(?:\s*\/>|>([\s\S]*?)<\/Detail>)/g
// В каждом узле ищем детали (Detail) - поддерживаем как самозакрывающиеся, так и полные теги
// Используем более простой подход: сначала найдем все вхождения тега Detail
console.log('🔍 Поиск деталей в полном блоке Unit (первые 500 символов):')
console.log(unitMatch[0].substring(0, 500))
// Ищем все теги Detail внутри текущего Unit
const detailPattern = /<Detail[^>]*(?:\s*\/>|>[^<]*<\/Detail>)/g
let detailMatch
let detailCount = 0
while ((detailMatch = detailPattern.exec(unitContent)) !== null) {
// Получаем содержимое Unit для поиска деталей
const unitContentForDetails = unitMatch[2] || ''
// Ищем все детали в содержимом Unit
const allDetailMatches = [...unitContentForDetails.matchAll(/<Detail([^>]*?)(?:\s*\/>)/g)]
console.log(`🔍 Найдено ${allDetailMatches.length} самозакрывающихся тегов Detail в Unit`)
for (const detailMatch of allDetailMatches) {
const detailAttributes = detailMatch[1]
const detailContent = detailMatch[2] || ''
console.log(`🔍 Raw detail match #${detailCount + 1}:`)
console.log('Attributes:', detailAttributes.substring(0, 150))
const detailId = this.extractAttribute(detailAttributes, 'detailid')
const detailName = this.extractAttribute(detailAttributes, 'name')
// Извлекаем основные атрибуты детали
const name = this.extractAttribute(detailAttributes, 'name')
const oem = this.extractAttribute(detailAttributes, 'oem')
const brand = this.extractAttribute(detailAttributes, 'brand')
const amount = this.extractAttribute(detailAttributes, 'amount')
const range = this.extractAttribute(detailAttributes, 'range')
console.log('🔩 Найдена деталь:', { detailId, detailName, oem, brand })
const codeonimage = this.extractAttribute(detailAttributes, 'codeonimage')
const match = this.extractAttribute(detailAttributes, 'match')
const dateRange = this.extractAttribute(detailAttributes, 'date_range')
const ssd = this.extractAttribute(detailAttributes, 'ssd')
const detail: LaximoOEMDetail = {
detailid: detailId,
name: detailName,
oem: oem,
brand: brand,
amount: amount,
range: range,
if (name && oem) {
const detail: LaximoDetail = {
detailid: `${unitId}_${detailCount}`,
name,
oem,
brand: '',
amount: amount || '1pcs',
range: dateRange || '',
codeonimage: codeonimage || '',
match: match === 't',
dateRange: dateRange || '',
ssd: ssd || '',
applicablemodels: '',
note: '',
attributes: []
}
// Парсим атрибуты детали
const attributePattern = /<attribute([^>]*?)(?:\s*\/>)/g
let attrMatch
while ((attrMatch = attributePattern.exec(detailContent)) !== null) {
const attrAttributes = attrMatch[1]
const key = this.extractAttribute(attrAttributes, 'key')
const name = this.extractAttribute(attrAttributes, 'name')
const value = this.extractAttribute(attrAttributes, 'value')
detail.attributes?.push({
key,
name: name || key,
value
})
console.log(`🔩 Найдена деталь #${detailCount + 1}: ${detail.name} (${detail.oem})`)
unit.details!.push(detail)
detailCount++
}
unit.details.push(detail)
}
category.units.push(unit)
@ -3026,7 +3331,7 @@ export class LaximoUnitService extends LaximoService {
console.log('📋 Параметры:', { catalogCode, vehicleId, unitId, ssd: ssd ? `${ssd.substring(0, 30)}...` : 'отсутствует' })
// Используем GetUnitInfo согласно документации Laximo
let command = `GetUnitInfo:Locale=ru_RU|Catalog=${catalogCode}|UnitId=${unitId}`
let command = `GetUnitInfo:Locale=ru_RU|Catalog=${catalogCode}|VehicleId=${vehicleId}|UnitId=${unitId}`
if (ssd && ssd.trim() !== '') {
command += `|ssd=${ssd}`
@ -3061,7 +3366,7 @@ export class LaximoUnitService extends LaximoService {
console.log('📋 Параметры:', { catalogCode, vehicleId, unitId, ssd: ssd ? `${ssd.substring(0, 30)}...` : 'отсутствует' })
// Используем ListDetailByUnit согласно документации Laximo
let command = `ListDetailByUnit:Locale=ru_RU|Catalog=${catalogCode}|UnitId=${unitId}`
let command = `ListDetailByUnit:Locale=ru_RU|Catalog=${catalogCode}|VehicleId=${vehicleId}|UnitId=${unitId}`
if (ssd && ssd.trim() !== '') {
command += `|ssd=${ssd}`
@ -3099,7 +3404,7 @@ export class LaximoUnitService extends LaximoService {
console.log('📋 Параметры:', { catalogCode, vehicleId, unitId, ssd: ssd ? `${ssd.substring(0, 30)}...` : 'отсутствует' })
// Используем ListImageMapByUnit согласно документации Laximo
let command = `ListImageMapByUnit:Catalog=${catalogCode}|UnitId=${unitId}`
let command = `ListImageMapByUnit:Catalog=${catalogCode}|VehicleId=${vehicleId}|UnitId=${unitId}`
if (ssd && ssd.trim() !== '') {
command += `|ssd=${ssd}`
@ -3107,6 +3412,9 @@ export class LaximoUnitService extends LaximoService {
command += `|ssd=`
}
// Добавляем WithLinks=true согласно документации
command += `|WithLinks=true`
const hmac = this.createHMAC(command)
console.log('📝 ListImageMapByUnit Command:', command)

View File

@ -0,0 +1,5 @@
// Wrapper to dynamically load parts-db only on server side
export async function getPartsDb() {
const { partsDb } = await import('./parts-db')
return partsDb
}

46
src/lib/parts-db.ts Normal file
View File

@ -0,0 +1,46 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
// Temporary no-op implementation to avoid requiring 'pg' when parts index is disabled.
class NoopPartsDatabase {
constructor() {
console.warn('Parts DB disabled: using no-op implementation')
}
async createCategoryTable() {
return
}
async insertProducts(_categoryId, _categoryName, _categoryType, products) {
console.warn(`Parts DB noop: insertProducts called for ${products?.length || 0} items`)
return 0
}
async getProducts(_categoryId, _categoryType, _options = {}) {
return { products: [], total: 0 }
}
async getCategoryTables() {
return []
}
async deleteCategoryTable() {
return
}
async testConnection() {
return true
}
async close() {
return
}
// Keep signature compatibility with previous helper
getCategoryTableName(categoryId, categoryType) {
const sanitizedId = String(categoryId || '').replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase()
return `category_${categoryType}_${sanitizedId}`
}
}
export const partsDb = new NoopPartsDatabase()

View File

@ -37,7 +37,7 @@ class PartsAPIService {
private mediaApiKey: string;
constructor() {
this.baseURL = 'https://api.partsapi.ru';
this.baseURL = process.env.PARTSAPI_URL || 'https://api.partsapi.ru';
// Получаем ключи API из переменных окружения
this.categoriesApiKey = process.env.PARTSAPI_CATEGORIES_KEY || '';

View File

@ -1,9 +1,10 @@
import axios from 'axios';
// Интерфейсы для типизации данных Parts Index API
export interface PartsIndexCatalog {
id: string;
name: string;
image: string | null;
image: string;
}
export interface PartsIndexGroup {
@ -14,10 +15,6 @@ export interface PartsIndexGroup {
entityNames: { id: string; name: string; }[];
}
export interface PartsIndexCatalogsResponse {
list: PartsIndexCatalog[];
}
export interface PartsIndexGroupResponse {
id: string;
name: string;
@ -29,35 +26,30 @@ export interface PartsIndexGroupResponse {
subgroups: PartsIndexGroup[];
}
// Новые интерфейсы для товаров каталога
export interface PartsIndexParameter {
id: string;
code: string;
title: string;
type: string;
values: Array<{
id: string;
value: string;
}>;
}
export interface PartsIndexBrand {
id: string;
name: string;
}
export interface PartsIndexProductName {
id: string;
name: string;
}
export interface PartsIndexEntity {
id: string;
name: PartsIndexProductName;
originalName: string;
code: string;
brand: PartsIndexBrand;
parameters: PartsIndexParameter[];
name: {
id: string;
name: string;
};
originalName: string;
brand: {
id: string;
name: string;
};
barcodes: string[];
parameters: {
id: string;
title: string;
code: string;
type: string;
values: {
id: string;
value: string;
title?: string;
}[];
}[];
images: string[];
}
@ -74,6 +66,8 @@ export interface PartsIndexEntitiesResponse {
catalog: {
id: string;
name: string;
image: string;
groups: PartsIndexGroup[];
};
subgroup: {
id: string;
@ -82,24 +76,22 @@ export interface PartsIndexEntitiesResponse {
}
export interface PartsIndexParamsResponse {
list: PartsIndexParam[];
paramsQuery: Record<string, string>;
list: {
id: string;
name: string;
code: string;
type: 'range' | 'dropdown';
values: {
id: string;
value: string;
title?: string;
available: boolean;
}[];
}[];
}
export interface PartsIndexParam {
id: string;
code: string;
name: string;
isGeneral: boolean;
type: 'select' | 'range';
values: PartsIndexParamValue[];
}
export interface PartsIndexParamValue {
value: string;
title: string;
available: boolean;
selected: boolean;
export interface PartsIndexEntityInfoResponse {
list: PartsIndexEntityDetail[];
}
export interface PartsIndexEntityDetail {
@ -155,20 +147,105 @@ export interface PartsIndexEntityDetail {
}[];
}
class PartsIndexService {
private baseURL = 'https://api.parts-index.com/v1';
private apiKey: string;
// Интерфейс для кэша
interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number; // время жизни в миллисекундах
}
constructor() {
this.apiKey = process.env.PARTSINDEX_API_KEY || '';
if (!this.apiKey) {
console.error('❌ PartsIndex API ключ не найден в переменных окружения');
class PartsIndexService {
private baseHost = process.env.PARTSAPI_URL || 'https://api.parts-index.com';
private baseURL = `${this.baseHost}/v1`;
private apiKey = process.env.PARTSAPI_KEY || 'PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE';
private enabled = Boolean(process.env.PARTSAPI_URL) || process.env.PARTSINDEX_ENABLED === 'true';
// Простой in-memory кэш
private cache = new Map<string, CacheEntry<any>>();
private readonly DEFAULT_TTL = 30 * 60 * 1000; // 30 минут
private readonly CATALOGS_TTL = 24 * 60 * 60 * 1000; // 24 часа для каталогов
private readonly GROUPS_TTL = 24 * 60 * 60 * 1000; // 24 часа для групп
private readonly ENTITIES_TTL = 10 * 60 * 1000; // 10 минут для товаров
private readonly PARAMS_TTL = 60 * 60 * 1000; // 1 час для параметров
// Проверяем актуальность кэша
private isValidCacheEntry<T>(entry: CacheEntry<T>): boolean {
return Date.now() - entry.timestamp < entry.ttl;
}
// Получаем данные из кэша
private getFromCache<T>(key: string): T | null {
const entry = this.cache.get(key);
if (entry && this.isValidCacheEntry(entry)) {
console.log(`🔥 Используем кэш для ключа: ${key}`);
return entry.data;
}
if (entry) {
console.log(`🗑️ Удаляем устаревший кэш для ключа: ${key}`);
this.cache.delete(key);
}
return null;
}
// Сохраняем данные в кэш
private setCache<T>(key: string, data: T, ttl: number = this.DEFAULT_TTL): void {
this.cache.set(key, {
data,
timestamp: Date.now(),
ttl
});
console.log(`💾 Сохранено в кэш: ${key} (TTL: ${ttl}ms)`);
}
// Очистка кэша (для административных целей)
public clearCache(): void {
this.cache.clear();
console.log('🗑️ Кэш PartsIndex полностью очищен');
}
// Очистка конкретного типа кэша
public clearCacheByPrefix(prefix: string): void {
const keysToDelete: string[] = [];
this.cache.forEach((_, key) => {
if (key.startsWith(prefix)) {
keysToDelete.push(key);
}
});
keysToDelete.forEach(key => this.cache.delete(key));
console.log(`🗑️ Очищен кэш PartsIndex с префиксом: ${prefix} (${keysToDelete.length} записей)`);
}
// Статистика кэша
public getCacheStats(): { size: number; entries: { key: string; size: number; ttl: number; age: number }[] } {
const entries: { key: string; size: number; ttl: number; age: number }[] = [];
this.cache.forEach((entry, key) => {
const size = JSON.stringify(entry.data).length;
const age = Date.now() - entry.timestamp;
entries.push({ key, size, ttl: entry.ttl, age });
});
return {
size: this.cache.size,
entries: entries.sort((a, b) => b.size - a.size) // Сортируем по размеру
};
}
// Получить список каталогов
async getCatalogs(lang: 'ru' | 'en' = 'ru'): Promise<PartsIndexCatalog[]> {
if (!this.enabled) {
// Disabled: return empty to avoid external calls during local dev
return [];
}
const cacheKey = `catalogs_${lang}`;
// Проверяем кэш
const cached = this.getFromCache<PartsIndexCatalog[]>(cacheKey);
if (cached) {
return cached;
}
try {
console.log('🔍 PartsIndex запрос каталогов:', { lang });
@ -187,7 +264,11 @@ class PartsIndexService {
return [];
}
return response.data.list;
const catalogs = response.data.list;
// Сохраняем в кэш на 1 час
this.setCache(cacheKey, catalogs, this.CATALOGS_TTL);
return catalogs;
} catch (error) {
console.error('❌ Ошибка запроса PartsIndex getCatalogs:', error);
return [];
@ -196,6 +277,17 @@ class PartsIndexService {
// Получить группы каталога
async getCatalogGroups(catalogId: string, lang: 'ru' | 'en' = 'ru'): Promise<PartsIndexGroup[]> {
if (!this.enabled) {
return [];
}
const cacheKey = `groups_${catalogId}_${lang}`;
// Проверяем кэш
const cached = this.getFromCache<PartsIndexGroup[]>(cacheKey);
if (cached) {
return cached;
}
try {
console.log('🔍 PartsIndex запрос групп каталога:', { catalogId, lang });
@ -218,27 +310,118 @@ class PartsIndexService {
return [];
}
let groups: PartsIndexGroup[];
// Если есть подгруппы, возвращаем их
if (groupData.subgroups.length > 0) {
console.log('📁 Найдено подгрупп:', groupData.subgroups.length);
return groupData.subgroups;
groups = groupData.subgroups;
} else {
// Если подгрупп нет, создаем группу из самого каталога
console.log('📝 Подгрупп нет, возвращаем главную группу');
groups = [{
id: groupData.id,
name: groupData.name,
image: groupData.image,
subgroups: [],
entityNames: groupData.entityNames
}];
}
// Если подгрупп нет, создаем группу из самого каталога
console.log('📝 Подгрупп нет, возвращаем главную группу');
return [{
id: groupData.id,
name: groupData.name,
image: groupData.image,
subgroups: [],
entityNames: groupData.entityNames
}];
// Сохраняем в кэш на 24 часа
this.setCache(cacheKey, groups, this.GROUPS_TTL);
return groups;
} catch (error) {
console.error('❌ Ошибка запроса PartsIndex getCatalogGroups:', error);
return [];
}
}
// Новый метод: получить ВСЕ товары каталога (с пагинацией)
async getAllCatalogEntities(
catalogId: string,
groupId: string,
options: {
lang?: 'ru' | 'en';
q?: string;
engineId?: string;
generationId?: string;
params?: Record<string, any>;
maxItems?: number;
} = {}
): Promise<PartsIndexEntity[]> {
const {
lang = 'ru',
q,
engineId,
generationId,
params,
maxItems = 10000
} = options;
try {
if (!this.enabled) {
return [];
}
console.log('🔍 PartsIndex запрос ВСЕХ товаров каталога:', {
catalogId,
groupId,
lang,
q,
maxItems
});
const allEntities: PartsIndexEntity[] = [];
let currentPage = 1;
const itemsPerPage = 100; // Увеличиваем размер страницы для эффективности
let hasMorePages = true;
while (hasMorePages && allEntities.length < maxItems) {
const response = await this.getCatalogEntities(catalogId, groupId, {
lang,
limit: itemsPerPage,
page: currentPage,
q,
engineId,
generationId,
params
});
if (!response || !response.list || response.list.length === 0) {
hasMorePages = false;
break;
}
allEntities.push(...response.list);
console.log(`📄 Страница ${currentPage}: получено ${response.list.length} товаров, всего: ${allEntities.length}`);
// Проверяем, есть ли следующая страница
hasMorePages = response.pagination && response.pagination.page.next !== null && response.list.length === itemsPerPage;
currentPage++;
// Защита от бесконечного цикла
if (currentPage > 100) {
console.warn('⚠️ Достигнут лимит страниц (100), прерываем загрузку');
break;
}
// Небольшая задержка между запросами, чтобы не перегружать API
if (hasMorePages) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
console.log(`✅ PartsIndex получено всего товаров: ${allEntities.length}`);
return allEntities;
} catch (error) {
console.error('❌ Ошибка получения всех товаров PartsIndex:', error);
return [];
}
}
// Новый метод: получить товары каталога
async getCatalogEntities(
catalogId: string,
@ -253,17 +436,30 @@ class PartsIndexService {
params?: Record<string, any>;
} = {}
): Promise<PartsIndexEntitiesResponse | null> {
try {
const {
lang = 'ru',
limit = 25,
page = 1,
q,
engineId,
generationId,
params
} = options;
const {
lang = 'ru',
limit = 25,
page = 1,
q,
engineId,
generationId,
params
} = options;
if (!this.enabled) {
return null;
}
// Создаем ключ кэша на основе всех параметров
const cacheKey = `entities_${catalogId}_${groupId}_${lang}_${limit}_${page}_${q || 'no-query'}_${engineId || 'no-engine'}_${generationId || 'no-generation'}_${JSON.stringify(params || {})}`;
// Проверяем кэш (кэшируем товары на короткое время)
const cached = this.getFromCache<PartsIndexEntitiesResponse>(cacheKey);
if (cached) {
return cached;
}
try {
console.log('🔍 PartsIndex запрос товаров каталога:', {
catalogId,
groupId,
@ -316,7 +512,11 @@ class PartsIndexService {
return null;
}
return response.data;
const result = response.data;
// Сохраняем в кэш на 10 минут (товары могут изменяться)
this.setCache(cacheKey, result, this.ENTITIES_TTL);
return result;
} catch (error) {
console.error('❌ Ошибка запроса PartsIndex getCatalogEntities:', error);
return null;
@ -335,15 +535,24 @@ class PartsIndexService {
q?: string;
} = {}
): Promise<PartsIndexParamsResponse | null> {
try {
const {
lang = 'ru',
engineId,
generationId,
params,
q
} = options;
const {
lang = 'ru',
engineId,
generationId,
params,
q
} = options;
// Создаем ключ кэша на основе всех параметров
const cacheKey = `params_${catalogId}_${groupId}_${lang}_${q || 'no-query'}_${engineId || 'no-engine'}_${generationId || 'no-generation'}_${JSON.stringify(params || {})}`;
// Проверяем кэш
const cached = this.getFromCache<PartsIndexParamsResponse>(cacheKey);
if (cached) {
return cached;
}
try {
console.log('🔍 PartsIndex запрос параметров каталога:', {
catalogId,
groupId,
@ -392,15 +601,27 @@ class PartsIndexService {
return null;
}
return response.data;
const result = response.data;
// Сохраняем в кэш на 1 час
this.setCache(cacheKey, result, this.PARAMS_TTL);
return result;
} catch (error) {
console.error('❌ Ошибка запроса PartsIndex getCatalogParams:', error);
return null;
}
}
// Получить полную структуру категорий с подкатегориями
// Получить полную структуру категорий с подкатегориями (оптимизированная версия)
async getCategoriesWithGroups(lang: 'ru' | 'en' = 'ru'): Promise<Array<PartsIndexCatalog & { groups: PartsIndexGroup[] }>> {
const cacheKey = `categories_with_groups_${lang}`;
// Проверяем кэш
const cached = this.getFromCache<Array<PartsIndexCatalog & { groups: PartsIndexGroup[] }>>(cacheKey);
if (cached) {
return cached;
}
try {
console.log('🔍 PartsIndex запрос полной структуры категорий');
@ -413,17 +634,43 @@ class PartsIndexService {
}
// Для каждого каталога получаем его группы
const catalogsWithGroups = await Promise.all(
catalogs.map(async (catalog) => {
const groups = await this.getCatalogGroups(catalog.id, lang);
return {
...catalog,
groups
};
})
);
// Ограничиваем количество одновременных запросов
const BATCH_SIZE = 3;
const catalogsWithGroups: Array<PartsIndexCatalog & { groups: PartsIndexGroup[] }> = [];
console.log('✅ PartsIndex полная структуря получена:', catalogsWithGroups.length, 'каталогов');
for (let i = 0; i < catalogs.length; i += BATCH_SIZE) {
const batch = catalogs.slice(i, i + BATCH_SIZE);
const batchResults = await Promise.all(
batch.map(async (catalog) => {
try {
const groups = await this.getCatalogGroups(catalog.id, lang);
return {
...catalog,
groups
};
} catch (error) {
console.error(`❌ Ошибка загрузки групп для каталога ${catalog.id}:`, error);
return {
...catalog,
groups: []
};
}
})
);
catalogsWithGroups.push(...batchResults);
// Небольшая задержка между батчами для снижения нагрузки на API
if (i + BATCH_SIZE < catalogs.length) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
console.log('✅ PartsIndex полная структура получена:', catalogsWithGroups.length, 'каталогов');
// Сохраняем в кэш на 24 часа
this.setCache(cacheKey, catalogsWithGroups, this.CATALOGS_TTL);
return catalogsWithGroups;
} catch (error) {
@ -439,6 +686,9 @@ class PartsIndexService {
lang: 'ru' | 'en' = 'ru'
): Promise<PartsIndexEntityDetail | null> {
try {
if (!this.enabled) {
return null;
}
console.log('🔍 PartsIndex запрос детали товара:', { catalogId, entityId, lang });
const response = await axios.get(`${this.baseURL}/catalogs/${catalogId}/entities/${entityId}`, {
@ -485,6 +735,9 @@ class PartsIndexService {
lang: 'ru' | 'en' = 'ru'
): Promise<PartsIndexEntityDetail | null> {
try {
if (!this.enabled) {
return null;
}
console.log('🔍 PartsIndex прямой поиск по артикулу:', { code, brand, lang });
const params: any = {
@ -537,6 +790,9 @@ class PartsIndexService {
lang: 'ru' | 'en' = 'ru'
): Promise<PartsIndexEntity | null> {
try {
if (!this.enabled) {
return null;
}
console.log('🔍 PartsIndex поиск товара в конкретной категории:', {
catalogId,
groupId,
@ -587,4 +843,4 @@ class PartsIndexService {
}
}
export const partsIndexService = new PartsIndexService();
export const partsIndexService = new PartsIndexService();

View File

@ -4,59 +4,3 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Утилиты для работы с датами
export const formatDate = (dateString: string | Date, options?: Intl.DateTimeFormatOptions): string => {
try {
const date = typeof dateString === 'string' ? new Date(dateString) : dateString
if (isNaN(date.getTime())) {
return 'Неизвестно'
}
const defaultOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric'
}
return date.toLocaleDateString('ru-RU', options || defaultOptions)
} catch {
return 'Неизвестно'
}
}
export const formatDateTime = (dateString: string | Date): string => {
return formatDate(dateString, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
export const formatRelativeTime = (dateString: string | Date): string => {
try {
const date = typeof dateString === 'string' ? new Date(dateString) : dateString
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) {
return 'только что'
} else if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60)
return `${minutes} мин. назад`
} else if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600)
return `${hours} ч. назад`
} else if (diffInSeconds < 2592000) {
const days = Math.floor(diffInSeconds / 86400)
return `${days} дн. назад`
} else {
return formatDate(date)
}
} catch {
return 'Неизвестно'
}
}

View File

@ -24,5 +24,15 @@ export function middleware(request: NextRequest) {
// Указываем, для каких путей применять middleware
export const config = {
matcher: ['/api/:path*'],
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
*/
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
'/api/:path*'
],
}

View File

@ -5,6 +5,9 @@ CMS_PORT=3000
# Подключение к внешней PostgreSQL базе
DATABASE_URL=postgresql://username:password@your-db-host:5432/protekauto_cms
# База данных для сохранения данных запчастей
PARTSDB_URL=postgresql://username:password@your-db-host:5432/protekauto_parts
# ===== АВТОРИЗАЦИЯ =====
# Секретный ключ для NextAuth (генерируйте случайно)
NEXTAUTH_SECRET=your-super-secret-key-here-change-me
@ -69,6 +72,10 @@ PARTSAPI_MEDIA_KEY=230d8c7118a36cc6d36d72681b76982b
# API ключ для PartsIndex (каталог автотоваров)
PARTSINDEX_API_KEY=PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE
# ===== PARTSAPI =====
# URL для API Parts-Index
PARTSAPI_URL=https://api.parts-index.com
# ===== S3 ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Альтернативное название бакета (если отличается от AWS_BUCKET_NAME)
AWS_S3_BUCKET=your_s3_bucket_name

View File

@ -1,57 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ["var(--font-geist-sans)", "sans-serif"],
mono: ["var(--font-geist-mono)", "monospace"],
},
},
},
plugins: [],
}