Files
marketing/src/views/pages/MarketingAnalysis.vue
T
2026-01-30 16:04:35 +05:00

2469 lines
131 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="min-h-screen bg-indigo-50/30 p-4 md:p-6">
<Toast />
<div class="mx-auto max-w-[1920px] space-y-7">
<!-- Header (hide in embedded) -->
<div v-if="!embedded" class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-2 text-[13px] text-slate-500">
<i class="pi pi-chart-line"></i>
<span>Маркетинг</span>
<i class="pi pi-angle-right text-[11px]"></i>
<span class="font-medium text-slate-700">Маркетинговый анализ</span>
</div>
<h1 class="mt-1 text-2xl md:text-3xl font-semibold text-slate-900 truncate">
{{ report?.report_title || 'Маркетинговый анализ' }}
</h1>
</div>
</div>
<!-- Generate (API v2) (hide in embedded) -->
<div v-if="!embedded" class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4 md:p-5 relative overflow-hidden">
<div
class="absolute inset-0 pointer-events-none opacity-[0.10]"
style="background: radial-gradient(900px 260px at 10% 0%, rgba(91, 137, 255, 0.3), transparent), radial-gradient(900px 260px at 90% 0%, rgba(255, 142, 79, 0.28), transparent)"
></div>
<div class="relative flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div class="text-[16px] font-semibold text-slate-800">Параметры анализа (API v2)</div>
<div class="text-[12px] text-slate-500 mt-0.5">Заполните поля и нажмите Generate мок-данные не используются.</div>
</div>
<div class="flex items-center gap-2">
<Button icon="pi pi-sparkles" label="Generate" :loading="loading" :disabled="loading" @click="loadAnalysisV2" />
<Button severity="secondary" outlined icon="pi pi-trash" label="Reset" :disabled="loading" @click="resetDashboard" />
</div>
</div>
<div class="relative mt-4 grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-6">
<label class="block text-[12px] font-semibold text-slate-600 mb-1">Ниша бизнеса</label>
<InputText v-model="formData.businessNiche" class="w-full" placeholder="Например: стоматология, фитнес, недвижимость" />
</div>
<div class="col-span-12 md:col-span-6">
<label class="block text-[12px] font-semibold text-slate-600 mb-1">Продукт / услуга</label>
<InputText v-model="formData.product" class="w-full" placeholder="Например: имплантация зубов, абонемент, аренда" />
</div>
<div class="col-span-12 md:col-span-6">
<label class="block text-[12px] font-semibold text-slate-600 mb-1">Регионы</label>
<MultiSelect v-model="formData.region" :options="REGION_OPTIONS" class="w-full" placeholder="Выберите регионы" display="chip" />
</div>
<div class="col-span-12 md:col-span-6">
<label class="block text-[12px] font-semibold text-slate-600 mb-1">Уровень детализации</label>
<Dropdown v-model="formData.detailLevel" :options="DETAIL_LEVEL_OPTIONS" class="w-full" placeholder="Выберите" />
</div>
<div class="col-span-12">
<label class="block text-[12px] font-semibold text-slate-600 mb-1">Тип анализа</label>
<MultiSelect v-model="formData.analysisType" :options="ANALYSIS_TYPE_OPTIONS" class="w-full" placeholder="Выберите типы" display="chip" />
</div>
<div class="col-span-12 md:col-span-4">
<label class="block text-[12px] font-semibold text-slate-600 mb-1">ЦА: гендер</label>
<MultiSelect v-model="formData.targetAudience.genders" :options="GENDER_OPTIONS" class="w-full" placeholder="Выберите" display="chip" />
</div>
<div class="col-span-12 md:col-span-4">
<label class="block text-[12px] font-semibold text-slate-600 mb-1">ЦА: возраст</label>
<MultiSelect v-model="formData.targetAudience.ageRanges" :options="AGE_RANGE_OPTIONS" class="w-full" placeholder="Выберите" display="chip" />
</div>
<div class="col-span-12 md:col-span-4">
<label class="block text-[12px] font-semibold text-slate-600 mb-1">ЦА: тип</label>
<MultiSelect v-model="formData.targetAudience.types" :options="AUDIENCE_TYPE_OPTIONS" class="w-full" placeholder="Выберите" display="chip" />
</div>
<div class="col-span-12">
<label class="block text-[12px] font-semibold text-slate-600 mb-1">Цель на 612 месяцев</label>
<Textarea v-model="formData.goal" class="w-full" autoResize rows="2" placeholder="Например: увеличить заявки, снизить CAC, выйти в новый регион" />
</div>
<div class="col-span-12 md:col-span-6">
<label class="block text-[12px] font-semibold text-slate-600 mb-1">Сильная сторона (опционально)</label>
<InputText v-model="formData.strongSide" class="w-full" placeholder="Например: лучший сервис, гарантия, цена" />
</div>
<div class="col-span-12 md:col-span-6">
<label class="block text-[12px] font-semibold text-slate-600 mb-1">Слабая сторона (опционально)</label>
<InputText v-model="formData.weakSide" class="w-full" placeholder="Например: слабый сайт, мало отзывов, низкая скорость обработки" />
</div>
</div>
<div v-if="error" class="relative mt-4">
<Message severity="error" class="api-error" :closable="false">{{ error }}</Message>
</div>
<div v-if="loading" class="absolute inset-0 bg-white/65 backdrop-blur-[1px] flex items-center justify-center">
<ProgressSpinner size="48" />
</div>
</div>
<!-- Embedded loading / error -->
<div v-if="embedded && !report && loading" class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-6 text-center">
<ProgressSpinner size="48" />
<div class="mt-3 text-[13px] text-slate-600">Загружаем v2 отчёт</div>
</div>
<div v-if="embedded && error" class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4">
<Message severity="error" class="api-error" :closable="false">{{ error }}</Message>
</div>
<div v-if="!embedded && !report && !loading" class="rounded-2xl border border-dashed border-slate-300 bg-white/60 p-8 text-center text-slate-600">
<div class="text-[15px] font-semibold text-slate-800">Данные ещё не загружены</div>
<div class="mt-2 text-[13px]">Заполните параметры сверху и нажмите <span class="font-semibold">Generate</span>, чтобы получить отчёт из API v2.</div>
</div>
<!-- Slide: Обзор рынка -->
<section v-if="report" class="space-y-3">
<div class="text-2xl font-semibold text-slate-900">{{ report?.sections?.market_overview?.title }}</div>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Индекс онлайн-спроса"
:value="marketDemandIndexValue"
:subtitle="marketPotentialSubtitle"
icon="pi-search"
:accentColor="PPT_BLUE"
description="Коэффициент востребованности в сети (отношение текущего спроса к базисному). Показывает, насколько активен спрос на ваш продукт/услугу в цифровых каналах."
/>
</div>
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Темп изменения спроса"
:value="marketYoYGrowthValue"
subtitle="рост за год"
icon="pi-arrow-up-right"
:accentColor="PPT_ORANGE"
description="Процент роста спроса по сравнению с прошлым годом. Положительное значение означает рост интереса к продукту/услуге."
/>
</div>
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Коэффициент сезонности"
:value="marketSeasonalityValue"
:subtitle="`диапазон ${seasonalityRangeValue}`"
icon="pi-calendar"
:accentColor="PPT_GREEN"
description="Индекс активности спроса в весенний период. Например, 1.15x означает рост на 15% выше среднего. Помогает планировать маркетинговые кампании с учетом сезонности."
/>
</div>
<div class="col-span-12 lg:col-span-8">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4">
<div class="flex items-center justify-between gap-3 mb-3">
<div class="flex items-center gap-1.5">
<div class="text-[16px] font-semibold text-slate-800">Динамика онлайн-спроса</div>
<i
class="pi pi-question-circle text-[12px] text-slate-400 cursor-help"
v-tooltip.top="'Массив числовых значений интереса по конкретным месяцам. Показывает сезонные колебания спроса и помогает планировать рекламные кампании.'"
></i>
</div>
<div class="text-[14px] font-semibold text-green-700">{{ marketYoYGrowthValue }}</div>
</div>
<div class="h-[260px]">
<Chart type="line" :data="marketDynamicsLineData" :options="marketDynamicsLineOptions" :plugins="barPlugins" />
</div>
<!-- <div class="mt-4 pt-4 border-t border-slate-200/70">
<div class="overflow-hidden rounded-xl border border-slate-200/70">
<div class="grid grid-cols-12 bg-slate-50 text-[12px] font-semibold text-slate-600">
<div class="col-span-8 px-3 py-2">Месяц</div>
<div class="col-span-4 px-3 py-2 text-right">Индекс</div>
</div>
<div class="divide-y divide-slate-200/70">
<div v-for="row in marketDynamicsTableRows" :key="row.label" class="grid grid-cols-12 px-3 py-2.5">
<div class="col-span-8 text-[13px] text-slate-800">{{ row.label }}</div>
<div class="col-span-4 text-[13px] text-slate-800 text-right">{{ row.value }}</div>
</div>
</div>
</div>
</div> -->
</div>
</div>
<div class="col-span-12 lg:col-span-4">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4 h-[315px] flex flex-col">
<div class="flex items-center gap-1.5 mb-3">
<div class="text-[16px] font-semibold text-slate-800">География онлайн-интереса за год</div>
<i
class="pi pi-question-circle text-[12px] text-slate-400 cursor-help"
v-tooltip.top="'Список приоритетных регионов, где зафиксирован наибольший спрос. Помогает определить географию для фокусировки маркетинговых усилий.'"
></i>
</div>
<div v-if="geographyRows.length > 0" class="space-y-3 flex-1 flex flex-col min-h-0">
<div class="flex-1 overflow-y-auto rounded-xl border border-slate-200/70" style="scrollbar-width: thin; scrollbar-color: rgb(203 213 225) rgb(241 245 249);">
<div class="divide-y divide-slate-200/70">
<div v-for="(row, idx) in geographyRows" :key="row.city" class="px-4 py-3">
<div class="flex items-center justify-between gap-3 mb-2">
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full" :style="{ background: getGeographyColor(row.percentage, idx) }"></div>
<div class="text-[14px] font-medium text-slate-800">{{ row.city }}</div>
</div>
<div class="text-[14px] font-semibold text-slate-800">{{ row.value }}</div>
</div>
<div class="h-2.5 rounded-full bg-slate-200/70 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:style="{
width: `${row.percentage}%`,
background: getGeographyGradient(row.percentage, idx)
}"
></div>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-center gap-3 text-[12px] text-slate-500 flex-shrink-0">
<span class="inline-flex items-center gap-2">
<span class="h-2.5 w-10 rounded-full" style="background: linear-gradient(90deg, #ff8e4f, #52c41a)"></span>
<span>Высокий Низкий</span>
</span>
</div>
</div>
<div v-else class="flex-1 flex items-center justify-center rounded-xl border border-dashed border-slate-300 bg-slate-50/70 px-4 py-6 text-center text-slate-500">
<div class="text-sm">Нет данных</div>
</div>
</div>
</div>
<div class="col-span-12">
<PptConclusionBox :text="report?.sections?.market_overview?.conclusion" />
</div>
</div>
</section>
<!-- Slide: Целевая аудитория -->
<section class="space-y-3">
<div class="text-2xl font-semibold text-slate-900">{{ report?.sections?.target_audience?.title }}</div>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Размер аудитории"
:value="audienceTotalValue"
subtitle="высокий онлайн-потенциал"
icon="pi-users"
:accentColor="PPT_BLUE"
description="Оценочное количество потенциальных клиентов в выбранных регионах. Помогает оценить масштаб рынка и потенциал для роста."
/>
</div>
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Лидирующий сегмент"
:value="audienceLeadingSegmentValue"
subtitle="рост за год"
icon="pi-user"
:accentColor="PPT_ORANGE"
description="Название самого активного или многочисленного сегмента. Показывает, на какую группу потребителей стоит фокусировать маркетинговый бюджет."
/>
</div>
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Доход"
:value="audienceIncomeAboveAvgText"
subtitle="доход выше среднего"
icon="pi-wallet"
:accentColor="PPT_GREEN"
description="Процент аудитории с уровнем дохода «выше среднего». Показывает платёжеспособность целевой аудитории."
/>
</div>
<div class="col-span-12">
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 lg:col-span-6">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4">
<div class="flex items-center gap-1.5 mb-2">
<div class="text-[16px] font-semibold text-slate-800">Возрастная структура</div>
<i
class="pi pi-question-circle text-[12px] text-slate-400 cursor-help"
v-tooltip.top="'Процентное распределение аудитории по возрастным категориям (от 18 до 55+). Помогает адаптировать рекламные сообщения под возрастные группы.'"
></i>
</div>
<div class="h-[240px]">
<Chart type="bar" :data="ageBarData" :options="compactBarOptions" :plugins="barPlugins" />
</div>
</div>
</div>
<div class="col-span-12 lg:col-span-6">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4">
<div class="flex items-center gap-1.5 mb-2">
<div class="text-[16px] font-semibold text-slate-800">Доход и платёжеспособность</div>
<i
class="pi pi-question-circle text-[12px] text-slate-400 cursor-help"
v-tooltip.top="'Распределение аудитории по уровню дохода. Показывает, какая доля аудитории имеет доход выше среднего и может позволить себе ваш продукт/услугу.'"
></i>
</div>
<div class="h-[220px]">
<Chart type="bar" :data="incomeBarData" :options="compactBarOptions" :plugins="barPlugins" />
</div>
</div>
</div>
</div>
</div>
<div v-if="segmentRows.length > 0" class="col-span-12">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4">
<div class="flex items-center gap-1.5 mb-2">
<div class="text-[16px] font-semibold text-slate-800">Сегменты аудитории</div>
<i
class="pi pi-question-circle text-[12px] text-slate-400 cursor-help"
v-tooltip.top="'Распределение аудитории по поведенческим сегментам. Помогает понять, какие группы клиентов доминируют и как их приоритизировать.'"
></i>
</div>
<div class="overflow-hidden rounded-xl border border-slate-200/70">
<div class="grid grid-cols-12 bg-slate-50 text-[12px] font-semibold text-slate-600">
<div class="col-span-7 px-3 py-2">Сегмент</div>
<div class="col-span-5 px-3 py-2 text-right">Доля</div>
</div>
<div class="divide-y divide-slate-200/70">
<div v-for="row in segmentRows" :key="row.name" class="grid grid-cols-12 px-3 py-2.5">
<div class="col-span-7 text-[13px] text-slate-800">{{ row.name }}</div>
<div class="col-span-5 text-[13px] text-slate-800 text-right">{{ row.shareLabel }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-span-12">
<PptConclusionBox :text="report?.sections?.target_audience?.conclusion" />
</div>
</div>
</section>
<!-- Slide: Конкурентная среда -->
<section class="space-y-3">
<div class="text-2xl font-semibold text-slate-900">{{ report?.sections?.competitive_environment?.title }}</div>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Доля ключевого конкурента на рынке"
:value="competitionMarketShareValue"
:subtitle="competitionLeaderSubtitle"
icon="pi-sitemap"
:accentColor="PPT_BLUE"
description="Оценочная доля онлайн-трафика, которую занимает текущий бизнес. Показывает вашу позицию на рынке относительно общего объема трафика в нише."
/>
</div>
<div class="col-span-12 md:col-span-4">
<PptKpiCard
:title="competitionRankTitle"
:value="competitionRankValue"
subtitle=""
icon="pi-chart-bar"
:accentColor="PPT_GREEN"
:description="competitionRankDescription"
/>
</div>
<div class="col-span-12 md:col-span-4">
<PptKpiCard
:title="competitionAdPressureTitle"
:value="competitionCompetitorShareValue"
:subtitle="competitionAdPressureSubtitle"
icon="pi-wallet"
:accentColor="PPT_ORANGE"
:description="competitionAdPressureDescription"
/>
</div>
<div class="col-span-12 lg:col-span-7">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4 h-full">
<div class="flex items-center justify-between gap-3 mb-2">
<div class="flex items-center gap-1.5">
<div class="text-[16px] font-semibold text-slate-800">Digital-видимость: топ конкурентов</div>
<i
class="pi pi-question-circle text-[12px] text-slate-400 cursor-help"
v-tooltip.top="'Сравнительный индекс видимости (баллы от 0 до 100) для вашего бизнеса и конкурентов. Показывает, насколько хорошо вы заметны в цифровых каналах по сравнению с конкурентами.'"
></i>
</div>
<div class="text-[12px] text-slate-500">Бенчмарк</div>
</div>
<div class="h-[280px]">
<Chart type="bar" :data="visibilityBarData" :options="visibilityBarOptions" :plugins="barPlugins" />
</div>
</div>
</div>
<div class="col-span-12 lg:col-span-5">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4 h-full">
<div class="flex items-center gap-1.5 mb-3">
<div class="text-[16px] font-semibold text-slate-800">Ключевые сильные стороны основных конкурентов</div>
<i
class="pi pi-question-circle text-[12px] text-slate-400 cursor-help"
v-tooltip.top="'Список конкретных преимуществ для каждого игрока рынка (включая ваш бизнес). Помогает понять конкурентные преимущества и слабые места.'"
></i>
</div>
<div class="overflow-hidden rounded-xl border border-slate-200/70">
<div class="grid grid-cols-12 bg-slate-50 text-[12px] font-semibold text-slate-600">
<div class="col-span-5 px-3 py-2">Конкурент</div>
<div class="col-span-7 px-3 py-2">Бенчмарк (Астана)</div>
</div>
<div class="divide-y divide-slate-200/70">
<div v-for="row in competitionStrengthRows" :key="row.label" class="grid grid-cols-12 px-3 py-3">
<div class="col-span-5 text-[14px] text-slate-800">{{ row.label }}</div>
<div class="col-span-7 text-[13px] text-slate-600">
<span v-if="row.strengths?.length">{{ row.strengths.join(', ') }}</span>
<span v-else class="text-slate-400"></span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-span-12">
<PptConclusionBox :text="report?.sections?.competitive_environment?.conclusion" />
</div>
</div>
</section>
<!-- Slide: Спрос и поведение -->
<section class="space-y-3">
<div class="text-2xl font-semibold text-slate-900">{{ report?.sections?.demand_and_behavior?.title }}</div>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Поиск в Google"
:value="behaviorSearchGrowthValue"
subtitle=""
icon="pi-search"
:accentColor="PPT_BLUE"
description="Динамика роста поисковых запросов в нише. Показывает, растет ли интерес к продукту/услуге в поисковых системах."
/>
</div>
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Взаимодействия до покупки"
:value="behaviorInteractionsValue"
subtitle=""
icon="pi-send"
:accentColor="PPT_ORANGE"
description="Среднее количество касаний (визитов, кликов), необходимых клиенту для принятия решения. Помогает понять длину пути клиента и необходимое количество контактов."
/>
</div>
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Вероятность повторного отказа"
:value="behaviorRepeatRefusalValue"
subtitle=""
icon="pi-refresh"
:accentColor="PPT_GREEN"
description="Вероятность того, что клиент не вернется после первого опыта. Высокое значение указывает на проблемы с качеством сервиса или продукта."
/>
</div>
<div class="col-span-12 lg:col-span-5">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4 h-full">
<div class="flex items-center gap-1.5 mb-2">
<div class="text-[16px] font-semibold text-slate-800">Типы поисковых запросов</div>
<i
class="pi pi-question-circle text-[12px] text-slate-400 cursor-help"
v-tooltip.top="'Структура запросов: каталожные, информационные, брендовые. Показывает, как пользователи ищут ваш продукт/услугу и какие типы контента им нужны.'"
></i>
</div>
<div class="h-[260px]">
<Chart type="doughnut" :data="queryTypesDonutData" :options="queryTypesDonutOptions" :plugins="donutPlugins" />
</div>
<!-- Метрики под диаграммой -->
<div v-if="queryTypesMetrics.length > 0" class="mt-4 pt-4 border-t border-slate-200/70">
<div class="grid grid-cols-2 gap-3">
<div v-for="metric in queryTypesMetrics" :key="metric.label" class="flex items-center gap-2">
<div class="h-3 w-3 rounded-full" :style="{ background: metric.color }"></div>
<div class="flex-1 min-w-0">
<div class="text-[12px] text-slate-600 truncate">{{ metric.label }}</div>
</div>
<div class="text-[13px] font-semibold text-slate-800 whitespace-nowrap">{{ metric.value }}%</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-span-12 lg:col-span-7">
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4">
<div class="flex items-center gap-1.5 mb-2">
<div class="text-[16px] font-semibold text-slate-800">Факторы принятия решения</div>
<i
class="pi pi-question-circle text-[12px] text-slate-400 cursor-help"
v-tooltip.top="'Что важнее всего для клиента при выборе: Цена, Отзывы, Сервис и т.д. Помогает понять приоритеты клиентов и адаптировать оффер.'"
></i>
</div>
<div class="h-[220px]">
<Chart type="bar" :data="decisionFactorsBarData" :options="compactBarOptions" :plugins="barPlugins" />
</div>
</div>
</div>
<div class="col-span-12">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4">
<div class="flex items-center gap-1.5 mb-2">
<div class="text-[16px] font-semibold text-slate-800">Причины отказов</div>
<i
class="pi pi-question-circle text-[12px] text-slate-400 cursor-help"
v-tooltip.top="'Барьеры, которые мешают завершить покупку (например, «непонятный оффер», высокая цена, отсутствие отзывов). Помогает выявить и устранить препятствия на пути к конверсии.'"
></i>
</div>
<div class="h-[200px]">
<Chart type="bar" :data="refusalReasonsBarData" :options="compactBarOptions" :plugins="barPlugins" />
</div>
</div>
</div>
</div>
</div>
<div class="col-span-12">
<PptConclusionBox :text="report?.sections?.demand_and_behavior?.conclusion" />
</div>
</div>
</section>
<!-- Slide: Путь клиента -->
<section class="space-y-3">
<div class="text-2xl font-semibold text-slate-900">{{ report?.sections?.customer_journey?.title }}</div>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Конверсия лид → запись"
:value="report?.sections?.customer_journey?.conversion_rates?.lead_to_booking"
subtitle=""
icon="pi-comments"
:accentColor="PPT_BLUE"
description="Процент перехода от лида к записи. Показывает эффективность работы отдела продаж на этапе конверсии лида в клиента."
/>
</div>
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Конверсия запись → оплата"
:value="report?.sections?.customer_journey?.conversion_rates?.booking_to_payment"
subtitle=""
icon="pi-comment"
:accentColor="PPT_ORANGE"
description="Процент перехода от записи к оплате. Показывает, сколько клиентов, сделавших запись, завершают покупку."
/>
</div>
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Конверсия лид → оплата"
:value="report?.sections?.customer_journey?.conversion_rates?.lead_to_payment_total"
subtitle=""
icon="pi-wallet"
:accentColor="PPT_ORANGE"
description="Общая конверсия от лида до оплаты. Показывает эффективность всей воронки продаж от первого контакта до завершения сделки."
/>
</div>
<div class="col-span-12">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4 h-full">
<div class="flex items-center gap-1.5 mb-2">
<div class="text-[16px] font-semibold text-slate-800">Воронка продаж</div>
<i
class="pi pi-question-circle text-[12px] text-slate-400 cursor-help"
v-tooltip.top="'Абсолютные показатели воронки: сколько человек пришло с рекламы, сколько стали лидами, сколько купили. Визуализирует потери клиентов на каждом этапе.'"
></i>
</div>
<div class="h-[320px]">
<Chart type="bar" :data="funnelBarData" :options="funnelBarOptions" :plugins="barPlugins" />
</div>
</div>
</div>
<div v-if="funnelBottleneckRows.length > 0" class="col-span-12">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4">
<div class="flex items-center gap-1.5 mb-2">
<div class="text-[16px] font-semibold text-slate-800">Узкие места воронки</div>
<i
class="pi pi-question-circle text-[12px] text-slate-400 cursor-help"
v-tooltip.top="'Этапы, где теряется больше всего клиентов, и основные причины отказа.'"
></i>
</div>
<div class="overflow-hidden rounded-xl border border-slate-200/70">
<div class="grid grid-cols-12 bg-slate-50 text-[12px] font-semibold text-slate-600">
<div class="col-span-4 px-3 py-2">Этап</div>
<div class="col-span-8 px-3 py-2">Причина</div>
</div>
<div class="divide-y divide-slate-200/70">
<div v-for="row in funnelBottleneckRows" :key="row.key" class="grid grid-cols-12 px-3 py-2.5">
<div class="col-span-4 text-[13px] text-slate-800">{{ row.stage }}</div>
<div class="col-span-8 text-[13px] text-slate-600">{{ row.reason }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-span-12">
<PptConclusionBox :text="report?.sections?.customer_journey?.conclusion" />
</div>
</div>
</section>
<!-- Slide: Каналы привлечения -->
<section class="space-y-3">
<div class="text-2xl font-semibold text-slate-900">{{ report?.sections?.acquisition_channels?.title }}</div>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Доля поискового трафика"
:value="channelsSearchShareValue"
subtitle=""
icon="pi-search"
:accentColor="PPT_BLUE"
description="Процент клиентов, приходящих из поисковых систем. Показывает важность SEO и контекстной рекламы для вашего бизнеса."
/>
</div>
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Средняя стоимость лида"
:value="channelsLeadCostValue"
subtitle=""
icon="pi-chart-bar"
:accentColor="PPT_GREEN"
description="Средняя стоимость одного лида (CPL) в валюте региона. Помогает оценить эффективность инвестиций в маркетинг и сравнить каналы."
/>
</div>
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Потенциал каналов к росту"
:value="channelsGrowthPotentialValue"
subtitle=""
icon="pi-arrow-up-right"
:accentColor="PPT_ORANGE"
description="Оценка возможности роста в различных каналах привлечения. Помогает определить, в какие каналы стоит инвестировать больше."
/>
</div>
<div class="col-span-12">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4 h-full">
<div class="flex items-center gap-1.5 mb-2">
<div class="text-[16px] font-semibold text-slate-800">Доли каналов</div>
<i
class="pi pi-question-circle text-[12px] text-slate-400 cursor-help"
v-tooltip.top="'Распределение долей трафика по каналам. Помогает понять, какие каналы дают основной вклад в привлечение клиентов.'"
></i>
</div>
<div class="h-[240px]">
<Chart v-if="channelSharesBarData.labels.length > 0" type="bar" :data="channelSharesBarData" :options="channelSharesBarOptions" :plugins="barPlugins" />
<div v-else class="h-full flex items-center justify-center text-slate-500 text-[13px]">
Нет данных по долям каналов
</div>
</div>
</div>
</div>
<div class="col-span-12">
<PptConclusionBox :text="report?.sections?.acquisition_channels?.conclusion" />
</div>
</div>
</section>
<!-- Slide: Экономика маркетинга -->
<section class="space-y-3">
<div class="text-2xl font-semibold text-slate-900">{{ report?.sections?.marketing_economics?.title }}</div>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Стоимость клиента"
:value="cacValue"
subtitle=""
icon="pi-wallet"
:accentColor="PPT_BLUE"
description="Стоимость привлечения одного платящего клиента (CAC). Рассчитывается как отношение маркетинговых расходов к количеству новых клиентов. Показывает эффективность маркетинговых инвестиций."
/>
</div>
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Конверсия в повторные продажи"
:value="repeatSalesConversionValue"
subtitle=""
icon="pi-refresh"
:accentColor="PPT_GREEN"
description="Доля клиентов, совершающих вторую и последующие покупки. Показывает эффективность удержания клиентов и потенциал для увеличения LTV (lifetime value)."
/>
</div>
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Возврат инвестиций в маркетинг"
:value="roiValue"
subtitle=""
icon="pi-chart-line"
:accentColor="PPT_ORANGE"
description="Коэффициент возврата инвестиций (ROI). Показывает, во сколько раз выручка превышает вложения в маркетинг. Рассчитывается как (Прибыль / Инвестиции) × 100%."
/>
</div>
<div class="col-span-12">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3">
<div class="rounded-xl border border-slate-200/70 bg-slate-50/70 p-3">
<div class="flex items-center gap-1.5">
<div class="text-[12px] font-semibold text-slate-500">CAC</div>
<i
class="pi pi-question-circle text-[10px] text-slate-400 cursor-help"
v-tooltip.top="'Customer Acquisition Cost - стоимость привлечения одного нового клиента. Рассчитывается как: Маркетинговые расходы / Количество новых клиентов.'"
></i>
</div>
<div class="mt-2 katex-box" v-html="cacKatex"></div>
<div class="mt-2 text-[12px] text-slate-600">
Значение: <span class="font-semibold text-slate-800">{{ cacValue }}</span>
</div>
</div>
<div class="rounded-xl border border-slate-200/70 bg-slate-50/70 p-3">
<div class="flex items-center gap-1.5">
<div class="text-[12px] font-semibold text-slate-500">ROI</div>
<i
class="pi pi-question-circle text-[10px] text-slate-400 cursor-help"
v-tooltip.top="'Return on Investment - возврат инвестиций. Рассчитывается как: (Прибыль / Инвестиции) × 100%. Показывает эффективность маркетинговых вложений.'"
></i>
</div>
<div class="mt-2 katex-box" v-html="roiKatex"></div>
<div class="mt-2 text-[12px] text-slate-600">
Значение: <span class="font-semibold text-slate-800">{{ roiValue }}</span>
</div>
</div>
<div class="rounded-xl border border-slate-200/70 bg-slate-50/70 p-3">
<div class="text-[12px] font-semibold text-slate-500">Расходы на маркетинг</div>
<div class="mt-2 text-[16px] font-semibold text-slate-900">{{ report?.sections?.marketing_economics?.financial_performance?.marketing_expenses?.month }}</div>
<div class="text-[12px] text-slate-600">в месяц · {{ report?.sections?.marketing_economics?.financial_performance?.marketing_expenses?.year }} в год</div>
</div>
<div class="rounded-xl border border-slate-200/70 bg-slate-50/70 p-3">
<div class="text-[12px] font-semibold text-slate-500">Клиенты</div>
<div class="mt-2 text-[16px] font-semibold text-slate-900">{{ report?.sections?.marketing_economics?.financial_performance?.clients?.month }}</div>
<div class="text-[12px] text-slate-600">в месяц · {{ report?.sections?.marketing_economics?.financial_performance?.clients?.year }} в год</div>
</div>
</div>
</div>
</div>
<div class="col-span-12 lg:col-span-7">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4 h-full">
<div class="flex items-center gap-1.5 mb-3">
<div class="text-[16px] font-semibold text-slate-800">Основные показатели</div>
<i
class="pi pi-question-circle text-[12px] text-slate-400 cursor-help"
v-tooltip.top="'Сравнение расходов, количества клиентов, CAC, выручки и прибыли в разрезе Месяц против Год. Показывает динамику ключевых финансовых метрик.'"
></i>
</div>
<div class="overflow-hidden rounded-xl border border-slate-200/70">
<div class="grid grid-cols-12 bg-slate-50 text-[12px] font-semibold text-slate-600">
<div class="col-span-6 px-3 py-2">Показатель</div>
<div class="col-span-3 px-3 py-2 text-center">Месяц</div>
<div class="col-span-3 px-3 py-2 text-center">Год</div>
</div>
<div class="divide-y divide-slate-200/70">
<div v-for="row in economicsPerformanceRows" :key="row.key" class="grid grid-cols-12 px-3 py-3">
<div class="col-span-6 text-[13px] text-slate-800">{{ row.label }}</div>
<div class="col-span-3 text-[13px] text-slate-800 text-center">{{ row.month }}</div>
<div class="col-span-3 text-[13px] text-slate-800 text-center">{{ row.year }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-span-12 lg:col-span-5">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4 h-full">
<div class="text-[16px] font-semibold text-slate-800 mb-3">Процесс повторения</div>
<div class="overflow-hidden rounded-xl border border-slate-200/70">
<div class="grid grid-cols-12 bg-slate-50 text-[12px] font-semibold text-slate-600">
<div class="col-span-6 px-3 py-2">Этап</div>
<div class="col-span-3 px-3 py-2 text-center">Стоимость</div>
<div class="col-span-3 px-3 py-2">Эффективность</div>
</div>
<div class="divide-y divide-slate-200/70">
<div v-for="row in channelEfficiencyRows" :key="`econ-${row.key}`" class="grid grid-cols-12 px-3 py-3 items-center">
<div class="col-span-6 text-[13px] text-slate-800">{{ row.label }}</div>
<div class="col-span-3 text-[13px] text-slate-800 text-center">{{ row.cost }}</div>
<div class="col-span-3 text-[13px]" :class="row.statusClass">{{ row.status }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-span-12">
<PptConclusionBox :text="report?.sections?.marketing_economics?.conclusion" />
</div>
</div>
</section>
<!-- Slide: Проблемы и узкие места -->
<section class="space-y-3">
<div class="text-2xl font-semibold text-slate-900">{{ report?.sections?.bottlenecks?.title }}</div>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Проблемы на лендингах"
:value="bottlenecksLandingIssuesValue"
subtitle=""
icon="pi-exclamation-triangle"
:accentColor="PPT_BLUE"
description="Процентное влияние проблем на лендингах (сайтах). Показывает, насколько сильно проблемы с сайтом мешают конверсии клиентов."
/>
</div>
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Медленные ответы менеджеров"
:value="bottlenecksSlowResponsesValue"
subtitle=""
icon="pi-refresh"
:accentColor="PPT_GREEN"
description="Процентное влияние медленных ответов менеджеров на потери клиентов. Показывает важность скорости обработки лидов отделом продаж."
/>
</div>
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Отказ на повторных касаниях"
:value="bottlenecksRepeatRefusalValue"
subtitle=""
icon="pi-plus"
:accentColor="PPT_ORANGE"
description="Процентное влияние проблем с удержанием клиентов. Показывает, насколько сильно проблемы с ретеншном мешают повторным продажам."
/>
</div>
<div class="col-span-12">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4">
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 lg:col-span-4">
<div class="rounded-2xl border border-slate-200/70 bg-white p-4 h-full">
<div class="flex items-center gap-2 mb-3">
<div class="h-8 w-8 rounded-xl flex items-center justify-center" :style="{ background: 'rgba(91,137,255,0.14)', color: PPT_BLUE }">
<i class="pi pi-cog"></i>
</div>
<div class="flex items-center gap-1.5">
<div class="text-[15px] font-semibold text-slate-800">Лендинг</div>
<i
class="pi pi-question-circle text-[11px] text-slate-400 cursor-help"
v-tooltip.top="'Конкретные списки недоработок на сайте/лендинге. Показывает проблемы, которые мешают конверсии посетителей в клиентов.'"
></i>
</div>
</div>
<ul class="list-disc pl-5 space-y-2 text-[13px] text-slate-600">
<li v-for="t in bottlenecksDetails.landing" :key="t">{{ t }}</li>
</ul>
</div>
</div>
<div class="col-span-12 lg:col-span-4">
<div class="rounded-2xl border border-slate-200/70 bg-white p-4 h-full">
<div class="flex items-center gap-2 mb-3">
<div class="h-8 w-8 rounded-xl flex items-center justify-center" :style="{ background: 'rgba(255,142,79,0.16)', color: PPT_ORANGE }">
<i class="pi pi-clock"></i>
</div>
<div class="flex items-center gap-1.5">
<div class="text-[15px] font-semibold text-slate-800">Работа с лидами</div>
<i
class="pi pi-question-circle text-[11px] text-slate-400 cursor-help"
v-tooltip.top="'Конкретные списки недоработок в отделе продаж (Lead Management). Показывает проблемы в обработке лидов и работе с потенциальными клиентами.'"
></i>
</div>
</div>
<ul class="list-disc pl-5 space-y-2 text-[13px] text-slate-600">
<li v-for="t in bottlenecksDetails.lead_management" :key="t">{{ t }}</li>
</ul>
</div>
</div>
<div class="col-span-12 lg:col-span-4">
<div class="rounded-2xl border border-slate-200/70 bg-white p-4 h-full">
<div class="flex items-center gap-2 mb-3">
<div class="h-8 w-8 rounded-xl flex items-center justify-center" :style="{ background: 'rgba(255,107,107,0.14)', color: '#FF6B6B' }">
<i class="pi pi-chart-pie"></i>
</div>
<div class="flex items-center gap-1.5">
<div class="text-[15px] font-semibold text-slate-800">Ретеншн</div>
<i
class="pi pi-question-circle text-[11px] text-slate-400 cursor-help"
v-tooltip.top="'Конкретные списки недоработок в удержании клиентов (Retention). Показывает проблемы, которые мешают повторным покупкам и долгосрочным отношениям с клиентами.'"
></i>
</div>
</div>
<ul class="list-disc pl-5 space-y-2 text-[13px] text-slate-600">
<li v-for="t in bottlenecksDetails.retention" :key="t">{{ t }}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="col-span-12">
<PptConclusionBox :text="report?.sections?.bottlenecks?.conclusion" />
</div>
</div>
</section>
<!-- Slide: Рекомендации -->
<section class="space-y-3">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4 md:p-6">
<div class="text-[13px] text-slate-500 mb-1">Маркетинг <span class="mx-1"></span> Рекомендации</div>
<div class="flex items-center justify-center gap-2">
<div class="text-3xl font-semibold tracking-wide text-center text-slate-800">РЕКОМЕНДАЦИИ</div>
<i
class="pi pi-question-circle text-[14px] text-slate-400 cursor-help"
v-tooltip.top="
'Массив ключевых стратегий развития. Каждая содержит название направления (например, «Оптимизация сайта») и конкретное описание действий для реализации. Финальный прогноз эффективности при условии внедрения рекомендаций.'
"
></i>
</div>
<div class="text-center text-slate-500 text-sm mt-1">итоги маркетингового анализа для интернет-продвижения</div>
<div class="mt-6 grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Стоимость клиента"
:value="cacValue"
subtitle=""
icon="pi-wallet"
:accentColor="PPT_BLUE"
description="Стоимость привлечения одного платящего клиента (CAC). Рассчитывается как отношение маркетинговых расходов к количеству новых клиентов."
/>
</div>
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Конверсия в повторные продажи"
:value="repeatSalesConversionValue"
subtitle=""
icon="pi-refresh"
:accentColor="PPT_GREEN"
description="Доля клиентов, совершающих вторую и последующие покупки. Показывает эффективность удержания клиентов."
/>
</div>
<div class="col-span-12 md:col-span-4">
<PptKpiCard
title="Возврат инвестиций"
:value="roiValue"
subtitle=""
icon="pi-chart-line"
:accentColor="PPT_ORANGE"
description="Коэффициент возврата инвестиций (ROI). Показывает, во сколько раз выручка превышает вложения в маркетинг."
/>
</div>
<div class="col-span-12 grid grid-cols-12 gap-4">
<div v-for="(r, idx) in recommendationMessages" :key="r.key" class="col-span-12 md:col-span-6 xl:col-span-3">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4 h-full">
<div class="flex items-start gap-3">
<div
class="h-10 w-10 rounded-xl flex items-center justify-center border border-slate-200/70"
:style="{
background: idx === 0 ? 'rgba(91,137,255,0.14)' : idx === 1 ? 'rgba(82,196,26,0.14)' : idx === 2 ? 'rgba(255,142,79,0.14)' : 'rgba(91,137,255,0.10)',
color: idx === 1 ? PPT_GREEN : idx === 2 ? PPT_ORANGE : PPT_BLUE
}"
>
<i class="pi" :class="idx === 0 ? 'pi-bullseye' : idx === 1 ? 'pi-comments' : idx === 2 ? 'pi-star' : 'pi-chart-line'"></i>
</div>
<div class="min-w-0">
<div class="text-[15px] font-semibold text-slate-800">{{ r.strategy }}</div>
<div class="mt-2 text-[13px] leading-snug text-slate-600">{{ r.action }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-span-12">
<PptConclusionBox :text="report?.sections?.recommendations?.conclusion" />
</div>
</div>
</div>
</section>
<!-- Источники -->
<section class="space-y-3">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4 md:p-6">
<div class="text-[13px] text-slate-500 mb-1">Маркетинг <span class="mx-1"></span> Источники</div>
<div class="flex items-center justify-center gap-2">
<div class="text-3xl font-semibold tracking-wide text-center text-slate-800">ИСТОЧНИКИ</div>
<i
class="pi pi-info-circle text-[14px] text-slate-400 cursor-help"
v-tooltip.top="'Список источников данных, использованных при подготовке маркетингового анализа'"
></i>
</div>
<div class="text-center text-slate-500 text-sm mt-1">список источников данных для анализа</div>
<div class="mt-6">
<template v-if="report?.sources && Array.isArray(report.sources) && report.sources.length > 0">
<div class="space-y-3">
<div v-for="(source, index) in report.sources" :key="index" class="flex items-start gap-3 p-3 rounded-xl border border-slate-200/60 hover:bg-slate-50/50 transition-colors">
<div class="h-8 w-8 rounded-lg flex items-center justify-center border border-slate-200/70 bg-slate-50/50 flex-shrink-0">
<i class="pi pi-link text-slate-500 text-sm"></i>
</div>
<a
:href="source"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-blue-600 hover:text-blue-800 hover:underline break-all flex-1"
>
{{ source }}
</a>
</div>
</div>
</template>
<div v-else class="text-center text-slate-400 text-sm py-8">
<i class="pi pi-info-circle mr-2"></i>
Источники не найдены
</div>
</div>
</div>
</section>
<!-- Кнопка создания стратегии -->
<section v-if="report && !embedded" class="space-y-3">
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4 md:p-6">
<div class="flex items-center justify-center">
<Button
label="Создать стратегию"
icon="pi pi-magic"
severity="success"
size="large"
@click="goToStrategy"
/>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup>
import katex from 'katex';
import Button from 'primevue/button';
import Dropdown from 'primevue/dropdown';
import InputText from 'primevue/inputtext';
import Message from 'primevue/message';
import MultiSelect from 'primevue/multiselect';
import ProgressSpinner from 'primevue/progressspinner';
import Textarea from 'primevue/textarea';
import { useToast } from 'primevue/usetoast';
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import PptConclusionBox from '@/components/marketing/PptConclusionBox.vue';
import PptKpiCard from '@/components/marketing/PptKpiCard.vue';
import MarketingService from '@/service/MarketingService';
const props = defineProps({
embedded: { type: Boolean, default: false },
report: { type: Object, default: null },
loading: { type: Boolean, default: false },
error: { type: String, default: null }
});
const toast = useToast();
const route = useRoute();
const router = useRouter();
const localReport = ref(null);
const localLoading = ref(false);
const localError = ref(null);
// unify both modes: in embedded mode we read props; otherwise local state
const report = computed(() => {
const r = props.report || localReport.value;
if (r && !r.sources) {
// Если sources отсутствуют, проверяем исходные данные
if (props.report?.sources) {
return { ...r, sources: props.report.sources };
} else if (localReport.value?.sources) {
return { ...r, sources: localReport.value.sources };
}
}
return r;
});
const loading = computed(() => (props.embedded ? props.loading : localLoading.value));
const error = computed(() => (props.embedded ? props.error : localError.value));
// Watch для отслеживания изменений report и sources (можно использовать для отладки)
// watch(() => report.value, (newReport) => {
// if (newReport) {
// console.log('Report updated:', { keys: Object.keys(newReport), hasSources: !!newReport.sources });
// }
// }, { immediate: true, deep: true });
const DETAIL_LEVEL_OPTIONS = ['КРАТКО', 'СТАНДАРТНО', 'ПОДРОБНО'];
const ANALYSIS_TYPE_OPTIONS = ['РЫНОК', 'КОНКУРЕНТЫ', 'ЦА', 'КАНАЛЫ', 'SWOT'];
const GENDER_OPTIONS = ['Женщины', 'Мужчины'];
const AGE_RANGE_OPTIONS = ['18-25', '20-40', '25-45', '40-60', '60+'];
const AUDIENCE_TYPE_OPTIONS = ['Семьи', 'Молодёжь', 'Все подряд'];
const REGION_OPTIONS = ['Астана', 'Алматы', 'Шымкент', 'Караганда', 'Актобе', 'Тараз', 'Павлодар', 'Усть-Каменогорск', 'Семей', 'Костанай'];
const formData = ref({
businessNiche: '',
product: '',
targetAudience: {
genders: [],
ageRanges: [],
types: []
},
region: [],
goal: '',
detailLevel: 'СТАНДАРТНО',
strongSide: '',
weakSide: '',
analysisType: []
});
function buildTargetAudiencePayload(ta) {
const targetAudience = {};
if (ta?.genders?.length) targetAudience.genders = ta.genders;
if (ta?.ageRanges?.length) targetAudience.ageRanges = ta.ageRanges;
if (ta?.types?.length) targetAudience.types = ta.types;
return targetAudience;
}
async function loadAnalysisV2() {
if (props.embedded) return;
localError.value = null;
localLoading.value = true;
localReport.value = null;
try {
const requestData = {
businessNiche: formData.value.businessNiche?.trim(),
product: formData.value.product?.trim(),
targetAudience: buildTargetAudiencePayload(formData.value.targetAudience),
region: formData.value.region,
goal: formData.value.goal?.trim(),
detailLevel: formData.value.detailLevel,
analysisType: formData.value.analysisType
};
if (formData.value.strongSide?.trim()) requestData.strongSide = formData.value.strongSide.trim();
if (formData.value.weakSide?.trim()) requestData.weakSide = formData.value.weakSide.trim();
const data = await MarketingService.fetchAnalysisV2(requestData);
// MarketingService.fetchAnalysisV2 уже извлекает data из обёртки {success, message, data}
// Но на всякий случай проверяем, может быть еще одна обёртка
let extractedData = (data?.data || data) || null;
// Убеждаемся, что sources сохраняются - проверяем все возможные места
if (extractedData && !extractedData.sources) {
// Проверяем все возможные места, где могут быть sources
if (data?.data?.sources) {
extractedData.sources = data.data.sources;
} else if (data?.sources) {
extractedData.sources = data.sources;
}
}
// Создаём новый объект, чтобы гарантировать, что все поля сохранены
if (extractedData) {
localReport.value = {
...extractedData,
sources: extractedData.sources || []
};
} else {
localReport.value = null;
}
toast.add({
severity: 'success',
summary: 'Готово',
detail: 'Отчёт загружен из API v2',
life: 2500
});
} catch (e) {
localError.value = e?.message || 'Ошибка API';
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: localError.value,
life: 4500
});
} finally {
localLoading.value = false;
}
}
function resetDashboard() {
if (props.embedded) return;
localError.value = null;
localReport.value = null;
formData.value = {
businessNiche: '',
product: '',
targetAudience: { genders: [], ageRanges: [], types: [] },
region: [],
goal: '',
detailLevel: 'СТАНДАРТНО',
strongSide: '',
weakSide: '',
analysisType: []
};
}
function goToStrategy() {
const analysisId = route.query.analysisId;
if (analysisId) {
router.push({
name: 'marketing-promotion',
query: { analysisId }
});
} else {
toast.add({
severity: 'warn',
summary: 'Предупреждение',
detail: 'Не найден ID анализа',
life: 3000
});
}
}
const PPT_BLUE = '#5B89FF';
const PPT_ORANGE = '#FF8E4F';
const PPT_GREEN = '#52C41A';
const PPT_GRADIENT_BLUE_START = '#5B89FF';
const PPT_GRADIENT_BLUE_END = '#82A7FF';
const roiValue = computed(() => report.value?.sections?.marketing_economics?.metrics?.roi ?? '—');
const cacValue = computed(() => report.value?.sections?.marketing_economics?.metrics?.customer_acquisition_cost_cac ?? '—');
const repeatSalesConversionValue = computed(() => report.value?.sections?.marketing_economics?.metrics?.repeat_sales_conversion ?? '—');
const marketDemandIndexValue = computed(() => report.value?.sections?.market_overview?.metrics?.online_demand_index ?? '—');
const marketYoYGrowthValue = computed(() => report.value?.sections?.market_overview?.metrics?.demand_growth_rate_yoy ?? '—');
const marketSeasonalityValue = computed(() => report.value?.sections?.market_overview?.metrics?.seasonality_coefficient_spring ?? '—');
const seasonalityRangeValue = computed(() => report.value?.sections?.market_overview?.metrics?.seasonality_range ?? '—');
const marketPotentialSubtitle = computed(() => report.value?.sections?.market_overview?.metrics?.online_potential ?? 'высокий онлайн-потенциал');
const audienceTotalValue = computed(() => report.value?.sections?.target_audience?.metrics?.total_audience_size ?? '—');
const audienceLeadingSegmentValue = computed(() => report.value?.sections?.target_audience?.metrics?.leading_segment_share ?? '—');
const audienceIncomeAboveAvgText = computed(() => report.value?.sections?.target_audience?.metrics?.above_average_income_share ?? '—');
const competitionMetrics = computed(() => report.value?.sections?.competitive_environment?.metrics ?? {});
const competitionHasNewMetrics = computed(() => {
const m = competitionMetrics.value;
return Boolean(m.leader_market_share || m.competition_level || m.ad_pressure || m.market_leader_name);
});
const competitionLeaderSubtitle = computed(() => competitionMetrics.value?.market_leader_name ?? '');
const competitionMarketShareValue = computed(() => competitionMetrics.value?.leader_market_share ?? competitionMetrics.value?.market_share_traffic ?? '—');
const competitionRankValue = computed(() => competitionMetrics.value?.visibility_rank ?? competitionMetrics.value?.competition_level ?? '—');
const competitionCompetitorShareValue = computed(() => competitionMetrics.value?.competitor_share_in_context_ads ?? competitionMetrics.value?.ad_pressure ?? '—');
const competitionBenchmarkValue = computed(() => competitionMetrics.value?.benchmark_astana ?? '—');
const competitionRankTitle = computed(() => (competitionHasNewMetrics.value ? 'Уровень конкуренции' : 'Место в видимости'));
const competitionAdPressureTitle = computed(() => (competitionHasNewMetrics.value ? 'Давление в рекламе' : 'Доля конкурентов в контекстной рекламе'));
const competitionRankDescription = computed(() =>
competitionHasNewMetrics.value
? 'Общая оценка насыщенности рынка конкурентами. Помогает понять уровень борьбы за клиента.'
: 'Позиция бренда в поисковой выдаче и рекламных каналах относительно конкурентов. Чем ниже число, тем выше позиция.'
);
const competitionAdPressureDescription = computed(() =>
competitionHasNewMetrics.value
? 'Характер рекламного давления со стороны конкурентов. Показывает, насколько агрессивны их кампании.'
: 'Активность конкурентов в платной рекламе. Показывает уровень конкуренции в контекстной рекламе и необходимость инвестиций в этот канал.'
);
const competitionAdPressureSubtitle = computed(() => {
if (competitionHasNewMetrics.value) return '';
const benchmark = competitionBenchmarkValue.value;
return benchmark && benchmark !== '—' ? `Бенчмарк (Астана) ${benchmark}` : '';
});
const behaviorSearchGrowthValue = computed(() => report.value?.sections?.demand_and_behavior?.metrics?.search_volume_growth ?? '—');
const behaviorInteractionsValue = computed(() => report.value?.sections?.demand_and_behavior?.metrics?.interactions_before_purchase ?? '—');
const behaviorRepeatRefusalValue = computed(() => report.value?.sections?.demand_and_behavior?.metrics?.repeat_refusal_probability ?? '—');
const channelsSearchShareValue = computed(() => report.value?.sections?.acquisition_channels?.metrics?.search_traffic_share ?? '—');
const channelsLeadCostValue = computed(() => report.value?.sections?.acquisition_channels?.metrics?.average_lead_cost ?? '—');
const channelsGrowthPotentialValue = computed(() => report.value?.sections?.acquisition_channels?.metrics?.growth_potential ?? '—');
const bottlenecksLandingIssuesValue = computed(() => report.value?.sections?.bottlenecks?.statistics?.landing_page_issues ?? '—');
const bottlenecksSlowResponsesValue = computed(() => report.value?.sections?.bottlenecks?.statistics?.slow_manager_responses ?? '—');
const bottlenecksRepeatRefusalValue = computed(() => report.value?.sections?.bottlenecks?.statistics?.repeat_interaction_refusal ?? '—');
const aboveAvgIncomeValue = computed(() => {
const raw = report.value?.sections?.target_audience?.metrics?.above_average_income_share ?? '0%';
const n = Number(String(raw).replace(/[^\d.]/g, ''));
return Number.isFinite(n) ? Math.max(0, Math.min(100, n)) : 0;
});
function makeVerticalGradient(ctx, area, fromColor, toColor) {
const g = ctx.createLinearGradient(0, area.top, 0, area.bottom);
g.addColorStop(0, fromColor);
g.addColorStop(1, toColor);
return g;
}
function hexToRgba(hex, alpha) {
const h = String(hex || '')
.replace('#', '')
.trim();
const full =
h.length === 3
? h
.split('')
.map((c) => c + c)
.join('')
: h;
if (full.length !== 6) return `rgba(91, 137, 255, ${alpha})`;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
// Charts: Market dynamics (line / area)
const MARKET_MONTHS = [
{ key: 'january', label: 'Янв' },
{ key: 'february', label: 'Фев' },
{ key: 'march', label: 'Мар' },
{ key: 'april', label: 'Апр' },
{ key: 'may', label: 'Май' },
{ key: 'june', label: 'Июн' },
{ key: 'july', label: 'Июл' },
{ key: 'august', label: 'Авг' },
{ key: 'september', label: 'Сен' },
{ key: 'october', label: 'Окт' },
{ key: 'november', label: 'Ноя' },
{ key: 'december', label: 'Дек' }
];
const marketDynamicsLineData = computed(() => {
const monthly = report.value?.sections?.market_overview?.monthly_online_index ?? {};
const labels = MARKET_MONTHS.map((m) => m.label);
const values = MARKET_MONTHS.map((m) => {
const v = monthly?.[m.key];
return typeof v === 'number' ? v : 0;
});
return {
labels,
datasets: [
{
label: 'Индекс',
data: values,
fill: true,
borderColor: PPT_BLUE,
backgroundColor: (context) => {
const { chart } = context;
const { ctx, chartArea } = chart;
if (!chartArea) return 'rgba(91,137,255,0.18)';
return makeVerticalGradient(ctx, chartArea, 'rgba(91,137,255,0.28)', 'rgba(91,137,255,0.02)');
},
tension: 0.45,
pointRadius: 4,
pointHoverRadius: 5,
pointBorderWidth: 2,
pointBackgroundColor: PPT_BLUE,
pointBorderColor: '#ffffff'
}
]
};
});
const marketDynamicsTableRows = computed(() => {
const monthly = report.value?.sections?.market_overview?.monthly_online_index ?? {};
return MARKET_MONTHS.map((m) => {
const raw = monthly?.[m.key];
const parsed = typeof raw === 'number' ? raw : Number(String(raw ?? '').replace(/[^\d.-]/g, ''));
return {
label: m.label,
value: Number.isFinite(parsed) ? parsed : '—'
};
});
});
const marketDynamicsLineOptions = computed(() => ({
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: '#64748B' }, grid: { color: 'rgba(148,163,184,0.25)', drawBorder: false } },
y: { ticks: { color: '#64748B' }, grid: { color: 'rgba(148,163,184,0.25)', drawBorder: false } }
}
}));
// Audience: Age donut
function parsePercent(value) {
const s = String(value ?? '').trim();
const range = s.match(/(\d+)\s*-\s*(\d+)\s*%/);
if (range) return (Number(range[1]) + Number(range[2])) / 2;
const single = s.match(/(\d+)\s*%/);
if (single) return Number(single[1]);
return null;
}
const ageRows = computed(() => {
const a = report.value?.sections?.target_audience?.age_structure ?? {};
// Маппинг ключей бэкенда на ключи фронтенда
// Если ключ бэкенда пересекается с несколькими группами, распределяем значение
const backendKeyMap = {
'18-24': { '18-24': 1.0 },
'18-25': { '18-24': 1.0 }, // Маппим на 18-24
'25-34': { '25-34': 1.0 },
'25-45': { '25-34': 0.5, '35-44': 0.5 }, // Распределяем поровну
'35-44': { '35-44': 1.0 },
'20-40': { '25-34': 0.5, '35-44': 0.5 }, // Распределяем поровну
'45-54': { '45-54': 1.0 },
'45+': { '45-54': 1.0 },
'40-60': { '45-54': 1.0 }, // Маппим на 45-54
'55+': { '55+': 1.0 },
'60+': { '55+': 1.0 },
'60_plus': { '55+': 1.0 }
};
// Стандартный порядок для отображения
const order = [
{ key: '18-24', label: '1824' },
{ key: '25-34', label: '2534' },
{ key: '35-44', label: '3544' },
{ key: '45-54', label: '4554' },
{ key: '55+', label: '55+' }
];
// Агрегируем значения из бэкенда
const aggregated = {};
Object.entries(a).forEach(([backendKey, value]) => {
const mapping = backendKeyMap[backendKey];
if (mapping) {
const parsedValue = parsePercent(value);
if (Number.isFinite(parsedValue)) {
Object.entries(mapping).forEach(([frontendKey, ratio]) => {
aggregated[frontendKey] = (aggregated[frontendKey] || 0) + parsedValue * ratio;
});
}
}
});
return order.map((o) => {
const v = aggregated[o.key] || 0;
return {
key: o.key,
label: o.label,
value: Number.isFinite(v) ? Math.round(v) : 0
};
});
});
const barValuesPlugin = {
id: 'barValues',
afterDatasetsDraw(chart) {
const { ctx, data } = chart;
ctx.save();
ctx.font = 'bold 12px Inter, system-ui, sans-serif';
ctx.textBaseline = 'middle';
data.datasets.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i);
if (!meta.hidden) {
meta.data.forEach((bar, index) => {
const value = dataset.data[index];
if (value !== null && value !== undefined && value !== 0) {
const unit = dataset.unit || '';
let label = value.toLocaleString();
if (typeof value === 'number' && !Number.isInteger(value) && value < 10) {
label = value.toFixed(2);
}
label += unit;
const isHorizontal = chart.options.indexAxis === 'y';
if (isHorizontal) {
const barWidth = Math.abs(bar.x - bar.base);
if (barWidth > 60) {
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'right';
ctx.fillText(label, bar.x - 12, bar.y);
} else {
ctx.fillStyle = '#64748b';
ctx.textAlign = 'left';
ctx.fillText(label, bar.x + 10, bar.y);
}
} else {
const barHeight = Math.abs(bar.base - bar.y);
ctx.textAlign = 'center';
if (barHeight > 35) {
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 12px Inter, system-ui, sans-serif';
ctx.fillText(label, bar.x, bar.y + 20);
} else {
ctx.fillStyle = '#64748b';
ctx.font = 'bold 10px Inter, system-ui, sans-serif';
ctx.fillText(label, bar.x, bar.y - 15);
}
}
}
});
}
});
ctx.restore();
}
};
const donutPercentPlugin = {
id: 'donutPercent',
afterDatasetsDraw(chart) {
const ctx = chart.ctx;
const data = chart.data.datasets[0]?.data || [];
const total = data.reduce((a, b) => a + b, 0);
if (total === 0) return;
chart.data.datasets.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i);
meta.data.forEach((element, index) => {
const value = dataset.data[index];
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
if (parseFloat(percentage) < 3) return;
const arc = element;
const { x, y, startAngle, endAngle, innerRadius, outerRadius } = arc;
const angle = (startAngle + endAngle) / 2;
const radius = (innerRadius + outerRadius) / 2;
const centerX = x + Math.cos(angle) * radius;
const centerY = y + Math.sin(angle) * radius;
ctx.save();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 14px Inter, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0, 0, 0, 0.4)';
ctx.shadowBlur = 4;
ctx.shadowOffsetX = 1;
ctx.shadowOffsetY = 1;
ctx.fillText(`${percentage}%`, centerX, centerY);
ctx.restore();
});
});
}
};
const barPlugins = [barValuesPlugin];
const donutPlugins = [donutPercentPlugin];
const compactBarOptions = computed(() => ({
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: { ticks: { color: '#64748B', font: { size: 11 } }, grid: { display: false, drawBorder: false } },
y: {
ticks: { color: '#64748B', font: { size: 10 } },
grid: { color: 'rgba(148,163,184,0.15)', drawBorder: false },
beginAtZero: true
}
}
}));
const compactDonutOptions = computed(() => ({
maintainAspectRatio: false,
plugins: {
legend: { position: 'right', labels: { usePointStyle: true, color: '#334155', boxWidth: 8 } }
},
cutout: '62%'
}));
// Опции для donut диаграммы с отображением процентов
const queryTypesDonutOptions = computed(() => {
return {
maintainAspectRatio: false,
plugins: {
legend: { position: 'right', labels: { usePointStyle: true, color: '#334155', boxWidth: 8 } },
tooltip: {
callbacks: {
label: function (context) {
const label = context.label || '';
const value = context.parsed || 0;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
return `${label}: ${percentage}%`;
}
}
},
// Кастомный плагин для отображения процентов на сегментах
donutPercent: {
id: 'donutPercent',
afterDatasetsDraw(chart) {
const ctx = chart.ctx;
const data = chart.data.datasets[0]?.data || [];
const total = data.reduce((a, b) => a + b, 0);
if (total === 0) return;
chart.data.datasets.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i);
meta.data.forEach((element, index) => {
const value = dataset.data[index];
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
// Пропускаем отображение, если процент слишком мал
if (parseFloat(percentage) < 3) return;
// Получаем центр сегмента (для donut диаграммы используем центр дуги)
const arc = element;
const { x, y, startAngle, endAngle, innerRadius, outerRadius } = arc;
// Вычисляем средний угол для размещения текста
const angle = (startAngle + endAngle) / 2;
// Размещаем текст на середине между innerRadius и outerRadius
const radius = (innerRadius + outerRadius) / 2;
// Вычисляем координаты центра сегмента
const centerX = x + Math.cos(angle) * radius;
const centerY = y + Math.sin(angle) * radius;
ctx.save();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 13px Inter, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0, 0, 0, 0.4)';
ctx.shadowBlur = 4;
ctx.shadowOffsetX = 1;
ctx.shadowOffsetY = 1;
ctx.fillText(`${percentage}%`, centerX, centerY);
ctx.restore();
});
});
}
}
},
cutout: '62%'
};
});
const ageBarData = computed(() => ({
labels: ageRows.value.map((r) => r.label),
datasets: [
{
data: ageRows.value.map((r) => r.value),
unit: '%',
borderRadius: 10,
backgroundColor: (context) => {
const { chart } = context;
const { ctx, chartArea } = chart;
if (!chartArea) return PPT_BLUE;
return makeVerticalGradient(ctx, chartArea, PPT_GRADIENT_BLUE_START, PPT_GRADIENT_BLUE_END);
}
}
]
}));
// Income by age rows
const incomeRows = computed(() => {
const incomeByAge = report.value?.sections?.target_audience?.income_by_age ?? {};
// Маппинг ключей бэкенда на ключи фронтенда
const backendKeyMap = {
'18-24': { '18-24': 1.0 },
'18-25': { '18-24': 1.0 },
'25-34': { '25-34': 1.0 },
'25-45': { '25-34': 0.5, '35-44': 0.5 },
'35-44': { '35-44': 1.0 },
'20-40': { '25-34': 0.5, '35-44': 0.5 },
'45-54': { '45-54': 1.0 },
'45+': { '45-54': 1.0 },
'40-60': { '45-54': 1.0 },
'55+': { '55+': 1.0 },
'60+': { '55+': 1.0 },
'60_plus': { '55+': 1.0 }
};
// Стандартный порядок для отображения
const order = [
{ key: '18-24', label: '1824' },
{ key: '25-34', label: '2534' },
{ key: '35-44', label: '3544' },
{ key: '45-54', label: '4554' },
{ key: '55+', label: '55+' }
];
// Агрегируем значения из бэкенда
const aggregated = {};
Object.keys(incomeByAge).forEach((backendKey) => {
const mapping = backendKeyMap[backendKey];
if (mapping) {
const value = Number(incomeByAge[backendKey]) || 0;
Object.keys(mapping).forEach((frontendKey) => {
if (!aggregated[frontendKey]) aggregated[frontendKey] = 0;
aggregated[frontendKey] += value * mapping[frontendKey];
});
}
});
// Формируем строки в нужном порядке
return order
.map(({ key, label }) => {
const value = aggregated[key] || 0;
return { key, label, value: Math.round(value) };
})
.filter((row) => row.value > 0);
});
const incomeBarData = computed(() => ({
labels: incomeRows.value.map((r) => r.label),
datasets: [
{
data: incomeRows.value.map((r) => r.value),
unit: ' ₸',
borderRadius: 10,
backgroundColor: (context) => {
const { chart } = context;
const { ctx, chartArea } = chart;
if (!chartArea) return PPT_GREEN;
return makeVerticalGradient(ctx, chartArea, PPT_GREEN, '#73D13D');
}
}
]
}));
const incomeTableRows = computed(() => {
return incomeRows.value.map((row) => ({
label: row.label,
value: row.value.toLocaleString('ru-RU')
}));
});
// Segments list
const segmentRows = computed(() => {
const segs = report.value?.sections?.target_audience?.segments ?? [];
return segs
.map((s) => {
const shareText = s?.share !== undefined && s?.share !== null ? String(s.share) : '';
const n = Number(String(shareText).replace(/[^\d.]/g, ''));
const value = Number.isFinite(n) ? n : 0;
return {
name: s?.name ?? '—',
value,
shareLabel: shareText || (value ? `${value}%` : '—')
};
})
.sort((a, b) => b.value - a.value);
});
// Geography rows
const geographyRows = computed(() => {
const geo = report.value?.sections?.market_overview?.geography ?? {};
if (typeof geo !== 'object' || geo === null) return [];
const entries = Object.entries(geo)
.map(([city, value]) => ({
city: String(city),
value: typeof value === 'number' ? value : Number(String(value).replace(/[^\d.]/g, '')) || 0
}))
.filter((item) => item.value > 0)
.sort((a, b) => b.value - a.value);
if (entries.length === 0) return [];
const maxValue = entries[0].value;
return entries.map((item) => ({
...item,
percentage: maxValue > 0 ? Math.round((item.value / maxValue) * 100) : 0
}));
});
// Geography color helpers
function getGeographyColor(percentage, index) {
// High values (orange/red) -> Low values (green)
if (percentage >= 80) return '#ff8e4f';
if (percentage >= 60) return '#ffa366';
if (percentage >= 40) return '#ffb880';
if (percentage >= 20) return '#7dd87d';
return '#52c41a';
}
function getGeographyGradient(percentage, index) {
const color = getGeographyColor(percentage, index);
// Create a subtle gradient from the color to a lighter version
if (percentage >= 60) {
return `linear-gradient(90deg, ${color}, ${color}dd)`;
}
return `linear-gradient(90deg, ${color}, ${color}aa)`;
}
// Competitive: visibility horizontal bar
const competitiveListRows = computed(() => {
const list = report.value?.sections?.competitive_environment?.competitors;
if (!Array.isArray(list)) return [];
return list
.map((competitor, index) => {
if (!competitor || typeof competitor !== 'object') return null;
const label = competitor.name ? String(competitor.name) : `Конкурент ${index + 1}`;
const visibilityRaw = competitor.visibility_score ?? competitor.visibilityScore ?? 0;
const visibility = typeof visibilityRaw === 'number' ? visibilityRaw : percentToNumber(visibilityRaw);
return {
key: competitor.name ? String(competitor.name) : `competitor_${index + 1}`,
label,
visibility: Number.isFinite(visibility) ? visibility : 0,
strengths: Array.isArray(competitor.key_strengths)
? competitor.key_strengths
: Array.isArray(competitor.keyStrengths)
? competitor.keyStrengths
: [],
priceSegment: competitor.price_segment ?? competitor.priceSegment ?? '',
website: competitor.website ?? ''
};
})
.filter(Boolean);
});
const comparisonRows = computed(() => {
if (competitiveListRows.value.length > 0) {
return competitiveListRows.value.map((row) => ({
key: row.key,
label: row.label,
visibility: row.visibility,
strengths: row.strengths,
priceSegment: row.priceSegment,
website: row.website
}));
}
const bench = report.value?.sections?.competitive_environment?.digital_visibility_benchmarks ?? {};
const strengths = report.value?.sections?.competitive_environment?.competitor_strengths ?? {};
const mapLabel = {
competitor_a: 'Competitor A',
competitor_b: 'Competitor B',
competitor_c: 'Competitor C',
your_business: 'Your business',
lead: 'Lead',
payment: 'Payment',
others: 'Others'
};
return Object.entries(bench).map(([key, visibility]) => ({
key,
label: mapLabel[key] ?? key,
visibility,
strengths: strengths[key] ?? []
}));
});
const visibilityBarData = computed(() => {
const labels = comparisonRows.value.map((r) => r.label);
const values = comparisonRows.value.map((r) => (typeof r.visibility === 'number' ? r.visibility : 0));
const keys = comparisonRows.value.map((r) => r.key);
const baseByKey = {
competitor_a: PPT_GREEN,
your_business: PPT_BLUE,
lead: '#A3E635',
payment: PPT_ORANGE,
others: '#FF6B6B'
};
const fallbackColors = [PPT_BLUE, PPT_GREEN, PPT_ORANGE, '#60A5FA', '#A855F7', '#EC4899', '#F59E0B'];
return {
labels,
datasets: [
{
data: values,
unit: '%',
borderRadius: 12,
borderSkipped: false,
backgroundColor: (context) => {
const { chart, dataIndex } = context;
const { ctx, chartArea } = chart;
const base = baseByKey[keys[dataIndex]] ?? fallbackColors[dataIndex % fallbackColors.length];
if (!chartArea) return hexToRgba(base, 0.55);
return makeVerticalGradient(ctx, chartArea, hexToRgba(base, 0.88), hexToRgba(base, 0.24));
}
}
]
};
});
const visibilityBarOptions = computed(() => {
const base = compactBarOptions.value;
return {
...base,
indexAxis: 'y',
scales: {
x: { ...base.scales.x, grid: { color: 'rgba(148,163,184,0.15)', drawBorder: false } },
y: { ...base.scales.y, grid: { display: false, drawBorder: false } }
}
};
});
const pptFunnelSteps = computed(() => {
const fm = report.value?.sections?.customer_journey?.funnel_metrics ?? {};
const cr = report.value?.sections?.customer_journey?.conversion_rates ?? {};
return [
{ key: 'ad', label: fm.ad_traffic ?? 'Реклама', value: '—', color: '#86EFAC', rateToNext: '' },
{ key: 'site', label: fm.site_visitors ?? 'Сайт / Лендинг', value: '—', color: '#93C5FD', rateToNext: cr.lead_to_booking },
{ key: 'leads', label: 'Лид', value: String(fm.leads ?? '—'), color: PPT_BLUE, rateToNext: cr.booking_to_payment },
{ key: 'bookings', label: 'Запись', value: String(fm.bookings ?? '—'), color: PPT_ORANGE, rateToNext: cr.lead_to_payment_total },
{ key: 'payments', label: 'Оплата', value: String(fm.payments ?? '—'), color: '#FF6B6B' }
];
});
// KaTeX
const roiKatex = computed(() =>
katex.renderToString(String.raw`ROI = \frac{Profit}{Investment} \times 100\%`, {
throwOnError: false,
displayMode: false
})
);
const cacKatex = computed(() =>
katex.renderToString(String.raw`CAC = \frac{Marketing\ Spend}{New\ Customers}`, {
throwOnError: false,
displayMode: false
})
);
// Problems & recommendations messages
const problemMessages = computed(() => {
const details = report.value?.sections?.bottlenecks?.details ?? {};
const flat = [];
for (const [k, arr] of Object.entries(details)) {
if (Array.isArray(arr)) {
for (const text of arr) flat.push({ key: `${k}-${text}`, title: k, text: String(text) });
}
}
return flat.slice(0, 6);
});
const recommendationMessages = computed(() => {
const pillars = report.value?.sections?.recommendations?.core_pillars ?? [];
return pillars.map((p, idx) => ({
key: String(idx),
strategy: p.strategy ?? '—',
action: p.action ?? '—'
}));
});
const bottlenecksDetails = computed(() => report.value?.sections?.bottlenecks?.details ?? { landing: [], lead_management: [], retention: [] });
const competitionStrengthRows = computed(() => {
if (competitiveListRows.value.length > 0) {
return competitiveListRows.value.map((row) => {
const extras = [];
if (row.priceSegment) extras.push(`Сегмент: ${row.priceSegment}`);
if (row.website) extras.push(String(row.website));
return {
label: row.label,
strengths: [...(row.strengths ?? []), ...extras]
};
});
}
const strengths = report.value?.sections?.competitive_environment?.competitor_strengths ?? {};
return [
{ label: 'Ваш бизнес', strengths: strengths.your_business ?? [] },
{ label: 'Конкурент A', strengths: strengths.competitor_a ?? [] },
{ label: 'Конкурент B', strengths: strengths.competitor_b ?? [] },
{ label: 'Конкурент C', strengths: strengths.competitor_c ?? [] }
];
});
// Demand & behavior charts
function percentToNumber(p) {
const n = Number(String(p ?? '').replace(/[^\d.]/g, ''));
return Number.isFinite(n) ? n : 0;
}
const CHART_COLORS = [PPT_BLUE, '#7C3AED', PPT_ORANGE, '#34D399', '#60A5FA', '#A855F7', '#F59E0B', '#10B981'];
function getChartColors(count) {
const safeCount = Math.max(0, Number(count) || 0);
return Array.from({ length: safeCount }, (_, i) => CHART_COLORS[i % CHART_COLORS.length]);
}
function normalizeLabel(rawKey) {
const raw = String(rawKey ?? '').trim();
if (!raw) return '';
if (!raw.includes('_')) return raw;
return raw
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
function normalizeEntries(raw, preferredEntries = [], aliasOrder = [], aliasMap = {}) {
if (!raw || typeof raw !== 'object') return [];
const hasPreferred = preferredEntries.some(({ key }) => raw[key] !== undefined);
if (hasPreferred) {
return preferredEntries
.map(({ key, label }) => ({ label, value: raw[key] }))
.filter(({ value }) => value !== undefined && value !== null && value !== '');
}
const aliasEntries = aliasOrder
.filter((key) => raw[key] !== undefined)
.map((key) => ({ label: aliasMap[key] ?? key, value: raw[key] }));
if (aliasEntries.length > 0) return aliasEntries;
return Object.entries(raw).map(([key, value]) => ({ label: normalizeLabel(key), value }));
}
const QUERY_TYPE_KEYS = [
{ key: 'direct_interest_catalog', label: 'Прямой интерес' },
{ key: 'question_based_articles', label: 'Поиск по вопросу' },
{ key: 'competitors_reviews', label: 'Конкуренты' },
{ key: 'problem_solving_hot_promo', label: 'Решение проблемы' }
];
const QUERY_TYPE_ALIAS_ORDER = ['Коммерческие (купить, цена)', 'Информационные (отзывы)', 'Брендовые', 'Гео-запросы'];
const QUERY_TYPE_ALIAS_MAP = {
'Коммерческие (купить, цена)': 'Коммерческие (купить, цена)',
'Информационные (отзывы)': 'Информационные (отзывы)',
'Брендовые': 'Брендовые',
'Гео-запросы': 'Гео-запросы'
};
const DECISION_FACTOR_KEYS = [
{ key: 'promo_discount', label: 'Акция/скидка' },
{ key: 'review_recommendation', label: 'Отзыв/рекоменд.' },
{ key: 'warranty_quality', label: 'Гарантия/качество' },
{ key: 'convenience_service', label: 'Удобство/сервис' },
{ key: 'availability', label: 'Наличие мест' },
{ key: 'price', label: 'Цена' }
];
const DECISION_FACTOR_ALIAS_ORDER = ['Цена / Рассрочка', 'Отзывы', 'Локация', 'Сервис', 'Бренд'];
const DECISION_FACTOR_ALIAS_MAP = {
'Цена / Рассрочка': 'Цена / Рассрочка',
'Отзывы': 'Отзывы',
'Локация': 'Локация',
'Сервис': 'Сервис',
'Бренд': 'Бренд'
};
const REFUSAL_REASON_KEYS = [
{ key: 'high_price', label: 'Высокая цена' },
{ key: 'no_reviews_social_proof', label: 'Нет отзывов' },
{ key: 'choice_difficulty_unclear_offer', label: 'Сложности с выбором' }
];
const REFUSAL_REASON_ALIAS_ORDER = ['Скрытые условия', 'Плохие отзывы', 'Долгая реакция'];
const REFUSAL_REASON_ALIAS_MAP = {
'Скрытые условия': 'Скрытые условия',
'Плохие отзывы': 'Плохие отзывы',
'Долгая реакция': 'Долгая реакция'
};
const queryTypeEntries = computed(() => {
const q = report.value?.sections?.demand_and_behavior?.search_query_types ?? {};
return normalizeEntries(q, QUERY_TYPE_KEYS, QUERY_TYPE_ALIAS_ORDER, QUERY_TYPE_ALIAS_MAP);
});
const queryTypesDonutData = computed(() => {
const entries = queryTypeEntries.value;
const labels = entries.map((entry) => entry.label);
const values = entries.map((entry) => percentToNumber(entry.value));
const colors = getChartColors(labels.length);
return {
labels,
datasets: [
{
data: values,
backgroundColor: colors,
borderColor: '#ffffff',
borderWidth: 2
}
]
};
});
// Метрики для отображения под диаграммой
const queryTypesMetrics = computed(() => {
const entries = queryTypeEntries.value;
const colors = getChartColors(entries.length);
return entries
.map((entry, index) => ({
label: entry.label,
value: percentToNumber(entry.value),
color: colors[index]
}))
.filter((metric) => metric.value > 0);
});
const queryTypesTableRows = computed(() => {
return queryTypeEntries.value.map((entry) => ({
label: entry.label,
value: percentToNumber(entry.value)
}));
});
const decisionFactorEntries = computed(() => {
const f = report.value?.sections?.demand_and_behavior?.decision_factors ?? {};
return normalizeEntries(f, DECISION_FACTOR_KEYS, DECISION_FACTOR_ALIAS_ORDER, DECISION_FACTOR_ALIAS_MAP);
});
const decisionFactorsBarData = computed(() => {
const entries = decisionFactorEntries.value;
const labels = entries.map((entry) => entry.label);
const values = entries.map((entry) => percentToNumber(entry.value));
return {
labels,
datasets: [
{
data: values,
unit: '%',
borderRadius: 10,
backgroundColor: (context) => {
const { chart } = context;
const { ctx, chartArea } = chart;
if (!chartArea) return PPT_BLUE;
return makeVerticalGradient(ctx, chartArea, PPT_GRADIENT_BLUE_START, PPT_GRADIENT_BLUE_END);
}
}
]
};
});
const decisionFactorsTableRows = computed(() => {
return decisionFactorEntries.value.map((entry) => ({
label: entry.label,
value: percentToNumber(entry.value)
}));
});
const refusalReasonEntries = computed(() => {
const r = report.value?.sections?.demand_and_behavior?.reasons_for_refusal ?? {};
return normalizeEntries(r, REFUSAL_REASON_KEYS, REFUSAL_REASON_ALIAS_ORDER, REFUSAL_REASON_ALIAS_MAP);
});
const refusalReasonsBarData = computed(() => {
const entries = refusalReasonEntries.value;
const labels = entries.map((entry) => entry.label);
const values = entries.map((entry) => percentToNumber(entry.value));
return {
labels,
datasets: [
{
data: values,
unit: '%',
borderRadius: 10,
backgroundColor: (context) => {
const { chart } = context;
const { ctx, chartArea } = chart;
if (!chartArea) return PPT_ORANGE;
return makeVerticalGradient(ctx, chartArea, PPT_ORANGE, '#FFBB96');
}
}
]
};
});
const refusalReasonsTableRows = computed(() => {
return refusalReasonEntries.value.map((entry) => ({
label: entry.label,
value: percentToNumber(entry.value)
}));
});
// Acquisition channels charts/tables
const channelSharesBarData = computed(() => {
const entries = channelShareEntries.value;
const labels = entries.map((entry) => entry.label || getChannelLabel(entry.label));
const values = entries.map((entry) => percentToNumber(entry.value));
const colors = getChartColors(labels.length);
return {
labels,
datasets: [
{
data: values,
unit: '%',
borderRadius: 12,
borderSkipped: false,
backgroundColor: (context) => {
const { chart, dataIndex } = context;
const { ctx, chartArea } = chart;
const base = colors[dataIndex] ?? PPT_BLUE;
if (!chartArea) return hexToRgba(base, 0.55);
return makeVerticalGradient(ctx, chartArea, hexToRgba(base, 0.88), hexToRgba(base, 0.24));
}
}
]
};
});
const channelSharesTableRows = computed(() => {
return channelShareEntries.value.map((entry) => ({
label: entry.label || getChannelLabel(entry.label),
value: percentToNumber(entry.value)
}));
});
const FUNNEL_BOTTLENECK_LABELS = {
lead_to_booking: 'Лид → запись',
booking_to_payment: 'Запись → оплата',
lead_to_payment_total: 'Лид → оплата'
};
const funnelBottleneckRows = computed(() => {
const bottlenecks = report.value?.sections?.customer_journey?.funnel_bottlenecks ?? {};
if (!bottlenecks || typeof bottlenecks !== 'object') return [];
const orderedKeys = Object.keys(FUNNEL_BOTTLENECK_LABELS);
const rows = [];
orderedKeys.forEach((key) => {
if (bottlenecks[key]) {
rows.push({
key,
stage: FUNNEL_BOTTLENECK_LABELS[key],
reason: String(bottlenecks[key])
});
}
});
Object.entries(bottlenecks).forEach(([key, reason]) => {
if (orderedKeys.includes(key)) return;
if (!reason) return;
rows.push({
key,
stage: normalizeLabel(key),
reason: String(reason)
});
});
return rows;
});
const funnelBarData = computed(() => {
const fm = report.value?.sections?.customer_journey?.funnel_metrics ?? {};
const labels = ['Охват/Трафик', 'Визиты на сайт', 'Лиды', 'Записи/Бронь', 'Оплаты'];
const values = [fm.ad_traffic, fm.site_visitors, fm.leads, fm.bookings, fm.payments].map((v) => {
if (typeof v === 'string') return Number(v.replace(/[^\d.]/g, ''));
return Number(v) || 0;
});
return {
labels,
datasets: [
{
data: values,
borderRadius: 10,
backgroundColor: (context) => {
const { chart } = context;
const { ctx, chartArea } = chart;
if (!chartArea) return PPT_BLUE;
return makeVerticalGradient(ctx, chartArea, PPT_GRADIENT_BLUE_START, PPT_GRADIENT_BLUE_END);
}
}
]
};
});
const funnelBarOptions = computed(() => {
const base = compactBarOptions.value;
return {
...base,
indexAxis: 'y',
scales: {
x: { ...base.scales.x, grid: { color: 'rgba(148,163,184,0.15)', drawBorder: false } },
y: { ...base.scales.y, grid: { display: false, drawBorder: false } }
}
};
});
const channelSharesBarOptions = computed(() => {
const base = compactBarOptions.value;
return {
...base,
indexAxis: 'y',
scales: {
x: { ...base.scales.x, grid: { color: 'rgba(148,163,184,0.15)', drawBorder: false } },
y: { ...base.scales.y, grid: { display: false, drawBorder: false } }
}
};
});
// Функция для извлечения числового значения из стоимости (например, "2500 KZT" -> 2500)
function extractCostNumber(costStr) {
if (!costStr || costStr === '—') return 0;
const num = Number(String(costStr).replace(/[^\d.]/g, ''));
return Number.isFinite(num) ? num : 0;
}
const CHANNEL_LABELS = {
yandex: 'Яндекс',
yandex_direct: 'Яндекс Директ',
seo: 'SEO',
retargeting: 'Ретаргет',
vkontakte: 'VKontakte',
youtube: 'YouTube',
instagram: 'Instagram',
tiktok: 'TikTok',
kaspi: 'Kaspi',
google_search: 'Google Поиск',
'2gis': '2GIS',
google_ads: 'Google Ads',
facebook: 'Facebook'
};
function getChannelLabel(key) {
const raw = String(key ?? '');
if (CHANNEL_LABELS[raw]) return CHANNEL_LABELS[raw];
if (raw.includes(' ')) return raw;
return raw
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
const CHANNEL_SHARE_KEYS = [
{ key: 'yandex_direct', label: 'Яндекс Директ' },
{ key: 'seo', label: 'SEO (сиб.тайт)' },
{ key: 'retargeting', label: 'Ретаргетинг' },
{ key: 'vkontakte', label: 'VKontakte' }
];
const channelShareEntries = computed(() => {
const shares = report.value?.sections?.acquisition_channels?.channel_shares ?? {};
if (!shares || typeof shares !== 'object') return [];
const hasPreferred = CHANNEL_SHARE_KEYS.some(({ key }) => shares[key] !== undefined);
if (hasPreferred) {
const preferred = CHANNEL_SHARE_KEYS
.map(({ key, label }) => ({ label, value: shares[key] }))
.filter(({ value }) => value !== undefined && value !== null && value !== '');
const preferredKeys = new Set(CHANNEL_SHARE_KEYS.map(({ key }) => key));
const extras = Object.entries(shares)
.filter(([key]) => !preferredKeys.has(key))
.map(([key, value]) => ({ label: getChannelLabel(key), value }));
return [...preferred, ...extras];
}
return Object.entries(shares).map(([key, value]) => ({ label: getChannelLabel(key), value }));
});
// График эффективности каналов (стоимость привлечения)
const channelEfficiencyBarData = computed(() => {
const eff = report.value?.sections?.acquisition_channels?.channel_efficiency ?? {};
// Цвета для разных каналов
const channelColors = [
PPT_BLUE,
PPT_ORANGE,
PPT_GREEN,
'#60A5FA',
'#A855F7',
'#EC4899',
'#F59E0B',
'#10B981',
'#3B82F6',
'#8B5CF6'
];
// Получаем все каналы из данных
const items = Object.keys(eff)
.map((key, index) => {
const row = eff[key];
if (!row || !row.cost) return null;
return {
key: key,
label: getChannelLabel(key),
cost: row.cost,
costValue: extractCostNumber(row.cost),
color: channelColors[index % channelColors.length]
};
})
.filter(Boolean)
.sort((a, b) => a.costValue - b.costValue); // Сортируем по стоимости (от меньшей к большей)
return {
labels: items.map((i) => i.label),
datasets: [
{
data: items.map((i) => i.costValue),
borderRadius: 12,
borderSkipped: false,
backgroundColor: (context) => {
const { chart, dataIndex } = context;
const { ctx, chartArea } = chart;
const base = items[dataIndex]?.color ?? PPT_BLUE;
if (!chartArea) return hexToRgba(base, 0.55);
return makeVerticalGradient(ctx, chartArea, hexToRgba(base, 0.88), hexToRgba(base, 0.24));
}
}
]
};
});
const channelEfficiencyBarOptions = computed(() => {
const base = compactBarOptions.value;
return {
...base,
indexAxis: 'y',
scales: {
x: {
...base.scales.x,
grid: { color: 'rgba(148,163,184,0.15)', drawBorder: false },
ticks: {
callback: function(value) {
return value.toLocaleString('ru-RU') + ' KZT';
}
}
},
y: { ...base.scales.y, grid: { display: false, drawBorder: false } }
},
plugins: {
...base.plugins,
tooltip: {
...base.plugins?.tooltip,
callbacks: {
label: function(context) {
return context.parsed.x.toLocaleString('ru-RU') + ' KZT';
}
}
}
}
};
});
const channelEfficiencyRows = computed(() => {
const eff = report.value?.sections?.acquisition_channels?.channel_efficiency ?? {};
function statusClass(status) {
const s = String(status || '').toLowerCase();
// Обработка английских статусов
if (s.includes('very high') || s.includes('очень высок') || s.includes('высок')) return 'text-green-700 font-semibold';
if (s.includes('high') || s.includes('эффектив')) return 'text-green-700';
if (s.includes('medium') || s.includes('средн') || s.includes('удовлетвор')) return 'text-amber-700';
if (s.includes('low') || s.includes('низк') || s.includes('завыш')) return 'text-red-700';
if (s.includes('local') || s.includes('локальн')) return 'text-blue-700';
return 'text-slate-600';
}
// Получаем все каналы из данных и преобразуем в массив
return Object.keys(eff)
.map((key) => {
const row = eff[key];
if (!row) return null;
return {
key: key,
label: getChannelLabel(key),
cost: row.cost ?? '—',
status: row.status ?? '—',
statusClass: statusClass(row.status)
};
})
.filter(Boolean)
.sort((a, b) => {
// Сортируем по статусу: Very High -> High -> Medium -> Local -> остальные
const statusOrder = { 'very high': 1, 'high': 2, 'medium': 3, 'local': 4 };
const aStatus = String(a.status || '').toLowerCase();
const bStatus = String(b.status || '').toLowerCase();
const aOrder = statusOrder[aStatus] || 5;
const bOrder = statusOrder[bStatus] || 5;
return aOrder - bOrder;
});
});
// Economics tables
const economicsPerformanceRows = computed(() => {
const fp = report.value?.sections?.marketing_economics?.financial_performance ?? {};
return [
{ key: 'marketing', label: 'Расходы на маркетинг', month: fp.marketing_expenses?.month ?? '—', year: fp.marketing_expenses?.year ?? '—' },
{ key: 'clients', label: 'Клиенты', month: fp.clients?.month ?? '—', year: fp.clients?.year ?? '—' },
{ key: 'cac', label: 'Стоимость клиента', month: fp.cac?.month ?? '—', year: fp.cac?.year ?? '—' },
{ key: 'revenue', label: 'Выручка с клиента', month: fp.revenue_from_client?.month ?? '—', year: fp.revenue_from_client?.year ?? '—' },
{ key: 'profit', label: 'Доход с клиента', month: fp.profit_from_client?.month ?? '—', year: fp.profit_from_client?.year ?? '—' }
];
});
</script>
<style scoped>
.katex-box :deep(.katex) {
font-size: 1.05rem;
}
</style>