This commit is contained in:
root
2026-01-25 22:18:27 +05:00
parent fc28cca365
commit f8950a240b
+383 -115
View File
@@ -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();