From 338d4cfb3543aac4c5053608d26f920fba1cfac1 Mon Sep 17 00:00:00 2001 From: arys Date: Mon, 6 Apr 2026 12:10:39 +0500 Subject: [PATCH] fix --- facebook_integration_guide.md | 60 ++ frontend_guide.md | 56 ++ package-lock.json | 147 +++ package.json | 2 + src/api/campaign.api.ts | 160 +++ src/components/targeting/PostPreviewCard.vue | 69 ++ .../targeting/TargetingPageHeader.vue | 45 + src/components/targeting/TargetingPanel.vue | 26 + .../targeting/TargetingStateCard.vue | 37 + src/composables/useCampaignBudget.ts | 39 + src/composables/useCampaignMetrics.ts | 155 +++ src/composables/useCampaignStatusPoller.ts | 138 +++ src/composables/useEnumLabels.ts | 28 + src/composables/useMarketingMedia.ts | 36 + src/composables/useTargetingApi.js | 65 ++ src/constants/targetingLabels.ts | 34 + src/main.js | 4 + src/router/index.js | 42 + src/service/MarketingV3Service.js | 23 +- src/service/TargetingService.js | 161 +++ src/stores/campaign.store.ts | 340 +++++++ src/stores/targeting.js | 38 + src/types/campaign.types.ts | 249 +++++ src/types/targeting.js | 122 +++ .../marketing/MarketingAnalysisV3List.vue | 428 +++++--- src/views/pages/marketing/MarketingMain.vue | 480 +++++++-- .../pages/marketing/MarketingPromotion.vue | 364 +++---- .../pages/marketing/MarketingStrategyV3.vue | 244 ++++- .../marketing/MarketingStrategyV3List.vue | 409 ++++---- .../targetologist/CampaignInsightsDetail.vue | 575 +++++++++++ .../targetologist/CampaignWizard.vue | 545 ++++++++++ .../targetologist/TargetologistDashboard.vue | 615 ++++++++++++ .../components/CampaignAdSets.vue | 506 ++++++++++ .../components/CampaignInsights.vue | 311 ++++++ .../components/CampaignMetrics.vue | 251 +++++ .../components/CampaignPrediction.vue | 930 ++++++++++++++++++ .../components/OAuthConnectionWidget.vue | 289 ++++++ .../components/PipelineTimeline.vue | 110 +++ .../TargetingRecommendationPanel.vue | 315 ++++++ .../targetologist/composables/useTargeting.ts | 199 ++++ src/views/targeting/TargetingAnalysisView.vue | 214 ++++ src/views/targeting/TargetingCreativeView.vue | 291 ++++++ src/views/targeting/TargetingInputView.vue | 125 +++ src/views/targeting/TargetingLayout.vue | 94 ++ src/views/targeting/TargetingStrategyView.vue | 202 ++++ ...timestamp-1775383080692-2b97e170850468.mjs | 47 + 46 files changed, 8942 insertions(+), 678 deletions(-) create mode 100644 facebook_integration_guide.md create mode 100644 frontend_guide.md create mode 100644 src/api/campaign.api.ts create mode 100644 src/components/targeting/PostPreviewCard.vue create mode 100644 src/components/targeting/TargetingPageHeader.vue create mode 100644 src/components/targeting/TargetingPanel.vue create mode 100644 src/components/targeting/TargetingStateCard.vue create mode 100644 src/composables/useCampaignBudget.ts create mode 100644 src/composables/useCampaignMetrics.ts create mode 100644 src/composables/useCampaignStatusPoller.ts create mode 100644 src/composables/useEnumLabels.ts create mode 100644 src/composables/useMarketingMedia.ts create mode 100644 src/composables/useTargetingApi.js create mode 100644 src/constants/targetingLabels.ts create mode 100644 src/service/TargetingService.js create mode 100644 src/stores/campaign.store.ts create mode 100644 src/stores/targeting.js create mode 100644 src/types/campaign.types.ts create mode 100644 src/types/targeting.js create mode 100644 src/views/pages/marketing/targetologist/CampaignInsightsDetail.vue create mode 100644 src/views/pages/marketing/targetologist/CampaignWizard.vue create mode 100644 src/views/pages/marketing/targetologist/TargetologistDashboard.vue create mode 100644 src/views/pages/marketing/targetologist/components/CampaignAdSets.vue create mode 100644 src/views/pages/marketing/targetologist/components/CampaignInsights.vue create mode 100644 src/views/pages/marketing/targetologist/components/CampaignMetrics.vue create mode 100644 src/views/pages/marketing/targetologist/components/CampaignPrediction.vue create mode 100644 src/views/pages/marketing/targetologist/components/OAuthConnectionWidget.vue create mode 100644 src/views/pages/marketing/targetologist/components/PipelineTimeline.vue create mode 100644 src/views/pages/marketing/targetologist/components/TargetingRecommendationPanel.vue create mode 100644 src/views/pages/marketing/targetologist/composables/useTargeting.ts create mode 100644 src/views/targeting/TargetingAnalysisView.vue create mode 100644 src/views/targeting/TargetingCreativeView.vue create mode 100644 src/views/targeting/TargetingInputView.vue create mode 100644 src/views/targeting/TargetingLayout.vue create mode 100644 src/views/targeting/TargetingStrategyView.vue create mode 100644 vite.config.mjs.timestamp-1775383080692-2b97e170850468.mjs diff --git a/facebook_integration_guide.md b/facebook_integration_guide.md new file mode 100644 index 0000000..94de97e --- /dev/null +++ b/facebook_integration_guide.md @@ -0,0 +1,60 @@ +# 📘 Гайд по интеграции Facebook Marketing (Frontend) +Этот документ описывает обновленный флоу автоматизации постинга. Теперь система работает по принципу «Настроил и забыл», автоматически обменивая временные токены на долгоживущие. + +--- + +## 1. Одинкратная настройка (Token Exchange) +Прежде чем запускать постинг, нужно получить долгоживущий токен страницы. Это делается один раз через специальный хелпер-эндпоинт. + +### Эндпоинт +`POST /api/facebook/config/exchange-token` + +### Payload +```json +{ + "shortToken": "USER_ACCESS_TOKEN_FROM_META_DEVELOPERS", + "pageId": "YOUR_FACEBOOK_PAGE_ID" +} +``` + +### Что происходит +- Фронтенд берет временный User Access Token (из Meta for Developers или через FB Login). +- Бекенд обращается к Graph API и обменивает его на бессрочный Page Access Token. +- Бекенд возвращает этот токен. Его нужно один раз прописать в `application.properties` в поле `facebook.page.token`. + +--- + +## 2. Запуск маркетинговой кампании (Launch) +Когда у пользователя готова стратегия (V3 Analysis -> V3 Strategy), он нажимает кнопку «Запустить кампанию». + +### Эндпоинт +`POST /api/targeting/launch/{strategyId}` + +### Описание процесса +- Бекенд находит стратегию по `strategyId`. +- Извлекает первый запланированный пост из `postCalendar`. +- ИИ генерирует финальные креативы (текст, хэштеги). +- `FacebookPostingService` автоматически публикует пост на стену страницы, используя сохраненный долгоживущий токен. +- В ответе фронтенд получает `TargetingLaunchResultDto` со статусом публикации. + +--- + +## 3. Статус кампании и предсказания +Теперь эндпоинты работают стабильно и автоматически запускают генерацию, если данных еще нет. + +### Проверка предсказания (AI Prediction) +`GET /api/marketing/targeting/campaigns/{id}/prediction` + +- Если готово: возвращает объект предсказания (JSON). +- Если в процессе: возвращает статус `202 Accepted` и сообщение `"status": "PROCESSING"`. Фронтенду нужно повторить запрос через 5-10 секунд. + +### Проверка статуса +`GET /api/marketing/targeting/campaigns/{id}/status` + +--- + +## 💡 Советы для фронтенда +- Лоадеры: при вызове `/prediction` всегда обрабатывайте статус `202` — показывайте пользователю «ИИ анализирует вашу кампанию...». +- Обработка ошибок: если бекенд вернет ошибку токена, направьте пользователя на страницу настроек для повторного обмена токена (Шаг 1). + +Этот гайд можно смело отдавать фронтенд-разработчикам. Всё настроено так, чтобы им не пришлось возиться с низкоуровневой логикой Meta API. diff --git a/frontend_guide.md b/frontend_guide.md new file mode 100644 index 0000000..3b0201e --- /dev/null +++ b/frontend_guide.md @@ -0,0 +1,56 @@ +# Frontend Guide: Marketing API (2026-04-03) + +## Media (Images / Videos) + +Media files are available via direct links and **do not require** `Authorization: Bearer ...`. + +Use `imageFilename` (or `mediaFilename`) from API responses and prefix it with: + +`https://api.konturai.kz/api/marketing/` + +Example: + +```html + +``` + +## Campaign Status Polling + +Endpoint: + +`GET /api/marketing/targeting/campaigns/{id}/status` + +Response includes `isTerminal`: + +- If `isTerminal === true`, campaign status is terminal (success or error). +- Stop polling when terminal and proceed to prediction fetch. + +Suggested polling loop: + +```js +const poll = setInterval(async () => { + const res = await fetch(`/api/marketing/targeting/campaigns/${id}/status`); + const data = await res.json(); + setStatus(data.status); + if (data.isTerminal) { + clearInterval(poll); + fetchPrediction(); + } +}, 3000); +``` + +## AI Prediction + +Endpoint: + +`GET /api/marketing/targeting/campaigns/{id}/prediction` + +Behavior: + +- `200 OK`: prediction is ready. +- `202 Accepted`: prediction is generating in background. Repeat request in ~10-15 seconds. + +Optional manual trigger (if automation did not start): + +`POST /api/marketing/targeting/campaigns/{id}/predict` + diff --git a/package-lock.json b/package-lock.json index 4669a07..aa0ec5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,11 @@ "@primeuix/themes": "^1.0.0", "chart.js": "3.3.2", "chartjs-plugin-datalabels": "^2.0.0", + "date-fns": "^4.1.0", "katex": "^0.16.27", "marked": "^17.0.1", "marked-katex-extension": "^5.1.6", + "pinia": "^3.0.4", "primeicons": "^7.0.0", "primevue": "^4.3.1", "tailwindcss-primeui": "^0.5.0", @@ -1089,6 +1091,30 @@ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.3.tgz", "integrity": "sha512-0MiMsFma/HqA6g3KLKn+AGpL1kgKhFWszC9U29NfpWK5LE7bjeXxySWJrOJ77hBz+TBrBQ7o4QJqbPbqbs8rJw==" }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, "node_modules/@vue/eslint-config-prettier": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@vue/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz", @@ -1289,6 +1315,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -1487,6 +1522,21 @@ "integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==", "dev": true }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -1516,6 +1566,16 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", @@ -2105,6 +2165,12 @@ "node": ">= 0.4" } }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -2231,6 +2297,18 @@ "node": ">=8" } }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2467,6 +2545,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/mlly": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.1.tgz", @@ -2694,6 +2778,12 @@ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", "dev": true }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2718,6 +2808,36 @@ "node": ">=0.10.0" } }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, "node_modules/pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", @@ -3023,6 +3143,12 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -3165,6 +3291,15 @@ "node": ">=0.10.0" } }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -3322,6 +3457,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 1b3af94..7320be0 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,11 @@ "@primeuix/themes": "^1.0.0", "chart.js": "3.3.2", "chartjs-plugin-datalabels": "^2.0.0", + "date-fns": "^4.1.0", "katex": "^0.16.27", "marked": "^17.0.1", "marked-katex-extension": "^5.1.6", + "pinia": "^3.0.4", "primeicons": "^7.0.0", "primevue": "^4.3.1", "tailwindcss-primeui": "^0.5.0", diff --git a/src/api/campaign.api.ts b/src/api/campaign.api.ts new file mode 100644 index 0000000..faa7767 --- /dev/null +++ b/src/api/campaign.api.ts @@ -0,0 +1,160 @@ +import { API_CONFIG, DEFAULT_REQUEST_CONFIG } from '@/config/api'; +import AuthService from '@/service/AuthService'; +import type { + ApiEnvelope, + CampaignDetails, + CampaignInsight, + CampaignPredictionResponse, + CampaignStatusResponse +} from '@/types/campaign.types'; + +type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; + +type ApiRequestError = Error & { + status?: number; + data?: unknown; +}; + +const toApiError = (fallback: string, payload: unknown, status?: number): ApiRequestError => { + const base = toError(fallback, payload) as ApiRequestError; + if (status !== undefined) { + base.status = status; + } + if (payload !== null && payload !== undefined) { + base.data = payload; + } + return base; +}; + +const toError = (fallback: string, payload: unknown): Error => { + if (typeof payload === 'object' && payload !== null && 'message' in payload) { + const message = (payload as { message?: unknown }).message; + if (typeof message === 'string' && message.trim()) { + return new Error(message); + } + } + return new Error(fallback); +}; + +const unwrap = (payload: unknown): T => { + if (typeof payload === 'object' && payload !== null && 'data' in payload) { + const env = payload as ApiEnvelope; + if (env.data !== undefined) { + return env.data; + } + } + return payload as T; +}; + +const requestWithFallback = async ( + method: HttpMethod, + paths: string[], + fallbackMessage: string, + body?: Record +): Promise => { + let lastError: Error | null = null; + + for (const path of paths) { + try { + const response = await AuthService.authFetch(`${API_CONFIG.BASE_URL}${path}`, { + method, + ...DEFAULT_REQUEST_CONFIG, + ...(body ? { body: JSON.stringify(body) } : {}) + }); + + const raw = await response.json().catch(() => null); + + if (!response.ok) { + if (response.status === 404) { + lastError = toApiError(fallbackMessage, raw, response.status); + continue; + } + throw toApiError(fallbackMessage, raw, response.status); + } + + return unwrap(raw); + } catch (error) { + lastError = error instanceof Error ? error : new Error(fallbackMessage); + } + } + + throw lastError ?? new Error(fallbackMessage); +}; + +const requestTreat202AsPending = async (method: HttpMethod, path: string, fallbackMessage: string): Promise => { + const response = await AuthService.authFetch(`${API_CONFIG.BASE_URL}${path}`, { + method, + ...DEFAULT_REQUEST_CONFIG + }); + + const raw = await response.json().catch(() => null); + + // For prediction, 202 means "PROCESSING" and should be handled by polling logic. + if (response.status === 202) { + throw toApiError(fallbackMessage, raw, response.status); + } + + if (!response.ok) { + throw toApiError(fallbackMessage, raw, response.status); + } + + return unwrap(raw); +}; + +const campaignPath = (id: string): string[] => [`/api/marketing/targeting/campaigns/${id}`]; +const campaignSubPath = (id: string, suffix: string): string[] => [ + `/api/marketing/targeting/campaigns/${id}/${suffix}` +]; + +const insightsPaths = (id: string): string[] => [ + `/api/marketing/targeting/campaigns/${id}/insights`, + // Some backend versions require `datePreset` (Meta API style). Keep fallbacks so Insights tab always works. + `/api/marketing/targeting/campaigns/${id}/insights?datePreset=last_7d`, + `/api/marketing/targeting/campaigns/${id}/insights?datePreset=lifetime` +]; + +export const campaignApi = { + getCampaignById: (id: string) => + requestWithFallback('GET', campaignPath(id), 'Не удалось загрузить кампанию'), + getCampaignStatus: (id: string) => + requestWithFallback('GET', campaignSubPath(id, 'status'), 'Не удалось получить статус кампании'), + getCampaignPrediction: (id: string) => + requestTreat202AsPending( + 'GET', + `/api/marketing/targeting/campaigns/${id}/prediction`, + 'Не удалось загрузить AI-прогноз кампании' + ), + predictCampaign: (id: string) => + requestWithFallback( + 'POST', + campaignSubPath(id, 'predict'), + 'Не удалось обновить AI-прогноз кампании' + ), + getCampaignInsights: (id: string) => + requestWithFallback('GET', insightsPaths(id), 'Не удалось загрузить инсайты кампании'), + syncCampaignInsights: (id: string) => + requestWithFallback('POST', campaignSubPath(id, 'sync-insights'), 'Не удалось синхронизировать инсайты'), + pauseCampaign: (id: string) => + requestWithFallback('POST', campaignSubPath(id, 'pause'), 'Не удалось поставить кампанию на паузу'), + resumeCampaign: (id: string) => + requestWithFallback('POST', campaignSubPath(id, 'resume'), 'Не удалось возобновить кампанию'), + retryCampaignLaunch: (id: string) => + requestWithFallback('POST', campaignSubPath(id, 'retry'), 'Не удалось повторно запустить кампанию'), + updateBudget: (id: string, totalBudget: number, dailyBudget: number) => + requestWithFallback('PUT', campaignSubPath(id, 'budget'), 'Не удалось обновить бюджет', { + totalBudget, + dailyBudget + }), + updateAudience: (id: string, audience: any) => + requestWithFallback('PUT', campaignSubPath(id, 'audience'), 'Не удалось обновить аудиторию', audience), + getAdSets: (id: string) => + requestWithFallback('GET', campaignSubPath(id, 'adsets'), 'Не удалось загрузить группы объявлений'), + updateAdSet: (id: string, adSetId: string, adSet: any) => + requestWithFallback( + 'PUT', + [`/api/marketing/targeting/campaigns/${id}/adsets/${adSetId}`], + 'Не удалось обновить группу объявлений', + adSet + ) +}; + diff --git a/src/components/targeting/PostPreviewCard.vue b/src/components/targeting/PostPreviewCard.vue new file mode 100644 index 0000000..116375b --- /dev/null +++ b/src/components/targeting/PostPreviewCard.vue @@ -0,0 +1,69 @@ + + + diff --git a/src/components/targeting/TargetingPageHeader.vue b/src/components/targeting/TargetingPageHeader.vue new file mode 100644 index 0000000..211f142 --- /dev/null +++ b/src/components/targeting/TargetingPageHeader.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/components/targeting/TargetingPanel.vue b/src/components/targeting/TargetingPanel.vue new file mode 100644 index 0000000..c075cfd --- /dev/null +++ b/src/components/targeting/TargetingPanel.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/components/targeting/TargetingStateCard.vue b/src/components/targeting/TargetingStateCard.vue new file mode 100644 index 0000000..96ab0b8 --- /dev/null +++ b/src/components/targeting/TargetingStateCard.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/composables/useCampaignBudget.ts b/src/composables/useCampaignBudget.ts new file mode 100644 index 0000000..d892210 --- /dev/null +++ b/src/composables/useCampaignBudget.ts @@ -0,0 +1,39 @@ +import { computed, unref, type Ref } from 'vue'; +import type { CampaignDetails } from '@/types/campaign.types'; + +type CampaignSource = CampaignDetails | null | Ref; + +export const useCampaignBudget = (campaignSource: CampaignSource) => { + const campaign = computed(() => unref(campaignSource)); + const budgetRecommendation = computed( + () => campaign.value?.aiRecommendations?.targetingRecommendation?.budgetRecommendation ?? null + ); + + const dailyBudget = computed(() => budgetRecommendation.value?.dailyBudget ?? null); + const monthlyBudget = computed(() => budgetRecommendation.value?.monthlyBudget ?? null); + const totalBudget = computed( + () => campaign.value?.totalBudgetKzt ?? campaign.value?.budgetKzt ?? monthlyBudget.value ?? null + ); + const campaignDays = computed(() => { + if (campaign.value?.plannedDays != null) { + return campaign.value.plannedDays; + } + if (dailyBudget.value && monthlyBudget.value) { + return Math.round(monthlyBudget.value / dailyBudget.value); + } + return null; + }); + const targetReach = computed( + () => campaign.value?.targetReach ?? budgetRecommendation.value?.estimatedDailyReach ?? null + ); + + return { + budgetRecommendation, + dailyBudget, + monthlyBudget, + totalBudget, + campaignDays, + targetReach + }; +}; + diff --git a/src/composables/useCampaignMetrics.ts b/src/composables/useCampaignMetrics.ts new file mode 100644 index 0000000..8fb55d9 --- /dev/null +++ b/src/composables/useCampaignMetrics.ts @@ -0,0 +1,155 @@ +import type { CampaignMetricRange } from '@/types/campaign.types'; + +export type MetricTrend = 'up' | 'down' | 'neutral' | 'none'; + +export interface MetricDisplayState { + value: string; + isNoData: boolean; + isZero: boolean; + tooltip: string | null; + helperText: string | null; +} + +const numberFormatter = new Intl.NumberFormat('ru-RU'); +const currencyFormatter = new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: 'KZT', + maximumFractionDigits: 0 +}); + +const toNumber = (value: string | number | null | undefined): number | null => { + if (value == null) { + return null; + } + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + const normalized = value.replace(',', '.').replace(/[^0-9.-]/g, ''); + if (!normalized.trim()) { + return null; + } + const numeric = Number(normalized); + return Number.isFinite(numeric) ? numeric : null; +}; + +export const formatGeo = (geo: string[] | string): string => { + if (Array.isArray(geo)) { + return geo.join(', '); + } + return geo; +}; + +export const formatArrayField = (value: string[] | string | null | undefined): string => { + if (!value) { + return '—'; + } + return Array.isArray(value) ? value.join(', ') : value; +}; + +export const formatMetricValue = (value: string | number | null | undefined, unit?: string): string => { + if (value == null) { + return '—'; + } + + if (typeof value === 'string') { + if (value.includes('%')) { + return value; + } + + const numericFromString = toNumber(value); + if (numericFromString == null) { + return value; + } + + if (unit === '%') { + return `${numberFormatter.format(numericFromString)}%`; + } + if (unit === 'KZT') { + return currencyFormatter.format(numericFromString); + } + if (Math.abs(numericFromString) >= 1000) { + return numberFormatter.format(numericFromString); + } + return Number.isInteger(numericFromString) ? String(numericFromString) : numericFromString.toFixed(2); + } + + if (unit === '%') { + return `${numberFormatter.format(value)}%`; + } + if (unit === 'KZT') { + return currencyFormatter.format(value); + } + if (Math.abs(value) >= 1000) { + return numberFormatter.format(value); + } + return Number.isInteger(value) ? String(value) : value.toFixed(2); +}; + +export const getMetricDisplayState = ( + value: string | number | null | undefined, + unit?: string +): MetricDisplayState => { + if (value == null) { + return { + value: '—', + isNoData: true, + isZero: false, + tooltip: 'Нет данных', + helperText: null + }; + } + + const numeric = toNumber(value); + if (numeric === 0) { + return { + value: formatMetricValue(0, unit), + isNoData: false, + isZero: true, + tooltip: null, + helperText: 'Данные появятся через 24-48 ч' + }; + } + + return { + value: formatMetricValue(value, unit), + isNoData: false, + isZero: false, + tooltip: null, + helperText: null + }; +}; + +export const compareMetricWithRange = ( + actual: string | number | null | undefined, + range: CampaignMetricRange | null | undefined +): MetricTrend => { + const actualNumber = toNumber(actual); + if (actualNumber == null || !range) { + return 'none'; + } + + const min = range.min ?? null; + const max = range.max ?? null; + if (min == null || max == null) { + return 'none'; + } + + if (actualNumber > max) { + return 'up'; + } + if (actualNumber < min) { + return 'down'; + } + return 'neutral'; +}; + +export const formatRangeLabel = (range: CampaignMetricRange | null | undefined): string => { + if (!range || range.min == null || range.max == null) { + return 'Прогноз: —'; + } + const unit = range.unit ?? undefined; + const minLabel = formatMetricValue(range.min, unit); + const maxLabel = formatMetricValue(range.max, unit); + return `Прогноз: ${minLabel} – ${maxLabel}`; +}; + diff --git a/src/composables/useCampaignStatusPoller.ts b/src/composables/useCampaignStatusPoller.ts new file mode 100644 index 0000000..4e564bf --- /dev/null +++ b/src/composables/useCampaignStatusPoller.ts @@ -0,0 +1,138 @@ +import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'; +import { useToast } from 'primevue/usetoast'; +import { campaignApi } from '@/api/campaign.api'; +import type { CampaignStatus, CampaignStatusResponse } from '@/types/campaign.types'; + +interface UseCampaignStatusPollerOptions { + campaignId: Readonly>; + status: Ref; + onStatusUpdate?: (payload: CampaignStatusResponse) => void | Promise; + onTerminal?: (payload: CampaignStatusResponse) => void | Promise; +} + +const FINAL_STATUSES: CampaignStatus[] = ['ACTIVE', 'FAILED', 'COMPLETED']; + +const normalizeCampaignStatus = (value: unknown): CampaignStatus => { + const raw = String(value ?? '').trim(); + const lower = raw.toLowerCase(); + if (lower === 'draft' || lower === 'created') return 'CREATED'; + if (lower === 'orchestrating' || lower === 'processing') return 'ORCHESTRATING'; + if (lower === 'mapping') return 'MAPPING'; + if (lower === 'publishing') return 'PUBLISHING'; + if (lower === 'active') return 'ACTIVE'; + if (lower === 'paused') return 'PAUSED'; + if (lower === 'completed' || lower === 'done') return 'COMPLETED'; + if (lower === 'failed' || lower === 'error') return 'FAILED'; + return raw.toUpperCase() as CampaignStatus; +}; + +export const useCampaignStatusPoller = ({ campaignId, status, onStatusUpdate, onTerminal }: UseCampaignStatusPollerOptions) => { + const toast = useToast(); + const intervalId = ref(null); + const terminalReached = ref(false); + const isPolling = computed(() => intervalId.value !== null); + const shouldPoll = computed(() => { + if (!campaignId.value) { + return false; + } + return !terminalReached.value; + }); + + const stopPolling = (): void => { + if (intervalId.value !== null) { + window.clearInterval(intervalId.value); + intervalId.value = null; + } + }; + + const handleStatusPayload = async (payload: CampaignStatusResponse): Promise => { + const normalizedPayload: CampaignStatusResponse = { + ...payload, + status: normalizeCampaignStatus(payload.status) + }; + + const previous = status.value; + status.value = normalizedPayload.status; + if (onStatusUpdate) { + await onStatusUpdate(normalizedPayload); + } + + const isTerminal = normalizedPayload.isTerminal ?? FINAL_STATUSES.includes(normalizedPayload.status); + + if (normalizedPayload.status === 'ACTIVE' && previous !== 'ACTIVE') { + toast.add({ + severity: 'success', + summary: 'Статус кампании', + detail: 'Кампания запущена!', + life: 4000 + }); + } + + if (normalizedPayload.status === 'FAILED' && previous !== 'FAILED') { + toast.add({ + severity: 'error', + summary: 'Ошибка кампании', + detail: normalizedPayload.message || 'Не удалось запустить кампанию', + life: 5000 + }); + } + + if (isTerminal) { + terminalReached.value = true; + if (onTerminal) { + await onTerminal(normalizedPayload); + } + stopPolling(); + } + }; + + const pollOnce = async (): Promise => { + if (!campaignId.value) { + return; + } + try { + const payload = await campaignApi.getCampaignStatus(campaignId.value); + await handleStatusPayload(payload); + } catch { + // Ошибки поллинга не пробрасываем, чтобы не ломать интерфейс. + } + }; + + const startPolling = (): void => { + if (!campaignId.value || intervalId.value !== null || !shouldPoll.value) { + return; + } + void pollOnce(); + intervalId.value = window.setInterval(() => { + void pollOnce(); + }, 3000); + }; + + watch( + () => status.value, + () => { + if (shouldPoll.value) { + startPolling(); + } else { + stopPolling(); + } + } + ); + + onMounted(() => { + if (shouldPoll.value) { + startPolling(); + } + }); + + onBeforeUnmount(() => { + stopPolling(); + }); + + return { + isPolling, + startPolling, + stopPolling + }; +}; + diff --git a/src/composables/useEnumLabels.ts b/src/composables/useEnumLabels.ts new file mode 100644 index 0000000..ffdfc1f --- /dev/null +++ b/src/composables/useEnumLabels.ts @@ -0,0 +1,28 @@ +import { + CAMPAIGN_STATUS_LABELS, + OBJECTIVE_LABELS, + PLATFORM_LABELS, + TARGETING_TYPE_LABELS +} from '@/constants/targetingLabels'; + +const toLabel = (value: string | null | undefined, map: Record): string => { + if (!value) { + return '—'; + } + return map[value] ?? value; +}; + +export const useEnumLabels = () => { + const targetingTypeLabel = (value: string | null | undefined): string => toLabel(value, TARGETING_TYPE_LABELS); + const platformLabel = (value: string | null | undefined): string => toLabel(value, PLATFORM_LABELS); + const campaignStatusLabel = (value: string | null | undefined): string => toLabel(value, CAMPAIGN_STATUS_LABELS); + const objectiveLabel = (value: string | null | undefined): string => toLabel(value, OBJECTIVE_LABELS); + + return { + targetingTypeLabel, + platformLabel, + campaignStatusLabel, + objectiveLabel + }; +}; + diff --git a/src/composables/useMarketingMedia.ts b/src/composables/useMarketingMedia.ts new file mode 100644 index 0000000..68e2e63 --- /dev/null +++ b/src/composables/useMarketingMedia.ts @@ -0,0 +1,36 @@ +import { API_CONFIG } from '@/config/api'; + +/** + * Marketing media (image_*.png, video_*.mp4) is served by backend without auth headers. + * Backend expects URLs like: `${BASE_URL}/api/marketing/`. + */ +export const resolveMarketingMediaUrl = (value: string | null | undefined): string => { + if (!value) return ''; + + const trimmed = String(value).trim(); + if (!trimmed) return ''; + + // Data URIs and absolute URLs are already good to go. + if (trimmed.startsWith('data:')) return trimmed; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + + const withoutLeadingSlash = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed; + + // If backend already returned a full API path, just prefix domain. + if (withoutLeadingSlash.startsWith('api/')) { + return `${API_CONFIG.BASE_URL}/${withoutLeadingSlash}`; + } + + // Common case: backend returns just `image_*.png` / `video_*.mp4`. + if ( + withoutLeadingSlash.startsWith('image_') || + withoutLeadingSlash.startsWith('video_') || + withoutLeadingSlash.startsWith('audio_') + ) { + return `${API_CONFIG.BASE_URL}/api/marketing/${withoutLeadingSlash}`; + } + + // Fallback: treat as root-relative path on API domain. + return `${API_CONFIG.BASE_URL}/${withoutLeadingSlash}`; +}; + diff --git a/src/composables/useTargetingApi.js b/src/composables/useTargetingApi.js new file mode 100644 index 0000000..73a46df --- /dev/null +++ b/src/composables/useTargetingApi.js @@ -0,0 +1,65 @@ +import AuthService from '@/service/AuthService'; +import { API_CONFIG } from '@/config/api'; + +const TARGETING_API_BASE_URL = import.meta.env.VITE_TARGETING_API_URL || API_CONFIG.BASE_URL; + +async function parseResponse(response) { + const data = await response.json().catch(() => null); + + if (!response.ok) { + const error = new Error(data?.message || data?.error || `HTTP ${response.status}`); + error.status = response.status; + error.data = data; + throw error; + } + + return data?.data ?? data; +} + +export function useTargetingApi() { + async function generateAnalysis(topic) { + const response = await AuthService.authFetch(`${TARGETING_API_BASE_URL}/api/targeting/analysis`, { + method: 'POST', + body: JSON.stringify({ topic }) + }); + + return parseResponse(response); + } + + async function generateStrategy(analysis) { + const response = await AuthService.authFetch(`${TARGETING_API_BASE_URL}/api/targeting/strategy`, { + method: 'POST', + body: JSON.stringify({ analysis }) + }); + + return parseResponse(response); + } + + async function generateCreative(captionIdea, analysis, strategy) { + const response = await AuthService.authFetch(`${TARGETING_API_BASE_URL}/api/targeting/creative`, { + method: 'POST', + body: JSON.stringify({ + captionIdea, + analysis, + strategy + }) + }); + + return parseResponse(response); + } + + async function launchFromStrategy(strategyId) { + const response = await AuthService.authFetch(`${TARGETING_API_BASE_URL}/api/targeting/launch/${strategyId}`, { + method: 'POST' + }); + + return parseResponse(response); + } + + return { + generateAnalysis, + generateStrategy, + generateCreative, + launchFromStrategy + }; +} diff --git a/src/constants/targetingLabels.ts b/src/constants/targetingLabels.ts new file mode 100644 index 0000000..1b4d033 --- /dev/null +++ b/src/constants/targetingLabels.ts @@ -0,0 +1,34 @@ +export const TARGETING_TYPE_LABELS: Record = { + COLD_INTEREST: 'Холодная аудитория', + BEHAVIORAL: 'Поведенческий таргетинг', + RETARGETING: 'Ретаргетинг', + LOOKALIKE: 'Похожая аудитория', + MIXED: 'Смешанный таргетинг' +}; + +export const PLATFORM_LABELS: Record = { + FACEBOOK: 'Facebook', + INSTAGRAM: 'Instagram', + TIKTOK: 'TikTok', + YOUTUBE: 'YouTube' +}; + +export const CAMPAIGN_STATUS_LABELS: Record = { + CREATED: 'Кампания создана', + ORCHESTRATING: 'ИИ строит сегменты', + MAPPING: 'Маппинг адсетов', + PUBLISHING: 'Публикация в Meta/TikTok', + ACTIVE: 'Кампания активна', + PAUSED: 'На паузе', + COMPLETED: 'Завершена', + FAILED: 'Ошибка публикации' +}; + +export const OBJECTIVE_LABELS: Record = { + LEADS: 'Лиды', + TRAFFIC: 'Трафик', + AWARENESS: 'Охват', + CONVERSIONS: 'Конверсии', + ENGAGEMENT: 'Вовлечённость', + APP_INSTALLS: 'Установки приложения' +}; diff --git a/src/main.js b/src/main.js index 590dab7..e8d9f92 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,5 @@ import { createApp } from 'vue'; +import { createPinia } from 'pinia'; import App from './App.vue'; import router from './router'; @@ -8,11 +9,14 @@ import ConfirmationService from 'primevue/confirmationservice'; import ToastService from 'primevue/toastservice'; import '@/assets/styles.scss'; +import '@/assets/kai-v4.css'; import 'katex/dist/katex.min.css'; const app = createApp(App); +const pinia = createPinia(); app.use(router); +app.use(pinia); app.use(PrimeVue, { theme: { preset: Aura, diff --git a/src/router/index.js b/src/router/index.js index 45628eb..0dda433 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,6 +1,7 @@ import AppLayout from '@/layout/AppLayout.vue'; import AuthService from '@/service/AuthService'; import IdentityService from '@/service/IdentityService'; +import { useTargetingStore } from '@/stores/targeting'; import { createRouter, createWebHistory } from 'vue-router'; const router = createRouter({ @@ -197,6 +198,18 @@ const router = createRouter({ name: 'smm-dashboard', component: () => import('@/views/smm/SmmDashboard.vue'), meta: { requiresAuth: true } + }, + { + path: '/targeting', + component: () => import('@/views/targeting/TargetingLayout.vue'), + meta: { requiresAuth: true }, + children: [ + { path: '', redirect: '/targeting/input' }, + { path: 'input', name: 'targeting-input', component: () => import('@/views/targeting/TargetingInputView.vue') }, + { path: 'analysis', name: 'targeting-analysis', component: () => import('@/views/targeting/TargetingAnalysisView.vue') }, + { path: 'strategy', name: 'targeting-strategy', component: () => import('@/views/targeting/TargetingStrategyView.vue') }, + { path: 'creative', name: 'targeting-creative', component: () => import('@/views/targeting/TargetingCreativeView.vue') } + ] } ] }, @@ -301,6 +314,24 @@ const router = createRouter({ component: () => import('@/views/pages/marketing/MarketingAnalysisV3.vue'), meta: { requiresAuth: true } }, + { + path: '/marketing-analysis/v3/targetologist/dashboard', + name: 'marketing-targetologist-dashboard', + component: () => import('@/views/pages/marketing/targetologist/TargetologistDashboard.vue'), + meta: { requiresAuth: true } + }, + { + path: '/marketing-analysis/v3/targetologist/wizard', + name: 'marketing-targetologist-wizard', + component: () => import('@/views/pages/marketing/targetologist/CampaignWizard.vue'), + meta: { requiresAuth: true } + }, + { + path: '/marketing-analysis/v3/targetologist/campaign/:id', + name: 'marketing-targetologist-campaign', + component: () => import('@/views/pages/marketing/targetologist/CampaignInsightsDetail.vue'), + meta: { requiresAuth: true } + }, { path: '/pages/notfound', name: 'notfound', @@ -344,6 +375,17 @@ router.beforeEach(async (to) => { return { name: 'login', query: { redirect: to.fullPath } }; } } + + const targetingStore = useTargetingStore(); + if (to.name === 'targeting-analysis' && !targetingStore.analysis.value) { + return { name: 'targeting-input' }; + } + if (to.name === 'targeting-strategy' && !targetingStore.strategy.value) { + return { name: 'targeting-input' }; + } + if (to.name === 'targeting-creative' && !targetingStore.creative.value && !to.query.strategyId) { + return { name: 'targeting-input' }; + } }); export default router; diff --git a/src/service/MarketingV3Service.js b/src/service/MarketingV3Service.js index 8f88c64..6b190bd 100644 --- a/src/service/MarketingV3Service.js +++ b/src/service/MarketingV3Service.js @@ -202,7 +202,7 @@ class MarketingV3Service { */ async executeTaskManually(taskId) { try { - const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/v3/tasks/${taskId}/execute`, { + const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/analysis/tasks/${taskId}/execute`, { method: 'POST', ...DEFAULT_REQUEST_CONFIG }); @@ -217,6 +217,27 @@ class MarketingV3Service { } } + /** + * Умная публикация первого поста стратегии в Facebook + * POST /api/marketing/targeting/strategy/{strategyId}/facebook/publish + */ + async publishStrategyToFacebook(strategyId) { + try { + const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/strategy/${strategyId}/facebook/publish`, { + method: 'POST', + ...DEFAULT_REQUEST_CONFIG + }); + const result = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(result?.message || result?.error?.message || 'Ошибка при автоматической публикации FB'); + } + return result; + } catch (error) { + console.error('Ошибка при автоматической публикации FB:', error); + throw error; + } + } + /** * Регенерация изображения для одного поста (B2) * POST /api/marketing/v3/strategy/{strategyId}/post/{postIndex}/regenerate-image diff --git a/src/service/TargetingService.js b/src/service/TargetingService.js new file mode 100644 index 0000000..ec06d73 --- /dev/null +++ b/src/service/TargetingService.js @@ -0,0 +1,161 @@ +import { API_CONFIG, DEFAULT_REQUEST_CONFIG } from '@/config/api'; +import AuthService from './AuthService'; + +const API_BASE_URL = API_CONFIG.BASE_URL; + +class TargetingService { + // --- OAuth --- + async getFacebookOAuthUrl() { + try { + const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/facebook/oauth-url`); + const result = await response.json(); + if (!response.ok) throw new Error(result?.message || 'Ошибка генерации OAuth-ссылки Facebook'); + return result?.url ?? (result?.data?.url); + } catch (error) { + console.error('Ошибка Facebook OAuth:', error); + throw error; + } + } + + async getTikTokOAuthUrl() { + try { + const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/tiktok/oauth-url`); + const result = await response.json(); + if (!response.ok) throw new Error(result?.message || 'Ошибка генерации OAuth-ссылки TikTok'); + return result?.url ?? (result?.data?.url); + } catch (error) { + console.error('Ошибка TikTok OAuth:', error); + throw error; + } + } + + // --- Account Selection --- + async getFacebookAdAccounts() { + try { + const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/facebook/ad-accounts`); + const result = await response.json(); + if (!response.ok) throw new Error(result?.message || 'Ошибка загрузки рекламных кабинетов Facebook'); + return result?.data ?? result; + } catch (error) { + console.error('Ошибка загрузки Facebook Accounts:', error); + throw error; + } + } + + async selectAdAccount(platform, adAccountId) { + try { + const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/account/select`, { + method: 'POST', + ...DEFAULT_REQUEST_CONFIG, + body: JSON.stringify({ platform: platform.toUpperCase(), adAccountId }) + }); + const result = await response.json(); + if (!response.ok) throw new Error(result?.message || 'Ошибка привязки рекламного кабинета'); + return result?.data ?? result; + } catch (error) { + console.error('Ошибка выбора кабинета:', error); + throw error; + } + } + + // --- Campaigns Orchestration --- + async createCampaign(payload) { + try { + const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/campaigns`, { + method: 'POST', + ...DEFAULT_REQUEST_CONFIG, + body: JSON.stringify(payload) + }); + const result = await response.json(); + if (!response.ok) throw new Error(result?.message || 'Ошибка при запуске кампании ИИ'); + return result?.data ?? result; + } catch (error) { + console.error('Ошибка запуска AI-кампании:', error); + throw error; + } + } + + async getCampaignById(id) { + try { + const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/campaigns/${id}`); + const result = await response.json(); + if (!response.ok) throw new Error(result?.message || `Ошибка получения данных кампании ${id}`); + return result?.data ?? result; + } catch (error) { + console.error('Ошибка загрузки кампании:', error); + throw error; + } + } + + async getCampaigns(page = 0, size = 10) { + try { + const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/campaigns?page=${page}&size=${size}`); + const result = await response.json(); + if (!response.ok) throw new Error(result?.message || 'Ошибка получения списка кампаний'); + // Support both array returned and page DTO wrapper from spring + return result?.data?.content ?? result?.content ?? result?.data ?? result; + } catch (error) { + console.error('Ошибка загрузки списка кампаний:', error); + throw error; + } + } + + // --- Campaign Control & Analytics --- + async pauseCampaign(id) { + const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/campaigns/${id}/pause`, { method: 'POST' }); + if (!response.ok) throw new Error('Не удалось остановить кампанию'); + return await response.json(); + } + + async resumeCampaign(id) { + const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/campaigns/${id}/resume`, { method: 'POST' }); + if (!response.ok) throw new Error('Не удалось возобновить кампанию'); + return await response.json(); + } + + async publishCampaign(id) { + const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/campaigns/${id}/publish`, { method: 'POST' }); + if (!response.ok) throw new Error('Не удалось опубликовать кампанию в Facebook'); + return await response.json(); + } + + /** + * Умная публикация первого поста стратегии в Facebook. + * POST /api/marketing/targeting/strategy/{strategyId}/facebook/publish + * Бэкенд сам находит первый FB-пост, скачивает медиа из MinIO и публикует. + */ + async publishStrategyToFacebook(strategyId) { + const response = await AuthService.authFetch( + `${API_BASE_URL}/api/marketing/targeting/strategy/${strategyId}/facebook/publish`, + { method: 'POST' } + ); + const result = await response.json().catch(() => null); + if (!response.ok) { + const errMsg = result?.error || result?.message || 'Не удалось опубликовать пост в Facebook'; + throw new Error(errMsg); + } + return result; + } + + async getCampaignInsights(id, datePreset = 'last_7d') { + const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/campaigns/${id}/insights?datePreset=${datePreset}`); + if (!response.ok) throw new Error('Ошибка при загрузке аналитики кампании'); + const r = await response.json(); + return r?.data ?? r; + } + + async syncCampaignInsights(id) { + const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/campaigns/${id}/sync-insights`, { method: 'POST' }); + if (!response.ok) throw new Error('Ошибка обновления статистики Facebook API'); + const r = await response.json(); + return r?.data ?? r; + } + + async optimizeBudget() { + const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/budget/optimize`, { method: 'POST' }); + if (!response.ok) throw new Error('Ошибка запуска оптимизатора бюджета'); + return await response.json(); + } +} + +export default new TargetingService(); diff --git a/src/stores/campaign.store.ts b/src/stores/campaign.store.ts new file mode 100644 index 0000000..dd2355b --- /dev/null +++ b/src/stores/campaign.store.ts @@ -0,0 +1,340 @@ +import { defineStore } from 'pinia'; +import { campaignApi } from '@/api/campaign.api'; +import type { + CampaignDetails, + CampaignInsight, + CampaignPredictionResponse, + CampaignStatus, + CampaignStatusResponse +} from '@/types/campaign.types'; + +interface CampaignLoadingState { + campaign: boolean; + prediction: boolean; + insights: boolean; + status: boolean; + action: boolean; +} + +interface ApiRequestError extends Error { + status?: number; +} + +interface CampaignStoreState { + campaign: CampaignDetails | null; + prediction: CampaignPredictionResponse | null; + insights: CampaignInsight[]; + adSets: any[]; + status: CampaignStatus | null; + loading: CampaignLoadingState; + error: string | null; +} + +const defaultLoadingState = (): CampaignLoadingState => ({ + campaign: false, + prediction: false, + insights: false, + status: false, + action: false +}); + +const sleep = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +const normalizeCampaignStatus = (value: unknown): CampaignStatus | null => { + if (value == null) return null; + const raw = String(value).trim(); + const lower = raw.toLowerCase(); + + // Backend may return lowercase pipeline statuses: draft -> orchestrating -> active. + if (lower === 'draft' || lower === 'created') return 'CREATED'; + if (lower === 'orchestrating' || lower === 'processing') return 'ORCHESTRATING'; + if (lower === 'mapping') return 'MAPPING'; + if (lower === 'publishing') return 'PUBLISHING'; + if (lower === 'active') return 'ACTIVE'; + if (lower === 'paused') return 'PAUSED'; + if (lower === 'completed' || lower === 'done') return 'COMPLETED'; + if (lower === 'failed' || lower === 'error') return 'FAILED'; + + const upper = raw.toUpperCase(); + const allowed: CampaignStatus[] = ['CREATED', 'ORCHESTRATING', 'MAPPING', 'PUBLISHING', 'ACTIVE', 'PAUSED', 'COMPLETED', 'FAILED']; + return allowed.includes(upper as CampaignStatus) ? (upper as CampaignStatus) : null; +}; + +export const useCampaignStore = defineStore('campaign', { + state: (): CampaignStoreState => ({ + campaign: null, + prediction: null, + insights: [], + adSets: [], + status: null, + loading: defaultLoadingState(), + error: null + }), + actions: { + clearError(): void { + this.error = null; + }, + setStatus(status: CampaignStatus): void { + this.status = normalizeCampaignStatus(status) ?? status; + if (this.campaign) { + this.campaign.status = this.status; + } + }, + applyStatusPayload(payload: CampaignStatusResponse): void { + this.status = normalizeCampaignStatus(payload.status) ?? payload.status; + if (this.campaign) { + this.campaign.status = this.status; + } + }, + async fetchCampaign(id: string): Promise { + this.loading.campaign = true; + this.clearError(); + try { + const campaign = await campaignApi.getCampaignById(id); + campaign.status = normalizeCampaignStatus(campaign.status) ?? campaign.status; + this.campaign = campaign; + this.status = campaign.status; + return campaign; + } catch (error) { + this.error = error instanceof Error ? error.message : 'Не удалось загрузить кампанию'; + throw error; + } finally { + this.loading.campaign = false; + } + }, + async fetchStatus(id: string): Promise { + this.loading.status = true; + this.clearError(); + try { + const statusPayload = await campaignApi.getCampaignStatus(id); + statusPayload.status = normalizeCampaignStatus(statusPayload.status) ?? statusPayload.status; + this.applyStatusPayload(statusPayload); + return statusPayload; + } catch (error) { + this.error = error instanceof Error ? error.message : 'Не удалось загрузить статус кампании'; + throw error; + } finally { + this.loading.status = false; + } + }, + async fetchPrediction(id: string): Promise { + this.loading.prediction = true; + this.clearError(); + try { + const prediction = await campaignApi.getCampaignPrediction(id); + this.prediction = prediction; + return prediction; + } catch (error) { + this.error = error instanceof Error ? error.message : 'Не удалось загрузить прогноз кампании'; + throw error; + } finally { + this.loading.prediction = false; + } + }, + async refreshPrediction(id: string): Promise { + this.loading.prediction = true; + this.clearError(); + try { + await campaignApi.predictCampaign(id); + const prediction = await this.waitForPrediction(id); + if (!prediction) { + throw new Error('Прогноз пока не готов. Попробуйте повторить через несколько секунд.'); + } + return prediction; + } catch (error) { + this.error = error instanceof Error ? error.message : 'Не удалось пересчитать прогноз кампании'; + throw error; + } finally { + this.loading.prediction = false; + } + }, + async waitForPrediction(id: string, attempts = 10, delayMs = 5000): Promise { + this.loading.prediction = true; + this.clearError(); + try { + for (let i = 0; i < attempts; i += 1) { + try { + const prediction = await campaignApi.getCampaignPrediction(id); + this.prediction = prediction; + return prediction; + } catch (error) { + const status = (error as ApiRequestError)?.status; + if (status === 404 || status === 202) { + if (i < attempts - 1) { + await sleep(delayMs); + continue; + } + this.prediction = null; + return null; + } + throw error; + } + } + this.prediction = null; + return null; + } catch (error) { + this.error = error instanceof Error ? error.message : 'Не удалось дождаться прогноза кампании'; + throw error; + } finally { + this.loading.prediction = false; + } + }, + async triggerPredictionGeneration(id: string): Promise { + this.loading.prediction = true; + this.clearError(); + try { + await campaignApi.predictCampaign(id); + } catch (error) { + this.error = error instanceof Error ? error.message : 'Не удалось запустить генерацию прогноза'; + throw error; + } finally { + this.loading.prediction = false; + } + }, + async fetchInsights(id: string): Promise { + this.loading.insights = true; + this.clearError(); + try { + const insights = await campaignApi.getCampaignInsights(id); + this.insights = insights; + return insights; + } catch (error) { + this.error = error instanceof Error ? error.message : 'Не удалось загрузить инсайты кампании'; + throw error; + } finally { + this.loading.insights = false; + } + }, + async syncInsights(id: string): Promise { + this.loading.action = true; + this.clearError(); + try { + await campaignApi.syncCampaignInsights(id); + await this.fetchInsights(id); + } catch (error) { + this.error = error instanceof Error ? error.message : 'Не удалось синхронизировать статистику'; + throw error; + } finally { + this.loading.action = false; + } + }, + async fetchAdSets(id: string): Promise { + this.loading.campaign = true; + this.clearError(); + try { + const adSets = await campaignApi.getAdSets(id); + this.adSets = adSets; + return adSets; + } catch (error) { + this.error = error instanceof Error ? error.message : 'Не удалось загрузить группы объявлений'; + throw error; + } finally { + this.loading.campaign = false; + } + }, + async updateBudget(id: string, total: number, daily: number): Promise { + this.loading.action = true; + this.clearError(); + try { + const campaign = await campaignApi.updateBudget(id, total, daily); + this.campaign = campaign; + return campaign; + } catch (error) { + this.error = error instanceof Error ? error.message : 'Не удалось обновить бюджет'; + throw error; + } finally { + this.loading.action = false; + } + }, + async updateAudience(id: string, audience: any): Promise { + this.loading.action = true; + this.clearError(); + try { + const campaign = await campaignApi.updateAudience(id, audience); + this.campaign = campaign; + return campaign; + } catch (error) { + this.error = error instanceof Error ? error.message : 'Не удалось обновить аудиторию'; + throw error; + } finally { + this.loading.action = false; + } + }, + async updateAdSet(id: string, adSetId: string, adSet: any): Promise { + this.loading.action = true; + this.clearError(); + try { + const campaign = await campaignApi.updateAdSet(id, adSetId, adSet); + this.campaign = campaign; + return campaign; + } catch (error) { + this.error = error instanceof Error ? error.message : 'Не удалось обновить группу объявлений'; + throw error; + } finally { + this.loading.action = false; + } + }, + async pauseCampaign(id: string): Promise { + this.loading.action = true; + this.clearError(); + try { + const payload = await campaignApi.pauseCampaign(id); + this.applyStatusPayload(payload); + return payload; + } catch (error) { + this.error = error instanceof Error ? error.message : 'Не удалось поставить кампанию на паузу'; + throw error; + } finally { + this.loading.action = false; + } + }, + async resumeCampaign(id: string): Promise { + this.loading.action = true; + this.clearError(); + try { + const payload = await campaignApi.resumeCampaign(id); + this.applyStatusPayload(payload); + return payload; + } catch (error) { + this.error = error instanceof Error ? error.message : 'Не удалось возобновить кампанию'; + throw error; + } finally { + this.loading.action = false; + } + }, + async retryCampaignLaunch(id: string): Promise { + this.loading.action = true; + this.clearError(); + try { + const payload = await campaignApi.retryCampaignLaunch(id); + this.applyStatusPayload(payload); + return payload; + } catch (error) { + this.error = error instanceof Error ? error.message : 'Не удалось повторить запуск кампании'; + throw error; + } finally { + this.loading.action = false; + } + }, + async createSimilarCampaign(id: string): Promise { + this.loading.action = true; + this.clearError(); + try { + // Since 'similar' endpoint was removed from controller, we redirect to wizard with strategyId + if (this.campaign?.strategyId) { + window.location.href = `/marketing-analysis/v3/targetologist/wizard?strategyId=${this.campaign.strategyId}`; + } + throw new Error('Функционал копирования через API удален. Используйте мастер создания.'); + } catch (error) { + this.error = error instanceof Error ? error.message : 'Не удалось создать похожую кампанию'; + throw error; + } finally { + this.loading.action = false; + } + } + } +}); + diff --git a/src/stores/targeting.js b/src/stores/targeting.js new file mode 100644 index 0000000..12d8c73 --- /dev/null +++ b/src/stores/targeting.js @@ -0,0 +1,38 @@ +import { ref } from 'vue'; + +const topic = ref(''); +const analysis = ref(null); +const strategy = ref(null); +const creative = ref(null); +const selectedCreative = ref(null); +const launchResult = ref(null); +const sourceStrategyId = ref(null); +const sourceStrategyName = ref(''); +const sourceContext = ref('targeting'); + +function reset() { + topic.value = ''; + analysis.value = null; + strategy.value = null; + creative.value = null; + selectedCreative.value = null; + launchResult.value = null; + sourceStrategyId.value = null; + sourceStrategyName.value = ''; + sourceContext.value = 'targeting'; +} + +export function useTargetingStore() { + return { + topic, + analysis, + strategy, + creative, + selectedCreative, + launchResult, + sourceStrategyId, + sourceStrategyName, + sourceContext, + reset + }; +} diff --git a/src/types/campaign.types.ts b/src/types/campaign.types.ts new file mode 100644 index 0000000..2f0ffbf --- /dev/null +++ b/src/types/campaign.types.ts @@ -0,0 +1,249 @@ +export type CampaignStatus = + | 'CREATED' + | 'ORCHESTRATING' + | 'MAPPING' + | 'PUBLISHING' + | 'ACTIVE' + | 'PAUSED' + | 'COMPLETED' + | 'FAILED'; + +export type PlatformType = 'FACEBOOK' | 'INSTAGRAM' | 'TIKTOK' | 'YOUTUBE'; + +export type TargetingType = 'COLD_INTEREST' | 'BEHAVIORAL' | 'RETARGETING' | 'LOOKALIKE' | 'MIXED'; + +export type InsightType = 'SUCCESS' | 'WARNING' | 'TIP' | 'ALERT'; + +export type RecommendationPriority = 'HIGH' | 'MEDIUM' | 'LOW'; + +export type MarketSaturationLevel = 'HIGH' | 'MEDIUM' | 'LOW'; + +export interface ApiEnvelope { + data?: T; + message?: string; +} + +export interface StatusHistoryItem { + status: CampaignStatus; + message?: string | null; + createdAt?: string | null; + timestamp?: string | null; +} + +export interface CampaignMetricRange { + min: number | null; + max: number | null; + unit?: string | null; +} + +export interface PerformanceMetrics { + dataAvailable: boolean; + reach: number | null; + impressions: number | null; + clicks: number | null; + ctr: number | null | string; + conversions: number | null; + cpl: number | null; + spend: number | null; + roas: number | null; +} + +export interface AudienceProfile { + primarySegment?: string | null; + secondarySegment?: string | null; + ageRange?: string | null; + gender?: string | null; + language?: string | null; + geography?: string[] | string | null; + locations?: string[] | string | null; + interests?: string[] | string | null; + behaviors?: string[] | string | null; + exclusions?: string[] | string | null; +} + +export interface BudgetRecommendation { + dailyBudget: number | null; + monthlyBudget: number | null; + bidStrategy?: string | null; + estimatedDailyReach?: number | null; + rationale?: string | null; +} + +export interface RecommendationPhase { + phase?: number | null; + name?: string | null; + duration?: string | null; + objective?: string | null; + budget?: string | null; + targetingType?: string | null; + kpi?: string | null; + tactics?: string[] | null; + expectedSpend?: number | null; +} + +export interface TargetingRecommendation { + recommendedType?: TargetingType | string | null; + recommendedTypeLabel?: string | null; + typeRationale?: string | null; + campaignObjective?: string | null; + campaignObjectiveLabel?: string | null; + campaignObjectiveRationale?: string | null; + estimatedCtr?: number | string | null; + estimatedCpl?: number | null; + estimatedReach?: number | null; + estimatedFrequency?: number | null; + estimatedConversions?: number | null; + recommendedPlatforms?: PlatformType[] | string[] | null; + recommendedAdFormats?: string[] | string | null; + recommendedPlacements?: string[] | string | null; + audienceProfile?: AudienceProfile | null; + budgetRecommendation?: BudgetRecommendation | null; + phases?: RecommendationPhase[] | null; + quickWins?: string[] | null; + warnings?: string[] | null; + competitiveContext?: string | null; + differentiationAdvice?: string | null; + ciiLevel?: MarketSaturationLevel | string | null; + primaryCta?: string | null; + funnelStage?: string | null; + conversionMechanism?: string | null; +} + +export interface AdCreative { + adId?: string | null; + id?: string | null; + headline?: string | null; + primaryText?: string | null; + callToAction?: string | null; + mediaUrl?: string | null; + mediaSize?: string | null; + contentType?: string | null; +} + +export interface CampaignAdSet { + adSetId?: string | null; + id?: string | null; + name?: string | null; + platform?: PlatformType | string | null; + status?: 'DRAFT' | 'ACTIVE' | 'PAUSED' | string | null; + budgetKzt?: number | null; + targetAudience?: string | string[] | null; + ads?: AdCreative[] | null; +} + +export interface CampaignAiRecommendations { + targetingRecommendation?: TargetingRecommendation | null; +} + +export interface CampaignDetails { + id: string; + name: string; + status: CampaignStatus; + strategyId?: string | null; + objective?: string | null; + platforms?: Array | null; + createdAt?: string | null; + startedAt?: string | null; + completedAt?: string | null; + metaCampaignUrl?: string | null; + budgetKzt?: number | null; + totalBudgetKzt?: number | null; + targetReach?: number | null; + plannedDays?: number | null; + performanceMetrics?: PerformanceMetrics | null; + predictedMetrics?: Record | null; + aiRecommendations?: CampaignAiRecommendations | null; + adSets?: CampaignAdSet[] | null; + statusHistory?: StatusHistoryItem[] | null; +} + +export interface CampaignStatusResponse { + campaignId?: string; + status: CampaignStatus; + isTerminal?: boolean; + message?: string | null; + updatedAt?: string | null; + completedAt?: string | null; +} + +export interface PredictedMetricMap { + ctr?: CampaignMetricRange; + cpl?: CampaignMetricRange; + reach?: CampaignMetricRange; + conversions?: CampaignMetricRange; + roas?: CampaignMetricRange; + impressions?: CampaignMetricRange; + clicks?: CampaignMetricRange; + spend?: CampaignMetricRange; + [key: string]: CampaignMetricRange | undefined; +} + +export interface WeeklyForecastPoint { + week: number; + expectedLeads: number; + expectedReach: number; + phaseName?: string | null; + phase?: string | null; + expectedSpend?: number | null; +} + +export interface TopRecommendation { + id?: string | null; + priority: RecommendationPriority | string; + category: string; + title: string; + description: string; + expectedImpact: string; +} + +export interface CampaignPredictionResponse { + id?: string | null; + campaignId?: string | null; + createdAt?: string | null; + latest?: boolean | null; + isLatest?: boolean | null; + modelVersion?: string | null; + predictionScore: number; + predictionLabel: string; + confidence: number; + predictedMetrics: PredictedMetricMap; + weeklyForecast: WeeklyForecastPoint[]; + strengthFactors: string[]; + riskFactors: string[]; + topRecommendations: TopRecommendation[]; + audienceInsights?: { + bestPerformingSegment?: string | null; + recommendedExpansion?: string | null; + exclusionAdvice?: string | null; + } | null; + budgetOptimization?: { + currentAllocation?: string | null; + recommendedReallocation?: string | null; + potentialCplReduction?: string | null; + } | null; + competitiveAnalysis?: { + marketSaturation?: MarketSaturationLevel | string | null; + differentiationScore?: number | null; + competitiveAdvantages?: string[] | null; + } | null; + bestPerformingSegment?: string | null; + recommendedExpansion?: string | null; + exclusionAdvice?: string | null; + currentAllocation?: string | null; + recommendedReallocation?: string | null; + potentialCplReduction?: string | null; + marketSaturation?: MarketSaturationLevel | string | null; + differentiationScore?: number | null; + competitiveAdvantages?: string[] | null; + updatedAt?: string | null; +} + +export interface CampaignInsight { + id: string; + type: InsightType; + title: string; + description: string; + actionRequired: boolean; + suggestedAction?: string | null; + createdAt?: string | null; +} diff --git a/src/types/targeting.js b/src/types/targeting.js new file mode 100644 index 0000000..b671ccd --- /dev/null +++ b/src/types/targeting.js @@ -0,0 +1,122 @@ +/** + * @typedef {Object} TargetAudience + * @property {string} primary + * @property {string} secondary + * @property {string} ageRange + * @property {string} gender + * @property {string} income + * @property {string[]} interests + * @property {string[]} painPoints + * @property {string[]} motivations + */ + +/** + * @typedef {Object} Platform + * @property {string} name + * @property {'Высокий'|'Средний'|'Низкий'} priority + * @property {string} reason + */ + +/** + * @typedef {Object} BudgetRecommendation + * @property {string} minDaily + * @property {string} optimalDaily + * @property {string} monthly + */ + +/** + * @typedef {Object} MarketingAnalysis + * @property {string} topic + * @property {string} marketOverview + * @property {TargetAudience} targetAudience + * @property {Platform[]} platforms + * @property {string[]} bestAdFormats + * @property {string[]} postingTimes + * @property {BudgetRecommendation} budgetRecommendation + * @property {string[]} keyMessages + * @property {string} competitorsInsight + * @property {string} recommendedVisuals + */ + +/** + * @typedef {Object} Phase + * @property {number} phase + * @property {string} name + * @property {string} duration + * @property {string} objective + * @property {string[]} tactics + * @property {string} kpi + */ + +/** + * @typedef {Object} AdCreativeNeed + * @property {number} id + * @property {string} type + * @property {string} theme + * @property {string} captionIdea + * @property {string} imageDescription + * @property {string} cta + * @property {string} targetSegment + */ + +/** + * @typedef {Object} ExpectedResults + * @property {string} reach + * @property {string} clicks + * @property {string} conversions + * @property {string} cpl + */ + +/** + * @typedef {Object} MarketingStrategy + * @property {string} strategyName + * @property {string} goal + * @property {string} duration + * @property {Phase[]} phases + * @property {AdCreativeNeed[]} adCreativesNeeded + * @property {string} totalBudget + * @property {ExpectedResults} expectedResults + * @property {string=} strategyId + * @property {string=} id + */ + +/** + * @typedef {Object} TargetingSettings + * @property {string} audience + * @property {string} age + * @property {string[]} interests + * @property {string} placement + * @property {string} budgetPerDay + * @property {string} estimatedReach + */ + +/** + * @typedef {Object} AdCreative + * @property {string} headline + * @property {string} caption + * @property {string[]} hashtags + * @property {string} ctaButton + * @property {TargetingSettings} targetingSettings + */ + +/** + * @typedef {Object} TargetingLaunchResult + * @property {string} strategyId + * @property {string} analysisId + * @property {string} scoringModelName + * @property {string} scoringModelTitle + * @property {number} postIndex + * @property {string} platform + * @property {string} contentType + * @property {string} theme + * @property {string} postText + * @property {string[]} hashtags + * @property {string} imageUrl + * @property {boolean} imagePosted + * @property {string} facebookPostId + * @property {'PUBLISHED'|'TEXT_ONLY'|'FAILED'} facebookStatus + * @property {TargetingSettings} targetingSettings + * @property {string} message + */ + +export {}; diff --git a/src/views/pages/marketing/MarketingAnalysisV3List.vue b/src/views/pages/marketing/MarketingAnalysisV3List.vue index 337d69b..cce3485 100644 --- a/src/views/pages/marketing/MarketingAnalysisV3List.vue +++ b/src/views/pages/marketing/MarketingAnalysisV3List.vue @@ -24,7 +24,6 @@ const STATUS_FILTERS = [ { label: 'Ошибка', value: 'FAILED' } ]; -// ─── Load ───────────────────────────────────────────────────────────────────── const load = async () => { loading.value = true; try { @@ -37,13 +36,12 @@ const load = async () => { } }; -// ─── Filtered & paginated ───────────────────────────────────────────────────── const filtered = computed(() => { let list = analyses.value; - if (filterStatus.value) list = list.filter((a) => a.status === filterStatus.value); + if (filterStatus.value) list = list.filter(a => a.status === filterStatus.value); if (searchQuery.value.trim()) { const q = searchQuery.value.trim().toLowerCase(); - list = list.filter((a) => { + list = list.filter(a => { const niche = (a.requestData?.businessNiche || a.businessNiche || '').toLowerCase(); const city = cityLabel(a).toLowerCase(); return niche.includes(q) || city.includes(q); @@ -56,64 +54,44 @@ const paginated = computed(() => { return filtered.value.slice(start, start + pageSize); }); -const countByStatus = (s) => analyses.value.filter((a) => a.status === s).length; +const countByStatus = s => analyses.value.filter(a => a.status === s).length; -// ─── Helpers ────────────────────────────────────────────────────────────────── -const cityLabel = (item) => { +const cityLabel = item => { const rd = item.requestData || item; if (rd.mainCity) return rd.mainCity; if (rd.presenceCities?.length) return rd.presenceCities.join(', '); if (rd.promotionCities?.length) return rd.promotionCities.join(', '); - - // Fallback if none of the above are set but it's a V3 analysis if (rd.geoScope === 'FULL_COUNTRY') return 'Вся страна'; if (rd.geoScope === 'ONLINE') return 'Онлайн'; - - return 'География не указана'; + return ''; }; -const formatDate = (dt) => (dt ? new Date(dt).toLocaleString('ru-RU', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'); +const formatDate = dt => dt ? new Date(dt).toLocaleString('ru-RU', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'; +const statusLabel = s => ({ COMPLETED: 'Завершён', PROCESSING: 'В работе', QUEUED: 'В очереди', FAILED: 'Ошибка' })[s] || s; -const statusLabel = (s) => ({ COMPLETED: 'Завершён', PROCESSING: 'В работе', QUEUED: 'В очереди', FAILED: 'Ошибка' })[s] || s; +const statusStyle = s => ({ + COMPLETED: { color: 'var(--kai-green)', bg: 'rgba(0,212,139,0.1)', border: 'rgba(0,212,139,0.25)' }, + PROCESSING: { color: 'var(--kai-blue)', bg: 'rgba(45,123,255,0.1)', border: 'rgba(45,123,255,0.25)' }, + QUEUED: { color: 'var(--kai-yellow)', bg: 'rgba(245,158,11,0.1)', border: 'rgba(245,158,11,0.25)' }, + FAILED: { color: 'var(--kai-red)', bg: 'rgba(239,68,68,0.1)', border: 'rgba(239,68,68,0.25)' } +})[s] || { color: 'var(--kai-txt3)', bg: 'rgba(255,255,255,0.05)', border: 'rgba(255,255,255,0.1)' }; -const statusIcon = (s) => - ({ - COMPLETED: 'pi pi-check-circle text-emerald-500', - PROCESSING: 'pi pi-spin pi-spinner text-blue-500', - QUEUED: 'pi pi-clock text-amber-500', - FAILED: 'pi pi-times-circle text-red-500' - })[s] || 'pi pi-circle text-surface-400'; +const statusIcon = s => ({ + COMPLETED: 'pi pi-check-circle', + PROCESSING: 'pi pi-spin pi-spinner', + QUEUED: 'pi pi-clock', + FAILED: 'pi pi-times-circle' +})[s] || 'pi pi-circle'; -const statusBg = (s) => - ({ - COMPLETED: 'bg-emerald-50 dark:bg-emerald-900/20', - PROCESSING: 'bg-blue-50 dark:bg-blue-900/20', - QUEUED: 'bg-amber-50 dark:bg-amber-900/20', - FAILED: 'bg-red-50 dark:bg-red-900/20' - })[s] || 'bg-surface-100 dark:bg-surface-800'; - -const statusTagClass = (s) => - ({ - COMPLETED: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', - PROCESSING: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', - QUEUED: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', - FAILED: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' - })[s] || 'bg-surface-200 text-surface-600 dark:bg-surface-700 dark:text-surface-300'; - -// ─── Actions ────────────────────────────────────────────────────────────────── -const openAnalysis = (item) => { - if (item.status === 'COMPLETED') { - router.push(`/marketing-analysis/v3/${item.id}`); - } else if (item.status === 'PROCESSING' || item.status === 'QUEUED') { +const openAnalysis = item => { + if (['COMPLETED', 'PROCESSING', 'QUEUED'].includes(item.status)) { router.push(`/marketing-analysis/v3/${item.id}`); } }; -const retry = (item) => { - router.push('/marketing-analysis/v3/new'); -}; +const retry = () => router.push('/marketing-analysis/v3/new'); -const confirmDelete = (item) => { +const confirmDelete = item => { confirm.require({ message: `Удалить анализ "${item.requestData?.businessNiche || 'без названия'}"?`, header: 'Подтверждение удаления', @@ -122,8 +100,7 @@ const confirmDelete = (item) => { rejectLabel: 'Отмена', acceptClass: 'p-button-danger', accept: () => { - // TODO: добавить DELETE /api/v3/marketing-analysis/{id} когда появится в API - analyses.value = analyses.value.filter((a) => a.id !== item.id); + analyses.value = analyses.value.filter(a => a.id !== item.id); toast.add({ severity: 'success', summary: 'Удалено', detail: 'Анализ удалён', life: 3000 }); } }); @@ -133,154 +110,279 @@ onMounted(load); diff --git a/src/views/pages/marketing/MarketingMain.vue b/src/views/pages/marketing/MarketingMain.vue index 608f607..c7d1c1a 100644 --- a/src/views/pages/marketing/MarketingMain.vue +++ b/src/views/pages/marketing/MarketingMain.vue @@ -1,104 +1,426 @@ - + diff --git a/src/views/pages/marketing/MarketingPromotion.vue b/src/views/pages/marketing/MarketingPromotion.vue index 593deb6..019257a 100644 --- a/src/views/pages/marketing/MarketingPromotion.vue +++ b/src/views/pages/marketing/MarketingPromotion.vue @@ -1,5 +1,5 @@ - diff --git a/src/views/pages/marketing/MarketingStrategyV3List.vue b/src/views/pages/marketing/MarketingStrategyV3List.vue index 7a87e30..8c79ba4 100644 --- a/src/views/pages/marketing/MarketingStrategyV3List.vue +++ b/src/views/pages/marketing/MarketingStrategyV3List.vue @@ -23,7 +23,6 @@ const STATUS_FILTERS = [ { label: 'Ошибка', value: 'FAILED' } ]; -// Load const load = async () => { loading.value = true; try { @@ -36,13 +35,12 @@ const load = async () => { } }; -// Filtered & Paginated const filtered = computed(() => { let list = strategies.value; - if (filterStatus.value) list = list.filter((a) => a.status === filterStatus.value); + if (filterStatus.value) list = list.filter(a => a.status === filterStatus.value); if (searchQuery.value.trim()) { const q = searchQuery.value.trim().toLowerCase(); - list = list.filter((a) => { + list = list.filter(a => { const niche = (a.analysisData?.requestData?.businessNiche || '').toLowerCase(); const id = (a.strategyId || a.id || '').toLowerCase(); return niche.includes(q) || id.includes(q); @@ -55,64 +53,52 @@ const paginated = computed(() => { return filtered.value.slice(start, start + pageSize); }); -const countByStatus = (s) => strategies.value.filter((a) => a.status === s).length; +const countByStatus = s => strategies.value.filter(a => a.status === s).length; +const formatDate = dt => dt ? new Date(dt).toLocaleString('ru-RU', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'; +const statusLabel = s => ({ COMPLETED: 'Завершена', PROCESSING: 'В работе', QUEUED: 'В очереди', FAILED: 'Ошибка' })[(s||'').toUpperCase()] || s; -// Helpers -const formatDate = (dt) => (dt ? new Date(dt).toLocaleString('ru-RU', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'); +const statusStyle = s => ({ + COMPLETED: { color: 'var(--kai-green)', bg: 'rgba(0,212,139,0.1)', border: 'rgba(0,212,139,0.25)' }, + PROCESSING: { color: 'var(--kai-blue)', bg: 'rgba(45,123,255,0.1)', border: 'rgba(45,123,255,0.25)' }, + QUEUED: { color: 'var(--kai-yellow)', bg: 'rgba(245,158,11,0.1)', border: 'rgba(245,158,11,0.25)' }, + FAILED: { color: 'var(--kai-red)', bg: 'rgba(239,68,68,0.1)', border: 'rgba(239,68,68,0.25)' } +})[(s||'').toUpperCase()] || { color: 'var(--kai-txt3)', bg: 'rgba(255,255,255,0.05)', border: 'rgba(255,255,255,0.1)' }; -const statusLabel = (s) => ({ COMPLETED: 'Завершена', PROCESSING: 'В работе', QUEUED: 'В очереди', FAILED: 'Ошибка' })[(s || '').toUpperCase()] || s; +const statusIcon = s => ({ + COMPLETED: 'pi pi-check-circle', + PROCESSING: 'pi pi-spin pi-spinner', + QUEUED: 'pi pi-clock', + FAILED: 'pi pi-times-circle' +})[(s||'').toUpperCase()] || 'pi pi-circle'; -const statusBg = (s) => - ({ - COMPLETED: 'bg-emerald-50 dark:bg-emerald-900/20', - PROCESSING: 'bg-blue-50 dark:bg-blue-900/20', - QUEUED: 'bg-amber-50 dark:bg-amber-900/20', - FAILED: 'bg-red-50 dark:bg-red-900/20' - })[(s || '').toUpperCase()] || 'bg-surface-100 dark:bg-surface-800'; - -const statusIcon = (s) => - ({ - COMPLETED: 'pi pi-check-circle text-emerald-500', - PROCESSING: 'pi pi-spin pi-spinner text-blue-500', - QUEUED: 'pi pi-clock text-amber-500', - FAILED: 'pi pi-times-circle text-red-500' - })[(s || '').toUpperCase()] || 'pi pi-circle text-surface-400'; - -const statusTagClass = (s) => - ({ - COMPLETED: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', - PROCESSING: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', - QUEUED: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', - FAILED: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' - })[(s || '').toUpperCase()] || 'bg-surface-200 text-surface-600 dark:bg-surface-700 dark:text-surface-300'; - -const getPlatformIcon = (platform) => { - const p = platform?.toLowerCase() || ''; - if (p.includes('insta')) return 'pi pi-instagram'; - if (p.includes('tik')) return 'pi pi-tiktok'; - if (p.includes('you')) return 'pi pi-youtube'; - if (p.includes('tg') || p.includes('tele')) return 'pi pi-telegram'; - if (p.includes('link')) return 'pi pi-linkedin'; - if (p.includes('vk')) return 'pi pi-vk'; +const getPlatformIcon = p => { + const pl = (p||'').toLowerCase(); + if (pl.includes('insta')) return 'pi pi-instagram'; + if (pl.includes('tik')) return 'pi pi-tiktok'; + if (pl.includes('you')) return 'pi pi-youtube'; + if (pl.includes('tg') || pl.includes('tele')) return 'pi pi-telegram'; return 'pi pi-share-alt'; }; -const getModelInfo = (modelName) => { +const getModelInfo = modelName => { if (!modelName) return DEFAULT_STRATEGY_MODEL; return STRATEGY_MODELS[modelName.toUpperCase()] || DEFAULT_STRATEGY_MODEL; }; -const getLatestStatusMessage = (item) => { - if (item.statusHistory && item.statusHistory.length > 0) { - return item.statusHistory[item.statusHistory.length - 1].message; - } +const getStrategyName = item => + item.analysisTitle || + (item.analysisData?.requestData?.productName + ? `${item.analysisData.requestData.productName} — ${item.analysisData.requestData.businessNiche}` + : (item.analysisData?.requestData?.businessNiche || 'Стратегия #' + (item.strategyId || item.id || '').slice(-6))); + +const getLatestMsg = item => { + if (item.statusHistory?.length > 0) return item.statusHistory[item.statusHistory.length - 1].message; return item.status === 'QUEUED' ? 'В очереди...' : 'Фабрика генерирует контент...'; }; -// Actions -const openStrategy = (item) => { +const openStrategy = item => { const s = (item.status || '').toUpperCase(); - if (s === 'COMPLETED' || s === 'PROCESSING' || s === 'QUEUED') { + if (['COMPLETED', 'PROCESSING', 'QUEUED'].includes(s)) { const id = item.strategyId || item.id; router.push(`/marketing-analysis/v3/strategy?strategyId=${id}&analysisId=${item.analysisId}`); } @@ -122,172 +108,223 @@ onMounted(load); diff --git a/src/views/pages/marketing/targetologist/CampaignInsightsDetail.vue b/src/views/pages/marketing/targetologist/CampaignInsightsDetail.vue new file mode 100644 index 0000000..500c241 --- /dev/null +++ b/src/views/pages/marketing/targetologist/CampaignInsightsDetail.vue @@ -0,0 +1,575 @@ + + + + + diff --git a/src/views/pages/marketing/targetologist/CampaignWizard.vue b/src/views/pages/marketing/targetologist/CampaignWizard.vue new file mode 100644 index 0000000..969fa76 --- /dev/null +++ b/src/views/pages/marketing/targetologist/CampaignWizard.vue @@ -0,0 +1,545 @@ + + + + + diff --git a/src/views/pages/marketing/targetologist/TargetologistDashboard.vue b/src/views/pages/marketing/targetologist/TargetologistDashboard.vue new file mode 100644 index 0000000..346c7c2 --- /dev/null +++ b/src/views/pages/marketing/targetologist/TargetologistDashboard.vue @@ -0,0 +1,615 @@ + + + + + diff --git a/src/views/pages/marketing/targetologist/components/CampaignAdSets.vue b/src/views/pages/marketing/targetologist/components/CampaignAdSets.vue new file mode 100644 index 0000000..49644cd --- /dev/null +++ b/src/views/pages/marketing/targetologist/components/CampaignAdSets.vue @@ -0,0 +1,506 @@ + + + + + + diff --git a/src/views/pages/marketing/targetologist/components/CampaignInsights.vue b/src/views/pages/marketing/targetologist/components/CampaignInsights.vue new file mode 100644 index 0000000..3650a02 --- /dev/null +++ b/src/views/pages/marketing/targetologist/components/CampaignInsights.vue @@ -0,0 +1,311 @@ + + + + + diff --git a/src/views/pages/marketing/targetologist/components/CampaignMetrics.vue b/src/views/pages/marketing/targetologist/components/CampaignMetrics.vue new file mode 100644 index 0000000..b4a0b00 --- /dev/null +++ b/src/views/pages/marketing/targetologist/components/CampaignMetrics.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/src/views/pages/marketing/targetologist/components/CampaignPrediction.vue b/src/views/pages/marketing/targetologist/components/CampaignPrediction.vue new file mode 100644 index 0000000..5e19e46 --- /dev/null +++ b/src/views/pages/marketing/targetologist/components/CampaignPrediction.vue @@ -0,0 +1,930 @@ + + + + + + diff --git a/src/views/pages/marketing/targetologist/components/OAuthConnectionWidget.vue b/src/views/pages/marketing/targetologist/components/OAuthConnectionWidget.vue new file mode 100644 index 0000000..ad14a90 --- /dev/null +++ b/src/views/pages/marketing/targetologist/components/OAuthConnectionWidget.vue @@ -0,0 +1,289 @@ + + + + + diff --git a/src/views/pages/marketing/targetologist/components/PipelineTimeline.vue b/src/views/pages/marketing/targetologist/components/PipelineTimeline.vue new file mode 100644 index 0000000..acbd3f2 --- /dev/null +++ b/src/views/pages/marketing/targetologist/components/PipelineTimeline.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/src/views/pages/marketing/targetologist/components/TargetingRecommendationPanel.vue b/src/views/pages/marketing/targetologist/components/TargetingRecommendationPanel.vue new file mode 100644 index 0000000..6e51750 --- /dev/null +++ b/src/views/pages/marketing/targetologist/components/TargetingRecommendationPanel.vue @@ -0,0 +1,315 @@ + + + + + diff --git a/src/views/pages/marketing/targetologist/composables/useTargeting.ts b/src/views/pages/marketing/targetologist/composables/useTargeting.ts new file mode 100644 index 0000000..22ffe26 --- /dev/null +++ b/src/views/pages/marketing/targetologist/composables/useTargeting.ts @@ -0,0 +1,199 @@ +import { reactive } from 'vue'; +import { useToast } from 'primevue/usetoast'; +import TargetingService from '@/service/TargetingService'; + +// --- Types Mocks (will be matched with API) --- +export interface AdAccountStatus { + facebook: boolean; + tiktok: boolean; +} + +export interface Campaign { + id: string; + name: string; + goal: string; + status: 'ACTIVE' | 'PAUSED' | 'FAILED' | 'GENERATING' | 'DRAFT' | 'COMPLETED'; + budgetKzt: number; + spentKzt: number; + reach: number; + cpm: number; + ctr: number; + cpc: number; + roas?: number; + conversions?: number; + platforms: string[]; + createdAt: string; +} + +export interface AdCreative { + id: string; + headline: string; + primaryText: string; + mediaUrl: string; + contentType: 'image' | 'video'; +} + +export interface AdSet { + id: string; + name: string; + targetAudience: string; + ads: AdCreative[]; +} + +export interface CampaignDetails extends Campaign { + adSets: AdSet[]; + statusHistory?: Array<{ status: string; message?: string }>; +} + +export interface TargetAudience { + trustedAI: boolean; + ageMin?: number; + ageMax?: number; + locations?: string[]; +} + +// --- Centralized State --- +const state = reactive({ + accounts: { + facebook: false, + tiktok: false, + } as AdAccountStatus, + accountNames: { + facebook: '', + tiktok: '', + }, + campaigns: [] as Campaign[], + currentCampaignDetails: null as any, + isGenerating: false, +}); + +// --- Actions (Composables) --- +export function useTargeting() { + const toast = useToast(); + + const connectAccount = async (platform: 'facebook' | 'tiktok', accountId: string, accountName: string) => { + try { + await TargetingService.selectAdAccount(platform, accountId); + state.accounts[platform] = true; + state.accountNames[platform] = accountName || `Account ${accountId}`; + toast.add({ severity: 'success', summary: 'Синхронизация завершена', detail: `Успешно подключен кабинет: ${state.accountNames[platform]}`, life: 4000 }); + } catch (error: any) { + toast.add({ severity: 'error', summary: 'Ошибка привязки', detail: error.message || 'Не удалось привязать кабинет', life: 5000 }); + } + }; + + const fetchCampaigns = async () => { + try { + const campaignsData = await TargetingService.getCampaigns(0, 50); + state.campaigns = Array.isArray(campaignsData) ? campaignsData : []; + return state.campaigns; + } catch (error) { + console.error(error); + toast.add({ severity: 'error', summary: 'Ошибка', detail: 'Не удалось загрузить кампании', life: 5000 }); + return []; + } + }; + + const getCampaignById = async (id: string, noStateUpdate = false) => { + try { + const details = await TargetingService.getCampaignById(id); + // mock ad structure if backend lacks it for now + if (!details.adSets) { + details.adSets = [ + { + id: 'set-1', + name: 'IT-Специалисты 18-34', + targetAudience: 'Lookalike, AI-matched', + ads: [ + { id: 'ad1', headline: 'Оффер для Tech', primaryText: 'Текст', mediaUrl: 'https://images.unsplash.com/photo-1558002038-1055907df827', contentType: 'image' } + ] + } + ]; + } + if (!noStateUpdate) { + state.currentCampaignDetails = details; + } + return details; + } catch (error) { + console.error(error); + throw error; + } + }; + + const createAiCampaign = async (payload: { name: string, goal: string, budgetKzt: number, platforms: string[], targetAudiences?: TargetAudience, strategyId?: string }) => { + state.isGenerating = true; + + try { + // Mapping frontend model to backend DTO Request + // analysisId намеренно не передаём — бэкенд подтягивает его из стратегии сам + // Map frontend platform keys → backend enum values + const mapPlatforms = (platforms: string[]): string[] => { + const result: string[] = []; + for (const p of platforms) { + const up = p.toUpperCase(); + if (up === 'FB_IG') { result.push('FACEBOOK', 'INSTAGRAM'); } + else if (up === 'FACEBOOK') { result.push('FACEBOOK'); } + else if (up === 'INSTAGRAM') { result.push('INSTAGRAM'); } + else if (up === 'TIKTOK') { result.push('TIKTOK'); } + else { result.push(up); } + } + return result; + }; + + const apiPayload = { + strategyId: payload.strategyId, + campaignName: payload.name || 'Новая AI Кампания', + objective: getObjectiveEnum(payload.goal), + platforms: mapPlatforms(payload.platforms), + totalBudgetKzt: payload.budgetKzt, + startDate: new Date().toISOString(), + endDate: new Date(Date.now() + 30 * 86400000).toISOString(), + }; + + const response = await TargetingService.createCampaign(apiPayload); + + // Response typically returns { id, status, statusHistory } + // Fetch immediately to put into state + if (response && response.id) { + return response.id; + } else { + throw new Error("Невалидный ответ от сервера при создании кампании"); + } + } catch (error: any) { + toast.add({ severity: 'error', summary: 'Сбой оркестратора', detail: error.message || 'Критическая ошибка запуска', life: 8000 }); + throw error; + } finally { + state.isGenerating = false; + } + }; + + // Map Russian UI labels OR direct enum values → backend objective enum + const getObjectiveEnum = (goalRu: string) => { + const v = goalRu.toUpperCase(); + // Direct enum pass-through + if (['CONVERSIONS', 'TRAFFIC', 'REACH', 'LEADS', 'AWARENESS', 'ENGAGEMENT', 'SALES'].includes(v)) return v; + // Russian UI labels + switch(goalRu.toLowerCase()) { + case 'продажи': return 'CONVERSIONS'; + case 'трафик': return 'TRAFFIC'; + case 'охват': return 'REACH'; + case 'лиды': return 'LEADS'; + default: return 'CONVERSIONS'; + } + } + + const formatCurrency = (amount: number) => { + if (!amount || isNaN(amount)) amount = 0; + return new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'KZT', maximumFractionDigits: 0 }).format(amount); + }; + + return { + state, + connectAccount, + fetchCampaigns, + getCampaignById, + createAiCampaign, + formatCurrency, + TargetingService // Expose raw service if needed directly + }; +} diff --git a/src/views/targeting/TargetingAnalysisView.vue b/src/views/targeting/TargetingAnalysisView.vue new file mode 100644 index 0000000..9693df9 --- /dev/null +++ b/src/views/targeting/TargetingAnalysisView.vue @@ -0,0 +1,214 @@ + + + diff --git a/src/views/targeting/TargetingCreativeView.vue b/src/views/targeting/TargetingCreativeView.vue new file mode 100644 index 0000000..0572240 --- /dev/null +++ b/src/views/targeting/TargetingCreativeView.vue @@ -0,0 +1,291 @@ + + + diff --git a/src/views/targeting/TargetingInputView.vue b/src/views/targeting/TargetingInputView.vue new file mode 100644 index 0000000..b264345 --- /dev/null +++ b/src/views/targeting/TargetingInputView.vue @@ -0,0 +1,125 @@ + + +