Compare commits

...

6 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
31 changed files with 5249 additions and 60 deletions

1
.gitignore vendored
View File

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

View File

@ -56,6 +56,7 @@ 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

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

@ -29,6 +29,7 @@ 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}
@ -79,6 +80,7 @@ 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}

View File

@ -25,7 +25,7 @@ const nextConfig = {
domains: ['localhost'],
},
// Настройки webpack для CSS
// Настройки webpack для CSS и server-only packages
webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => {
// Убеждаемся, что CSS правильно обрабатывается
if (!dev && !isServer) {
@ -37,6 +37,27 @@ const nextConfig = {
};
}
// Исключаем 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;
},
};

1381
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,6 +24,7 @@
"@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",
@ -34,8 +36,10 @@
"@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,7 @@
"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",
@ -79,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",
@ -1406,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",
@ -1415,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",
@ -1424,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",
@ -1455,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",
@ -2281,6 +2965,17 @@
"@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"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@ -2544,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",
@ -3320,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",
@ -4398,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",
@ -4715,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": {
@ -4834,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",
@ -5452,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",
@ -5556,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"
@ -5593,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",
@ -5656,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"
@ -5925,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"
@ -6862,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",
@ -6899,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,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",
@ -7247,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",
@ -7658,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",
@ -7702,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",
@ -7724,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",
@ -7750,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",
@ -8175,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",
@ -9021,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",
@ -9281,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",
@ -9308,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",
@ -9682,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",
@ -10766,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",
@ -10837,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",
@ -11541,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",
@ -11914,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",
@ -11944,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",
@ -12307,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",
@ -12317,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",
@ -12420,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",
@ -12540,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",
@ -12923,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",
@ -13168,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",
@ -13317,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",
@ -13325,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,6 +35,7 @@
"@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",
@ -44,8 +47,10 @@
"@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,7 @@
"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",
@ -89,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

@ -418,6 +418,23 @@ model PartsSearchHistory {
@@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
@ -797,6 +814,43 @@ enum DiscountCodeType {
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

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

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

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

@ -15,7 +15,10 @@ import {
Receipt,
Palette,
Star,
Image
Image,
BarChart3,
Shield,
Bot
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useAuth } from '@/components/providers/AuthProvider'
@ -30,11 +33,26 @@ 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',
@ -147,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

@ -1467,6 +1467,138 @@ export const GET_PARTSINDEX_CATEGORIES = gql`
}
`;
// 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 {

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'
@ -2297,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 {
// Проверяем входные параметры
@ -2323,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({
@ -2376,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)
@ -2390,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 {
@ -2399,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:', {
@ -2487,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. Определяем название товара и собираем данные
@ -2549,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,
@ -2561,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}`,
@ -3018,7 +3087,9 @@ export const resolvers = {
lang,
limit,
page,
q
q,
params,
hasParams: !!params
})
// Преобразуем строку params в объект если передан
@ -3026,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, {
@ -3064,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('Не удалось получить товары каталога')
@ -3804,10 +3972,13 @@ export const resolvers = {
// Если нет изображений и есть артикул и бренд, пытаемся получить из PartsIndex
if (product.article && product.brand) {
try {
const partsIndexEntity = await partsIndexService.searchEntityByCode(
product.article,
product.brand
)
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) {
// Создаем временные изображения для отображения (не сохраняем в БД)
@ -3893,10 +4064,13 @@ export const resolvers = {
// Если нет изображений и есть артикул и бренд, пытаемся получить из PartsIndex
if (product.article && product.brand) {
try {
const partsIndexEntity = await partsIndexService.searchEntityByCode(
product.article,
product.brand
)
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) {
// Создаем временные изображения для отображения (не сохраняем в БД)
@ -3988,10 +4162,13 @@ export const resolvers = {
// Если нет изображений и есть артикул и бренд, пытаемся получить из PartsIndex
if (product.article && product.brand) {
try {
const partsIndexEntity = await partsIndexService.searchEntityByCode(
product.article,
product.brand
)
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) {
// Создаем временные изображения для отображения (не сохраняем в БД)
@ -4046,6 +4223,27 @@ export const resolvers = {
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;
}
}
},
@ -9570,6 +9768,427 @@ export const resolvers = {
}
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

@ -966,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!
@ -1060,6 +1061,9 @@ export const typeDefs = gql`
# Новые поступления
newArrivals(limit: Int = 8): [Product!]!
# Корзина
getCart: Cart
}
type AuthPayload {
@ -1271,6 +1275,19 @@ export const typeDefs = gql`
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 {
@ -1707,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!
@ -1722,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 {
@ -1748,6 +1841,7 @@ export const typeDefs = gql`
rating: Float
supplier: String!
canPurchase: Boolean!
isInCart: Boolean!
}
type ExternalOffer {
@ -1760,6 +1854,7 @@ export const typeDefs = gql`
deliveryTime: Int!
deliveryTimeMax: Int!
quantity: Int!
isInCart: Boolean!
warehouse: String!
warehouseName: String
rejects: Float
@ -2342,4 +2437,58 @@ export const typeDefs = gql`
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

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

@ -155,8 +155,10 @@ interface CacheEntry<T> {
}
class PartsIndexService {
private baseURL = 'https://api.parts-index.com/v1';
private apiKey = 'PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE';
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>>();
@ -232,6 +234,10 @@ class PartsIndexService {
// Получить список каталогов
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}`;
// Проверяем кэш
@ -271,6 +277,9 @@ class PartsIndexService {
// Получить группы каталога
async getCatalogGroups(catalogId: string, lang: 'ru' | 'en' = 'ru'): Promise<PartsIndexGroup[]> {
if (!this.enabled) {
return [];
}
const cacheKey = `groups_${catalogId}_${lang}`;
// Проверяем кэш
@ -329,6 +338,90 @@ class PartsIndexService {
}
}
// Новый метод: получить ВСЕ товары каталога (с пагинацией)
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,
@ -353,6 +446,10 @@ class PartsIndexService {
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 || {})}`;
@ -589,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}`, {
@ -635,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 = {
@ -687,6 +790,9 @@ class PartsIndexService {
lang: 'ru' | 'en' = 'ru'
): Promise<PartsIndexEntity | null> {
try {
if (!this.enabled) {
return null;
}
console.log('🔍 PartsIndex поиск товара в конкретной категории:', {
catalogId,
groupId,
@ -737,4 +843,4 @@ class PartsIndexService {
}
}
export const partsIndexService = new PartsIndexService();
export const partsIndexService = new PartsIndexService();

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