Compare commits
6 Commits
0ccb009773
...
main
Author | SHA1 | Date | |
---|---|---|---|
c17c903bea | |||
10d4d41e95 | |||
08f76a7633 | |||
94ed190869 | |||
f96207c129 | |||
7fc55ab9c3 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -41,3 +41,4 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated
|
||||
.zzap-session.json
|
||||
|
@ -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
|
||||
|
37
README.md
37
README.md
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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
1381
package-lock.json
generated
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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
20
scripts/gateway.ts
Normal 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
6
src/app/admin/page.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function AdminRedirect() {
|
||||
redirect('/dashboard')
|
||||
}
|
||||
|
6
src/app/admin/zzap/page.tsx
Normal file
6
src/app/admin/zzap/page.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function AdminZzapRedirect() {
|
||||
redirect('/dashboard/zzap')
|
||||
}
|
||||
|
79
src/app/api/ai/chat/route.ts
Normal file
79
src/app/api/ai/chat/route.ts
Normal 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' }
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
71
src/app/api/zzap/history/route.ts
Normal file
71
src/app/api/zzap/history/route.ts
Normal 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' }
|
||||
})
|
||||
}
|
||||
}
|
666
src/app/api/zzap/screenshot/route.ts
Normal file
666
src/app/api/zzap/screenshot/route.ts
Normal 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(/&/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(/&/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' }
|
||||
})
|
||||
}
|
||||
}
|
125
src/app/dashboard/ai/page.tsx
Normal file
125
src/app/dashboard/ai/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
332
src/app/dashboard/kraja/page.tsx
Normal file
332
src/app/dashboard/kraja/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
182
src/app/dashboard/zzap/page.tsx
Normal file
182
src/app/dashboard/zzap/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
381
src/components/kraja/KrajaCategories.tsx
Normal file
381
src/components/kraja/KrajaCategories.tsx
Normal 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>
|
||||
)
|
||||
}
|
538
src/components/kraja/KrajaCategoryItems.tsx
Normal file
538
src/components/kraja/KrajaCategoryItems.tsx
Normal 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>
|
||||
)
|
||||
}
|
172
src/components/kraja/KrajaSavedTables.tsx
Normal file
172
src/components/kraja/KrajaSavedTables.tsx
Normal 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">Используйте кнопки "Сохранить" в категориях для создания таблиц</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>
|
||||
)
|
||||
}
|
48
src/components/ui/scroll-area.tsx
Normal file
48
src/components/ui/scroll-area.tsx
Normal 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 }
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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: 'Ошибка очистки корзины'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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!
|
||||
}
|
||||
`
|
5
src/lib/parts-db-wrapper.ts
Normal file
5
src/lib/parts-db-wrapper.ts
Normal 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
46
src/lib/parts-db.ts
Normal 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()
|
@ -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 || '';
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user