.
This commit is contained in:
@@ -120,7 +120,7 @@
|
||||
<PptKpiCard
|
||||
title="Индекс онлайн-спроса"
|
||||
:value="marketDemandIndexValue"
|
||||
subtitle="высокий онлайн-потенциал"
|
||||
:subtitle="marketPotentialSubtitle"
|
||||
icon="pi-search"
|
||||
:accentColor="PPT_BLUE"
|
||||
description="Коэффициент востребованности в сети (отношение текущего спроса к базисному). Показывает, насколько активен спрос на ваш продукт/услугу в цифровых каналах."
|
||||
@@ -299,6 +299,30 @@
|
||||
</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>
|
||||
@@ -314,7 +338,7 @@
|
||||
<PptKpiCard
|
||||
title="Доля ключевого конкурента на рынке"
|
||||
:value="competitionMarketShareValue"
|
||||
subtitle=""
|
||||
:subtitle="competitionLeaderSubtitle"
|
||||
icon="pi-sitemap"
|
||||
:accentColor="PPT_BLUE"
|
||||
description="Оценочная доля онлайн-трафика, которую занимает текущий бизнес. Показывает вашу позицию на рынке относительно общего объема трафика в нише."
|
||||
@@ -322,22 +346,22 @@
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<PptKpiCard
|
||||
title="Место в видимости"
|
||||
:title="competitionRankTitle"
|
||||
:value="competitionRankValue"
|
||||
subtitle=""
|
||||
icon="pi-chart-bar"
|
||||
:accentColor="PPT_GREEN"
|
||||
description="Позиция бренда в поисковой выдаче и рекламных каналах относительно конкурентов. Чем ниже число, тем выше позиция."
|
||||
:description="competitionRankDescription"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<PptKpiCard
|
||||
title="Доля конкурентов в контекстной рекламе"
|
||||
:title="competitionAdPressureTitle"
|
||||
:value="competitionCompetitorShareValue"
|
||||
:subtitle="`Бенчмарк (Астана) ${competitionBenchmarkValue}`"
|
||||
:subtitle="competitionAdPressureSubtitle"
|
||||
icon="pi-wallet"
|
||||
:accentColor="PPT_ORANGE"
|
||||
description="Активность конкурентов в платной рекламе. Показывает уровень конкуренции в контекстной рекламе и необходимость инвестиций в этот канал."
|
||||
:description="competitionAdPressureDescription"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -543,6 +567,30 @@
|
||||
</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>
|
||||
@@ -585,6 +633,24 @@
|
||||
/>
|
||||
</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">
|
||||
<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">
|
||||
@@ -1177,15 +1243,39 @@ const marketDemandIndexValue = computed(() => report.value?.sections?.market_ove
|
||||
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 competitionMarketShareValue = computed(() => report.value?.sections?.competitive_environment?.metrics?.market_share_traffic ?? '—');
|
||||
const competitionRankValue = computed(() => report.value?.sections?.competitive_environment?.metrics?.visibility_rank ?? '—');
|
||||
const competitionCompetitorShareValue = computed(() => report.value?.sections?.competitive_environment?.metrics?.competitor_share_in_context_ads ?? '—');
|
||||
const competitionBenchmarkValue = computed(() => report.value?.sections?.competitive_environment?.metrics?.benchmark_astana ?? '—');
|
||||
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 ?? '—');
|
||||
@@ -1323,6 +1413,7 @@ const ageRows = computed(() => {
|
||||
'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 },
|
||||
@@ -1584,6 +1675,7 @@ const incomeRows = computed(() => {
|
||||
'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 },
|
||||
@@ -1650,8 +1742,14 @@ const segmentRows = computed(() => {
|
||||
const segs = report.value?.sections?.target_audience?.segments ?? [];
|
||||
return segs
|
||||
.map((s) => {
|
||||
const n = Number(String(s.share ?? '').replace(/[^\d.]/g, ''));
|
||||
return { name: s.name ?? '—', value: Number.isFinite(n) ? n : 0 };
|
||||
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);
|
||||
});
|
||||
@@ -1698,7 +1796,44 @@ function getGeographyGradient(percentage, index) {
|
||||
}
|
||||
|
||||
// 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 ?? {};
|
||||
|
||||
@@ -1731,6 +1866,7 @@ const visibilityBarData = computed(() => {
|
||||
payment: PPT_ORANGE,
|
||||
others: '#FF6B6B'
|
||||
};
|
||||
const fallbackColors = [PPT_BLUE, PPT_GREEN, PPT_ORANGE, '#60A5FA', '#A855F7', '#EC4899', '#F59E0B'];
|
||||
|
||||
return {
|
||||
labels,
|
||||
@@ -1743,7 +1879,7 @@ const visibilityBarData = computed(() => {
|
||||
backgroundColor: (context) => {
|
||||
const { chart, dataIndex } = context;
|
||||
const { ctx, chartArea } = chart;
|
||||
const base = baseByKey[keys[dataIndex]] ?? PPT_BLUE;
|
||||
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));
|
||||
}
|
||||
@@ -1814,14 +1950,25 @@ const recommendationMessages = computed(() => {
|
||||
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 ?? {};
|
||||
const rows = [
|
||||
return [
|
||||
{ label: 'Ваш бизнес', strengths: strengths.your_business ?? [] },
|
||||
{ label: 'Конкурент A', strengths: strengths.competitor_a ?? [] },
|
||||
{ label: 'Конкурент B', strengths: strengths.competitor_b ?? [] },
|
||||
{ label: 'Конкурент C', strengths: strengths.competitor_c ?? [] }
|
||||
];
|
||||
return rows;
|
||||
});
|
||||
|
||||
// Demand & behavior charts
|
||||
@@ -1830,16 +1977,100 @@ function percentToNumber(p) {
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
const queryTypesDonutData = computed(() => {
|
||||
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 ?? {};
|
||||
const labels = ['Прямой интерес', 'Поиск по вопросу', 'Конкуренты', 'Решение проблемы'];
|
||||
const values = [q.direct_interest_catalog, q.question_based_articles, q.competitors_reviews, q.problem_solving_hot_promo].map(percentToNumber);
|
||||
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: [PPT_BLUE, '#7C3AED', PPT_ORANGE, '#34D399'],
|
||||
backgroundColor: colors,
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 2
|
||||
}
|
||||
@@ -1849,34 +2080,34 @@ const queryTypesDonutData = computed(() => {
|
||||
|
||||
// Метрики для отображения под диаграммой
|
||||
const queryTypesMetrics = computed(() => {
|
||||
const q = report.value?.sections?.demand_and_behavior?.search_query_types ?? {};
|
||||
const labels = ['Прямой интерес', 'Поиск по вопросу', 'Конкуренты', 'Решение проблемы'];
|
||||
const values = [q.direct_interest_catalog, q.question_based_articles, q.competitors_reviews, q.problem_solving_hot_promo].map(percentToNumber);
|
||||
const colors = [PPT_BLUE, '#7C3AED', PPT_ORANGE, '#34D399'];
|
||||
const entries = queryTypeEntries.value;
|
||||
const colors = getChartColors(entries.length);
|
||||
|
||||
return labels
|
||||
.map((label, index) => ({
|
||||
label,
|
||||
value: values[index] || 0,
|
||||
return entries
|
||||
.map((entry, index) => ({
|
||||
label: entry.label,
|
||||
value: percentToNumber(entry.value),
|
||||
color: colors[index]
|
||||
}))
|
||||
.filter((metric) => metric.value > 0);
|
||||
});
|
||||
|
||||
const queryTypesTableRows = computed(() => {
|
||||
const q = report.value?.sections?.demand_and_behavior?.search_query_types ?? {};
|
||||
return [
|
||||
{ label: 'Прямой интерес', value: percentToNumber(q.direct_interest_catalog) },
|
||||
{ label: 'Поиск по вопросу', value: percentToNumber(q.question_based_articles) },
|
||||
{ label: 'Конкуренты', value: percentToNumber(q.competitors_reviews) },
|
||||
{ label: 'Решение проблемы', value: percentToNumber(q.problem_solving_hot_promo) }
|
||||
];
|
||||
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 f = report.value?.sections?.demand_and_behavior?.decision_factors ?? {};
|
||||
const labels = ['Акция/скидка', 'Отзыв/рекоменд.', 'Гарантия/качество', 'Удобство/сервис', 'Наличие мест', 'Цена'];
|
||||
const values = [f.promo_discount, f.review_recommendation, f.warranty_quality, f.convenience_service, f.availability, f.price].map(percentToNumber);
|
||||
const entries = decisionFactorEntries.value;
|
||||
const labels = entries.map((entry) => entry.label);
|
||||
const values = entries.map((entry) => percentToNumber(entry.value));
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
@@ -1896,21 +2127,21 @@ const decisionFactorsBarData = computed(() => {
|
||||
});
|
||||
|
||||
const decisionFactorsTableRows = computed(() => {
|
||||
const f = report.value?.sections?.demand_and_behavior?.decision_factors ?? {};
|
||||
return [
|
||||
{ label: 'Акция/скидка', value: percentToNumber(f.promo_discount) },
|
||||
{ label: 'Отзыв/рекоменд.', value: percentToNumber(f.review_recommendation) },
|
||||
{ label: 'Гарантия/качество', value: percentToNumber(f.warranty_quality) },
|
||||
{ label: 'Удобство/сервис', value: percentToNumber(f.convenience_service) },
|
||||
{ label: 'Наличие мест', value: percentToNumber(f.availability) },
|
||||
{ label: 'Цена', value: percentToNumber(f.price) }
|
||||
];
|
||||
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 r = report.value?.sections?.demand_and_behavior?.reasons_for_refusal ?? {};
|
||||
const labels = ['Высокая цена', 'Нет отзывов', 'Сложности с выбором'];
|
||||
const values = [r.high_price, r.no_reviews_social_proof, r.choice_difficulty_unclear_offer].map(percentToNumber);
|
||||
const entries = refusalReasonEntries.value;
|
||||
const labels = entries.map((entry) => entry.label);
|
||||
const values = entries.map((entry) => percentToNumber(entry.value));
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
@@ -1930,35 +2161,30 @@ const refusalReasonsBarData = computed(() => {
|
||||
});
|
||||
|
||||
const refusalReasonsTableRows = computed(() => {
|
||||
const r = report.value?.sections?.demand_and_behavior?.reasons_for_refusal ?? {};
|
||||
return [
|
||||
{ label: 'Высокая цена', value: percentToNumber(r.high_price) },
|
||||
{ label: 'Нет отзывов', value: percentToNumber(r.no_reviews_social_proof) },
|
||||
{ label: 'Сложности с выбором', value: percentToNumber(r.choice_difficulty_unclear_offer) }
|
||||
];
|
||||
return refusalReasonEntries.value.map((entry) => ({
|
||||
label: entry.label,
|
||||
value: percentToNumber(entry.value)
|
||||
}));
|
||||
});
|
||||
|
||||
// Acquisition channels charts/tables
|
||||
const channelSharesBarData = computed(() => {
|
||||
const shares = report.value?.sections?.acquisition_channels?.channel_shares ?? {};
|
||||
const items = [
|
||||
{ key: 'yandex_direct', label: 'Яндекс Директ', value: shares.yandex_direct, color: PPT_ORANGE },
|
||||
{ key: 'seo', label: 'SEO (сиб.тайт)', value: shares.seo, color: PPT_BLUE },
|
||||
{ key: 'retargeting', label: 'Ретаргетинг', value: shares.retargeting, color: PPT_GREEN },
|
||||
{ key: 'vkontakte', label: 'VKontakte', value: shares.vkontakte, color: '#60A5FA' }
|
||||
];
|
||||
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: items.map((i) => i.label),
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data: items.map((i) => percentToNumber(i.value)),
|
||||
data: values,
|
||||
unit: '%',
|
||||
borderRadius: 12,
|
||||
borderSkipped: false,
|
||||
backgroundColor: (context) => {
|
||||
const { chart, dataIndex } = context;
|
||||
const { ctx, chartArea } = chart;
|
||||
const base = items[dataIndex]?.color ?? PPT_BLUE;
|
||||
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));
|
||||
}
|
||||
@@ -1968,13 +2194,45 @@ const channelSharesBarData = computed(() => {
|
||||
});
|
||||
|
||||
const channelSharesTableRows = computed(() => {
|
||||
const shares = report.value?.sections?.acquisition_channels?.channel_shares ?? {};
|
||||
return [
|
||||
{ label: 'Яндекс Директ', value: percentToNumber(shares.yandex_direct) },
|
||||
{ label: 'SEO (сиб.тайт)', value: percentToNumber(shares.seo) },
|
||||
{ label: 'Ретаргетинг', value: percentToNumber(shares.retargeting) },
|
||||
{ label: 'VKontakte', value: percentToNumber(shares.vkontakte) }
|
||||
];
|
||||
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(() => {
|
||||
@@ -2033,30 +2291,61 @@ function extractCostNumber(costStr) {
|
||||
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 channelLabels = {
|
||||
'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) {
|
||||
return channelLabels[key] || key.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
||||
}
|
||||
|
||||
// Цвета для разных каналов
|
||||
const channelColors = [
|
||||
@@ -2140,27 +2429,6 @@ const channelEfficiencyBarOptions = computed(() => {
|
||||
|
||||
const channelEfficiencyRows = computed(() => {
|
||||
const eff = report.value?.sections?.acquisition_channels?.channel_efficiency ?? {};
|
||||
|
||||
// Маппинг ключей каналов на читаемые названия
|
||||
const channelLabels = {
|
||||
'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) {
|
||||
return channelLabels[key] || key.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
const s = String(status || '').toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user