2469 lines
131 KiB
Vue
2469 lines
131 KiB
Vue
<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">Цель на 6–12 месяцев</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: '18–24' },
|
||
{ key: '25-34', label: '25–34' },
|
||
{ key: '35-44', label: '35–44' },
|
||
{ key: '45-54', label: '45–54' },
|
||
{ 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: '18–24' },
|
||
{ key: '25-34', label: '25–34' },
|
||
{ key: '35-44', label: '35–44' },
|
||
{ key: '45-54', label: '45–54' },
|
||
{ 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>
|