This commit is contained in:
arys
2026-03-10 20:11:48 +05:00
parent e3ebaec3df
commit 01c938cf21
31 changed files with 3048 additions and 983 deletions
+39
View File
@@ -0,0 +1,39 @@
import { API_CONFIG, DEFAULT_REQUEST_CONFIG } from '@/config/api';
import AuthService from '@/service/AuthService';
const api = {
get: async (url) => {
const fullUrl = `${API_CONFIG.BASE_URL}/api/marketing/v3${url}`;
const response = await AuthService.authFetch(fullUrl);
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.message || 'Ошибка API');
return { data: { data: data?.data ?? data } };
},
post: async (url, body, config = {}) => {
const fullUrl = `${API_CONFIG.BASE_URL}/api/marketing/v3${url}`;
// If body is FormData, don't stringify and let browser set Content-Type correctly
const isFormData = body instanceof FormData;
const fetchOptions = {
method: 'POST',
...DEFAULT_REQUEST_CONFIG,
body: isFormData ? body : JSON.stringify(body)
};
// If it's not FormData, ensure we send application/json
if (!isFormData) {
fetchOptions.headers = {
...fetchOptions.headers,
'Content-Type': 'application/json'
};
}
const response = await AuthService.authFetch(fullUrl, fetchOptions);
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.message || 'Ошибка API');
// Original components expect { data: responseBody }, and our backend returns { data: payload }
return { data: { data: data?.data ?? data } };
}
};
export default api;
+512
View File
@@ -0,0 +1,512 @@
/* =========================================================
KAI v4.0 Dashboard — Dark Theme Globals
Loaded by MarketingAnalysisV3.vue via <style>
========================================================= */
@import url('https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;900&family=Onest:wght@400;500;600;700&display=swap');
:root {
--kai-bg: #0A0E1A;
--kai-bg2: #0D1428;
--kai-card: rgba(255,255,255,0.04);
--kai-card-hover: rgba(255,255,255,0.07);
--kai-border: rgba(45,123,255,0.15);
--kai-border-hover: rgba(45,123,255,0.4);
--kai-blue: #2D7BFF;
--kai-green: #00D48B;
--kai-orange: #FF7A2D;
--kai-purple: #A855F7;
--kai-yellow: #F59E0B;
--kai-red: #EF4444;
--kai-txt: rgba(255,255,255,0.92);
--kai-txt2: rgba(255,255,255,0.60);
--kai-txt3: rgba(255,255,255,0.35);
}
/* Layout */
.kai-page {
background: var(--kai-bg);
min-height: 100vh;
font-family: 'Onest', sans-serif;
color: var(--kai-txt);
}
/* Header */
.kai-header {
position: sticky;
top: 0;
z-index: 100;
height: 64px;
background: rgba(10,14,26,0.96);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(45,123,255,0.2);
display: flex;
align-items: center;
padding: 0 32px;
gap: 16px;
}
.kai-logo {
width: 40px;
height: 40px;
background: var(--kai-blue);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Unbounded', sans-serif;
font-weight: 900;
font-size: 13px;
color: #fff;
flex-shrink: 0;
}
.kai-title-area {
flex: 1;
display: flex;
flex-direction: column;
}
.kai-title-niche {
font-family: 'Unbounded', sans-serif;
font-weight: 700;
font-size: 13px;
color: var(--kai-txt);
line-height: 1;
}
.kai-title-sub {
font-size: 11px;
color: var(--kai-txt2);
margin-top: 2px;
}
.kai-badge-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
font-family: 'Unbounded', sans-serif;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.kai-badge-status.green {
background: rgba(0,212,139,0.12);
border: 1px solid rgba(0,212,139,0.3);
color: var(--kai-green);
}
.kai-badge-status.yellow {
background: rgba(245,158,11,0.12);
border: 1px solid rgba(245,158,11,0.3);
color: var(--kai-yellow);
animation: kai-pulse 1.2s infinite;
}
@keyframes kai-pulse {
0%,100% { opacity:1 } 50% { opacity:0.5 }
}
.kai-pdf-btn {
padding: 8px 16px;
border-radius: 8px;
border: 1px solid var(--kai-border);
background: transparent;
color: var(--kai-txt2);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all .2s;
font-family: 'Onest', sans-serif;
white-space: nowrap;
}
.kai-pdf-btn:hover {
background: var(--kai-card);
border-color: var(--kai-border-hover);
color: var(--kai-txt);
}
/* Section */
.kai-section {
margin-bottom: 32px;
background: var(--kai-card);
border: 1px solid var(--kai-border);
border-radius: 16px;
overflow: hidden;
transition: border-color .3s;
}
.kai-section:hover {
border-color: rgba(45,123,255,0.25);
}
.kai-section-header {
padding: 20px 28px 18px;
border-bottom: 1px solid var(--kai-border);
background: rgba(45,123,255,0.03);
}
.kai-section-title {
font-family: 'Unbounded', sans-serif;
font-weight: 700;
font-size: 17px;
color: var(--kai-txt);
line-height: 1.2;
margin: 0;
}
.kai-section-sub {
font-size: 12px;
color: var(--kai-txt2);
margin-top: 4px;
font-weight: 500;
}
.kai-section-badge {
font-family: 'Unbounded', sans-serif;
font-size: 11px;
font-weight: 900;
color: var(--kai-blue);
opacity: 0.5;
margin-top: 2px;
}
.kai-section-body {
padding: 24px 28px;
}
/* KPI Cards */
.kai-kpi-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
@media (max-width: 1024px) { .kai-kpi-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 640px) { .kai-kpi-grid { grid-template-columns: 1fr; } }
.kai-kpi {
background: rgba(255,255,255,0.03);
border: 1px solid var(--kai-border);
border-radius: 14px;
padding: 20px;
transition: all .25s;
position: relative;
overflow: hidden;
}
.kai-kpi::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at top right, rgba(45,123,255,0.07), transparent 70%);
pointer-events: none;
}
.kai-kpi:hover {
border-color: var(--kai-border-hover);
background: var(--kai-card-hover);
transform: translateY(-2px);
}
.kai-kpi-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
margin-bottom: 14px;
}
.kai-kpi-num {
font-family: 'Unbounded', sans-serif;
font-size: 36px;
font-weight: 900;
line-height: 1;
margin-bottom: 6px;
color: var(--kai-txt);
}
.kai-kpi-label {
font-size: 11px;
font-weight: 600;
color: var(--kai-txt2);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 8px;
}
.kai-kpi-sub {
font-size: 12px;
color: var(--kai-txt3);
line-height: 1.4;
}
/* Competition level badges */
.kai-level-low { color: var(--kai-green); background: rgba(0,212,139,0.1); border: 1px solid rgba(0,212,139,0.2); }
.kai-level-medium { color: var(--kai-yellow); background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.2); }
.kai-level-high { color: var(--kai-orange); background: rgba(255,122,45,0.1); border: 1px solid rgba(255,122,45,0.2); }
.kai-level-vhigh { color: var(--kai-red); background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); }
.kai-level-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.kai-er-green { color: var(--kai-green); }
.kai-er-yellow { color: var(--kai-yellow); }
.kai-er-red { color: var(--kai-red); }
/* Stars */
.kai-stars { color: var(--kai-yellow); font-size: 16px; letter-spacing: 2px; }
/* Heatmap bars */
.kai-progress-track { background: rgba(255,255,255,0.06); border-radius: 999px; height: 6px; overflow: hidden; }
.kai-progress-fill { height: 100%; border-radius: 999px; transition: width 1s ease-out; background: var(--kai-blue); }
/* Info badge strip */
.kai-insight-strip {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 20px;
}
.kai-insight-tag {
padding: 5px 14px;
border-radius: 999px;
background: rgba(45,123,255,0.08);
border: 1px solid rgba(45,123,255,0.2);
font-size: 12px;
font-weight: 600;
color: var(--kai-txt2);
}
/* Gauge SVG */
.kai-gauge-wrap { display: flex; flex-direction: column; align-items: center; gap: 8px; }
/* Table */
.kai-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.kai-table th {
padding: 10px 14px;
text-align: left;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--kai-txt3);
border-bottom: 1px solid var(--kai-border);
cursor: pointer;
white-space: nowrap;
user-select: none;
}
.kai-table th:hover { color: var(--kai-txt2); }
.kai-table td {
padding: 12px 14px;
border-bottom: 1px solid rgba(255,255,255,0.04);
color: var(--kai-txt);
vertical-align: middle;
}
.kai-table tr:last-child td { border-bottom: none; }
.kai-table tr:hover td { background: rgba(45,123,255,0.04); }
.kai-table .sorted-arrow { margin-left: 4px; }
.kai-plat-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* Competitor expander */
.kai-expand-btn { background: none; border: none; cursor: pointer; color: var(--kai-txt2); padding: 4px; border-radius: 6px; transition: all .2s; display: flex; align-items: center; }
.kai-expand-btn:hover { background: rgba(45,123,255,0.1); color: var(--kai-blue); }
.kai-expand-row { background: rgba(10,14,26,0.5); border-top: 1px solid var(--kai-border); }
.kai-expand-inner { padding: 16px 20px; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 640px) { .kai-expand-inner { grid-template-columns: 1fr; } }
.kai-sw-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 10px; }
.kai-sw-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
.kai-sw-list li { display: flex; align-items: flex-start; gap: 8px; font-size: 12px; color: var(--kai-txt2); line-height: 1.4; }
.kai-sw-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; margin-top: 4px; }
.kai-sw-dot.green { background: var(--kai-green); }
.kai-sw-dot.red { background: var(--kai-red); }
/* CII Ring */
.kai-cii-ring-wrap { display: flex; flex-direction: column; align-items: center; text-align: center; }
.kai-cii-num { font-family: 'Unbounded', sans-serif; font-size: 56px; font-weight: 900; line-height: 1; }
.kai-cii-label { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; margin-top: 4px; }
.kai-cii-desc { font-size: 13px; color: var(--kai-txt2); margin-top: 10px; max-width: 260px; line-height: 1.5; }
/* Radar chart */
.kai-radar-labels { display: flex; flex-wrap: wrap; justify-content: center; gap: 12px; margin-top: 12px; }
.kai-radar-label { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--kai-txt2); font-weight: 600; }
.kai-radar-dot { width: 8px; height: 8px; border-radius: 50%; }
/* Gap table */
.kai-gap-table thead th { background: rgba(45,123,255,0.06); }
.kai-prio-badge {
padding: 3px 10px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
display: inline-block;
}
.kai-prio-crit { background: rgba(239,68,68,0.15); color: var(--kai-red); }
.kai-prio-imp { background: rgba(255,122,45,0.15); color: var(--kai-orange); }
.kai-prio-need { background: rgba(245,158,11,0.15); color: var(--kai-yellow); }
.kai-prio-ahead{ background: rgba(0,212,139,0.15); color: var(--kai-green); }
/* Conclusions */
.kai-conc-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }
@media (max-width: 1024px) { .kai-conc-grid { grid-template-columns: 1fr; } }
.kai-conc-card {
background: rgba(255,255,255,0.03);
border: 1px solid var(--kai-border);
border-radius: 14px;
padding: 18px 20px;
cursor: default;
transition: all .3s;
}
.kai-conc-card:hover { border-color: var(--kai-border-hover); transform: translateY(-4px); box-shadow: 0 0 24px rgba(45,123,255,0.12); }
.kai-conc-icon { font-size: 20px; margin-bottom: 10px; }
.kai-conc-text { font-size: 13px; color: var(--kai-txt); line-height: 1.6; }
/* SMM Rationale */
.kai-rationale {
background: linear-gradient(135deg, #0D1F4A 0%, var(--kai-bg) 100%);
border-left: 4px solid var(--kai-blue);
border-radius: 14px;
padding: 32px;
}
.kai-rationale-text { font-size: 15px; color: rgba(255,255,255,0.87); line-height: 1.8; white-space: pre-line; }
.kai-cta-box {
margin-top: 28px;
border: 1px solid rgba(45,123,255,0.3);
background: rgba(45,123,255,0.06);
border-radius: 14px;
padding: 28px;
}
.kai-cta-btn {
margin-top: 20px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 14px 32px;
background: var(--kai-blue);
color: #fff;
border: none;
border-radius: 10px;
font-family: 'Unbounded', sans-serif;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: all .25s;
}
.kai-cta-btn:hover { background: #1a6bff; transform: scale(1.02); box-shadow: 0 0 24px rgba(45,123,255,0.4); }
/* Sidebar nav */
.kai-sidenav {
position: fixed;
right: 20px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 6px;
z-index: 90;
}
@media (max-width: 1280px) { .kai-sidenav { display: none; } }
.kai-sidenav-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255,255,255,0.15);
cursor: pointer;
transition: all .2s;
border: none;
padding: 0;
}
.kai-sidenav-dot:hover,
.kai-sidenav-dot.active { background: var(--kai-blue); box-shadow: 0 0 8px rgba(45,123,255,0.6); width: 10px; height: 10px; }
/* Area chart gradient */
.kai-area-chart-wrap { background: rgba(255,255,255,0.02); border-radius: 12px; padding: 16px; }
/* Loading skeleton */
.kai-skeleton {
background: rgba(255,255,255,0.05);
border-radius: 12px;
position: relative;
overflow: hidden;
}
.kai-skeleton::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.04), transparent);
animation: kai-shimmer 1.5s infinite;
}
@keyframes kai-shimmer { 0% { transform: translateX(-100%) } 100% { transform: translateX(100%) } }
/* Factor card */
.kai-factor {
background: rgba(255,255,255,0.03);
border: 1px solid var(--kai-border);
border-radius: 12px;
padding: 16px;
}
.kai-factor-label { font-size: 11px; font-weight: 700; color: var(--kai-txt); margin-bottom: 4px; }
.kai-factor-val { font-size: 12px; color: var(--kai-txt2); margin-bottom: 10px; }
/* Strategic focus cards */
.kai-focus-card {
display: flex;
align-items: flex-start;
gap: 14px;
background: rgba(45,123,255,0.06);
border: 1px solid rgba(45,123,255,0.2);
border-radius: 12px;
padding: 16px;
}
.kai-focus-num {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--kai-blue);
color: #fff;
font-family: 'Unbounded', sans-serif;
font-weight: 900;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.kai-focus-text { font-size: 13px; font-weight: 600; color: var(--kai-txt); line-height: 1.5; padding-top: 6px; }
/* Search demand badges */
.kai-query-card { padding: 12px 16px; background: rgba(255,255,255,0.03); border: 1px solid var(--kai-border); border-radius: 10px; }
.kai-query-label { font-size: 13px; color: var(--kai-txt); font-weight: 500; margin-bottom: 8px; }
/* CTA tags (behavioral) */
.kai-cta-bubble {
padding: 12px 18px;
background: rgba(45,123,255,0.07);
border: 1px solid rgba(45,123,255,0.2);
border-radius: 12px;
font-size: 13px;
font-weight: 600;
color: var(--kai-txt);
transition: all .2s;
cursor: default;
}
.kai-cta-bubble:hover { background: rgba(45,123,255,0.12); border-color: rgba(45,123,255,0.4); }
/* Peak period card */
.kai-peak-card {
padding: 14px 16px;
background: rgba(45,123,255,0.07);
border: 1px solid rgba(45,123,255,0.2);
border-radius: 12px;
}
.kai-peak-title { font-weight: 700; color: var(--kai-blue); font-size: 13px; margin-bottom: 4px; }
.kai-peak-sub { font-size: 11px; color: var(--kai-txt2); }
.kai-term-icon { color: rgba(255,255,255,0.4); font-size: 13px; margin-left: 4px; cursor: help; transition: color 0.2s; }
.kai-term-icon:hover { color: #2D7BFF; }
@@ -194,6 +194,15 @@ const analysisTypeOptions = [
{ value: 'КАНАЛЫ', label: 'Каналы продвижения' }
];
const KAZAKHSTAN_CITIES = [
'Алматы', 'Астана', 'Шымкент', 'Актобе', 'Караганда',
'Тараз', 'Павлодар', 'Усть-Каменогорск', 'Семей', 'Атырау',
'Кызылорда', 'Костанай', 'Актау', 'Уральск', 'Петропавловск',
'Туркестан', 'Кокшетау', 'Талдыкорган', 'Жанаозен', 'Рудный',
'Экибастуз', 'Жезказган', 'Каскелен', 'Кульсары', 'Риддер',
'Весь Казахстан', 'СНГ', 'Зарубежье', 'Онлайн (без привязки)'
].sort();
// Валидация конкретного шага (запускается перед переходом)
const validateStep = (stepIndex) => {
let isValid = true;
@@ -352,9 +361,11 @@ watch(
() => formData.value.geoScope,
(newVal) => {
if (newVal === 'FULL_COUNTRY') {
formData.value.promotionCities = ['Алматы', 'Астана', 'Шымкент', 'Караганда', 'Актобе'];
formData.value.promotionCities = ['Весь Казахстан'];
} else if (newVal === 'ONLINE') {
formData.value.promotionCities = ['Remote'];
formData.value.promotionCities = ['Онлайн (без привязки)'];
} else {
formData.value.promotionCities = [];
}
if (newVal !== 'SINGLE_CITY') {
@@ -468,30 +479,38 @@ export default {
<div v-if="formData.geoScope === 'SINGLE_CITY'" class="field md:col-span-2">
<label for="mainCity" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Основной город * </label>
<InputText id="mainCity" v-model="formData.mainCity" placeholder="Например: Алматы, Астана, Шымкент, Караганда" class="w-full" :class="{ 'p-invalid': errors.mainCity }" />
<Dropdown id="mainCity" v-model="formData.mainCity" :options="KAZAKHSTAN_CITIES" filter placeholder="Выберите город из списка" class="w-full" :class="{ 'p-invalid': errors.mainCity }" />
<small v-if="errors.mainCity" class="p-error">{{ errors.mainCity }}</small>
</div>
<div v-if="formData.geoScope === 'MULTI_CITY'" class="field md:col-span-2">
<TagArrayField
<label for="presenceCities" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Города присутствия * </label>
<MultiSelect
id="presenceCities"
v-model="formData.presenceCities"
label="Города присутствия *"
placeholder="Например: Алматы, Астана, Актобе"
:max="5"
:error="errors.presenceCities"
@update:modelValue="formData.presenceCities = $event"
:options="KAZAKHSTAN_CITIES"
filter
placeholder="Выберите города из списка..."
display="chip"
class="w-full"
:class="{ 'p-invalid': errors.presenceCities }"
:maxSelectedLabels="10"
/>
<small v-if="errors.presenceCities" class="p-error">{{ errors.presenceCities }}</small>
</div>
<div class="field md:col-span-2">
<TagArrayField
<label for="promotionCities" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Города, где хотите усиливать продвижение * </label>
<MultiSelect
id="promotionCities"
v-model="formData.promotionCities"
label="Города, где хотите усиливать продвижение *"
placeholder="Например: Алматы, Астана, Шымкент"
:max="5"
:error="errors.promotionCities"
@update:modelValue="formData.promotionCities = $event"
:options="KAZAKHSTAN_CITIES"
filter
placeholder="Выберите города из списка..."
display="chip"
class="w-full"
:class="{ 'p-invalid': errors.promotionCities }"
:maxSelectedLabels="10"
/>
<small v-if="errors.promotionCities" class="p-error">{{ errors.promotionCities }}</small>
</div>
@@ -0,0 +1,117 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
data: {
type: Object,
default: () => ({})
}
});
const fmtPct = (n) => (n != null ? parseFloat(n).toFixed(1) + '%' : '—');
const behaviors = computed(() => {
return [
{
label: 'Промо и скидки',
value: props.data.promoUsagePercent || 0,
icon: 'pi-tag',
color: 'bg-rose-500',
textColor: 'text-rose-600 dark:text-rose-400',
bgLight: 'bg-rose-100 dark:bg-rose-500/20'
},
{
label: 'Прямое бронирование',
value: props.data.directBookingPercent || 0,
icon: 'pi-calendar-plus',
color: 'bg-blue-500',
textColor: 'text-blue-600 dark:text-blue-400',
bgLight: 'bg-blue-100 dark:bg-blue-500/20'
},
{
label: 'Открытые цены',
value: props.data.priceVisibilityPercent || 0,
icon: 'pi-money-bill',
color: 'bg-emerald-500',
textColor: 'text-emerald-600 dark:text-emerald-400',
bgLight: 'bg-emerald-100 dark:bg-emerald-500/20'
}
];
});
</script>
<template>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-surface-0 dark:bg-surface-800 rounded-2xl p-6 border border-surface-100 dark:border-surface-700 shadow-sm">
<h3 class="text-xs font-bold text-surface-500 dark:text-surface-400 uppercase tracking-widest mb-6">Поведенческие триггеры рынка</h3>
<div class="space-y-6">
<div v-for="(item, idx) in behaviors" :key="idx" class="group">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-md flex items-center justify-center shrink-0" :class="item.bgLight">
<i :class="['pi', item.icon, item.textColor]" class="text-[10px]"></i>
</div>
<span class="text-sm font-semibold text-surface-700 dark:text-surface-300">{{ item.label }}</span>
</div>
<span class="text-sm font-black" :class="item.textColor">{{ fmtPct(item.value) }}</span>
</div>
<div class="w-full bg-surface-100 dark:bg-surface-700 rounded-full h-2.5 overflow-hidden">
<div class="h-full rounded-full transition-all duration-1000 ease-out relative overflow-hidden"
:class="item.color" :style="{ width: item.value + '%' }">
<div class="absolute inset-0 bg-white/30 w-full h-full -translate-x-full group-hover:animate-[shimmer_1.5s_infinite]"></div>
</div>
</div>
</div>
</div>
<div class="mt-8 flex items-center justify-between p-4 rounded-xl bg-surface-50 dark:bg-surface-900 border border-surface-100 dark:border-surface-800">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center text-surface-600 dark:text-surface-400">
<i class="pi pi-hourglass"></i>
</div>
<div>
<p class="text-xs font-bold text-surface-500 dark:text-surface-400 uppercase tracking-wider">Цикл сделки</p>
<p class="text-sm font-medium text-surface-900 dark:text-surface-100">Среднее время от касания до покупки</p>
</div>
</div>
<div class="text-right">
<span class="text-2xl font-black text-surface-900 dark:text-surface-0">{{ data.avgCycleDays || '—' }}</span>
<span class="text-sm text-surface-500 ml-1">дн.</span>
</div>
</div>
</div>
<div class="bg-surface-0 dark:bg-surface-800 rounded-2xl p-6 border border-surface-100 dark:border-surface-700 shadow-sm relative overflow-hidden">
<div class="absolute -right-16 -top-16 w-48 h-48 bg-primary-500/5 rounded-full blur-[60px] pointer-events-none"></div>
<h3 class="text-xs font-bold text-surface-500 dark:text-surface-400 uppercase tracking-widest mb-6">Популярные CTA (Призывы к действию)</h3>
<div class="flex flex-wrap gap-3">
<div v-for="(cta, idx) in data.commonCta || []" :key="idx"
class="px-4 py-2.5 rounded-xl bg-surface-50 dark:bg-surface-900 hover:bg-primary-50 dark:hover:bg-primary-900/40 border border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-700 transition-colors shadow-sm flex items-center gap-2 group cursor-default">
<i class="pi pi-arrow-right text-[10px] text-surface-400 group-hover:text-primary-500 transition-colors"></i>
<span class="text-sm font-semibold text-surface-700 dark:text-surface-300 group-hover:text-primary-700 dark:group-hover:text-primary-400 transition-colors">{{ cta }}</span>
</div>
<div v-if="!data.commonCta?.length" class="text-sm italic text-surface-500 py-4 w-full text-center">
Нет данных по CTA
</div>
</div>
<div class="mt-8 p-4 rounded-xl border border-primary-100 dark:border-primary-900/30 bg-primary-50 dark:bg-primary-900/10 flex items-start gap-4">
<i class="pi pi-lightbulb text-primary-500 text-xl mt-0.5"></i>
<p class="text-xs text-primary-800 dark:text-primary-200 leading-relaxed font-medium">
Используйте наиболее частые призывы конкурентов как базу, но интегрируйте уникальные предложения, чтобы выделиться на их фоне.
</p>
</div>
</div>
</div>
</template>
<style scoped>
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
</style>
@@ -0,0 +1,97 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
data: {
type: Object,
default: () => ({})
},
metricInfo: {
type: Object,
default: () => ({})
}
});
const factors = computed(() => {
return props.data?.contributingFactors || [];
});
const indexColorClass = computed(() => {
const val = props.data?.ciiIndex || 0;
if (val >= 75) return 'text-rose-500';
if (val >= 50) return 'text-amber-500';
return 'text-emerald-500';
});
const indexBgClass = computed(() => {
const val = props.data?.ciiIndex || 0;
if (val >= 75) return 'bg-rose-500 dark:bg-rose-500';
if (val >= 50) return 'bg-amber-500 dark:bg-amber-500';
return 'bg-emerald-500 dark:bg-emerald-500';
});
const gradientClass = computed(() => {
const val = props.data?.ciiIndex || 0;
if (val >= 75) return 'from-rose-500/20 to-rose-500/5 dark:from-rose-500/10 dark:to-transparent';
if (val >= 50) return 'from-amber-500/20 to-amber-500/5 dark:from-amber-500/10 dark:to-transparent';
return 'from-emerald-500/20 to-emerald-500/5 dark:from-emerald-500/10 dark:to-transparent';
});
</script>
<template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- CII Score Visual -->
<div class="rounded-2xl border border-surface-100 dark:border-surface-700 bg-surface-0 dark:bg-surface-800 shadow-sm relative overflow-hidden flex flex-col items-center justify-center p-8">
<div :class="['absolute inset-0 bg-gradient-to-br pointer-events-none', gradientClass]"></div>
<h3 class="text-xs font-bold text-surface-500 dark:text-surface-400 uppercase tracking-widest mb-8 flex items-center gap-2 relative z-10 w-full justify-center">
Комплексный индекс
<i class="pi pi-info-circle text-primary-400 cursor-help" v-tooltip="metricInfo.cii" />
</h3>
<div class="relative flex items-center justify-center z-10 w-48 h-48 mb-6">
<!-- Circular progress background -->
<svg class="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" fill="none" class="stroke-surface-100 dark:stroke-surface-700" stroke-width="8" />
<circle cx="50" cy="50" r="45" fill="none" :class="indexColorClass" stroke-width="8" stroke-linecap="round"
:stroke-dasharray="`${(data.ciiIndex / 100) * 283} 283`" class="transition-all duration-1000 ease-out" />
</svg>
<div class="absolute flex flex-col items-center justify-center">
<span class="text-5xl font-black" :class="indexColorClass">{{ data.ciiIndex || 0 }}</span>
<span class="text-[10px] font-bold text-surface-400 uppercase tracking-widest mt-1">/ 100</span>
</div>
<!-- Glowing effect -->
<div class="absolute inset-0 rounded-full blur-2xl opacity-20" :class="indexBgClass"></div>
</div>
<div class="relative z-10 bg-surface-0 dark:bg-surface-900 border border-surface-200 dark:border-surface-700 px-6 py-2.5 rounded-full shadow-lg">
<span class="text-sm font-bold text-surface-900 dark:text-surface-100" :class="indexColorClass">{{ data.intensityLabel || 'Неизвестная интенсивность' }}</span>
</div>
</div>
<!-- Contributing Factors -->
<div class="rounded-2xl border border-surface-100 dark:border-surface-700 bg-surface-0 dark:bg-surface-800 shadow-sm p-6 md:p-8">
<h3 class="text-xs font-bold text-surface-500 dark:text-surface-400 uppercase tracking-widest mb-6">Ценообразующие и ключевые факторы</h3>
<div class="space-y-4">
<div v-for="(factor, idx) in factors" :key="idx"
class="group flex items-start gap-4 p-4 rounded-xl bg-surface-50 dark:bg-surface-900/50 border border-surface-100 dark:border-surface-800 hover:border-primary-300 dark:hover:border-primary-700 transition-colors">
<div class="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900/40 text-primary-600 dark:text-primary-400 flex items-center justify-center shrink-0 group-hover:bg-primary-500 group-hover:text-white transition-colors duration-300 shadow-sm">
<i :class="['pi', idx === 0 ? 'pi-users' : idx === 1 ? 'pi-calendar' : 'pi-star']" class="text-lg"></i>
</div>
<div class="flex flex-col justify-center min-h-[40px]">
<span class="text-sm font-semibold text-surface-900 dark:text-surface-100 leading-tight">{{ factor }}</span>
</div>
</div>
<div v-if="!factors.length" class="text-center py-8 text-surface-500 dark:text-surface-400 italic">
Нет данных по факторам
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,148 @@
<script setup>
import { ref } from 'vue';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import ScatterPlotSvg from '@/components/marketing/analysis/ScatterPlotSvg.vue';
const props = defineProps({
competitors: {
type: Array,
default: () => []
}
});
const expandedRows = ref([]);
const onRowExpand = (e) => {
expandedRows.value = [e.data];
};
const onRowCollapse = () => {
expandedRows.value = [];
};
const fmtNum = (n) => (n != null ? Number(n).toLocaleString('ru-RU') : '—');
const fmtPct = (n) => (n != null ? parseFloat(n).toFixed(2) + '%' : '—');
const erClass = (er) => {
const v = parseFloat(er);
if (v >= 6) return 'text-emerald-600 dark:text-emerald-400 font-bold';
if (v >= 3) return 'text-amber-600 dark:text-amber-400 font-bold';
return 'text-rose-600 dark:text-rose-400 font-bold';
};
</script>
<template>
<div class="space-y-6">
<div class="bg-surface-0 dark:bg-surface-800 rounded-2xl border border-surface-100 dark:border-surface-700 shadow-sm overflow-hidden">
<DataTable
:value="competitors"
dataKey="name"
:paginator="competitors.length > 10"
:rows="10"
responsiveLayout="scroll"
class="p-datatable-sm p-datatable-borderless text-sm"
:expandedRows="expandedRows"
@rowExpand="onRowExpand"
@rowCollapse="onRowCollapse"
:pt="{
headerRow: 'bg-surface-50 dark:bg-surface-900/50'
}"
>
<Column expander style="width: 3rem" />
<Column field="name" header="Бренд / Аккаунт" sortable>
<template #body="{ data }">
<div class="flex items-center gap-3 py-1">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-primary-100 to-primary-200 dark:from-primary-900/40 dark:to-primary-800/40 flex items-center justify-center text-primary-700 dark:text-primary-300 font-bold text-xs ring-2 ring-white dark:ring-surface-800 shadow-sm">
{{ data.name.charAt(0).toUpperCase() }}
</div>
<span class="font-bold text-surface-900 dark:text-surface-0">{{ data.name }}</span>
</div>
</template>
</Column>
<Column field="platform" header="Платформа" sortable>
<template #body="{ data }">
<span class="px-2.5 py-1 rounded-md text-[11px] font-bold uppercase tracking-wider bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-300">
{{ data.platform }}
</span>
</template>
</Column>
<Column field="followers" header="Подписчики" sortable>
<template #body="{ data }">
<span class="font-medium">{{ fmtNum(data.followers) }}</span>
</template>
</Column>
<Column field="postsPerMonth" header="Постов/мес" sortable>
<template #body="{ data }">
<span class="px-2 py-0.5 rounded bg-surface-100 dark:bg-surface-700 font-medium">{{ data.postsPerMonth }}</span>
</template>
</Column>
<Column field="er" header="ER" sortable>
<template #body="{ data }">
<span :class="erClass(data.er)" class="bg-surface-50 dark:bg-surface-900/50 px-2 py-1 rounded border border-surface-100 dark:border-surface-800 inline-block shadow-sm">{{ fmtPct(data.er) }}</span>
</template>
</Column>
<Column field="rating" header="★ Рейтинг" sortable>
<template #body="{ data }">
<span class="flex items-center gap-1.5 font-bold text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 px-2 py-1 rounded border border-amber-100 dark:border-amber-500/20 inline-block">
<i class="pi pi-star-fill text-amber-500 text-[10px]" />
{{ data.rating?.toFixed(1) || '—' }}
</span>
</template>
</Column>
<Column field="reviews" header="Отзывов" sortable>
<template #body="{ data }">
<span class="text-surface-500 dark:text-surface-400">{{ fmtNum(data.reviews) }}</span>
</template>
</Column>
<template #expansion="slotProps">
<div class="p-6 bg-gradient-to-br from-surface-50 to-surface-100/50 dark:from-surface-900/50 dark:to-surface-800/30 border-t border-surface-200 dark:border-surface-700">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Strengths -->
<div class="bg-surface-0 dark:bg-surface-800 rounded-xl p-5 border border-emerald-100 dark:border-emerald-900/30 shadow-sm relative overflow-hidden">
<div class="absolute right-0 top-0 w-24 h-24 bg-emerald-500/5 rounded-full blur-2xl -mr-8 -mt-8"></div>
<h4 class="font-bold text-emerald-700 dark:text-emerald-400 mb-4 flex items-center gap-2 uppercase tracking-wider text-xs">
<i class="pi pi-check-circle"></i> Сильные стороны
</h4>
<ul class="space-y-3">
<li v-for="(s, i) in slotProps.data.strengths || []" :key="i" class="flex items-start gap-3">
<div class="mt-0.5 w-5 h-5 rounded-full bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 flex items-center justify-center shrink-0">
<i class="pi pi-plus text-[10px]"></i>
</div>
<span class="text-sm text-surface-700 dark:text-surface-300 leading-tight">{{ s }}</span>
</li>
<li v-if="!slotProps.data.strengths?.length" class="text-sm italic text-surface-400">Нет данных</li>
</ul>
</div>
<!-- Weaknesses -->
<div class="bg-surface-0 dark:bg-surface-800 rounded-xl p-5 border border-rose-100 dark:border-rose-900/30 shadow-sm relative overflow-hidden">
<div class="absolute right-0 top-0 w-24 h-24 bg-rose-500/5 rounded-full blur-2xl -mr-8 -mt-8"></div>
<h4 class="font-bold text-rose-700 dark:text-rose-400 mb-4 flex items-center gap-2 uppercase tracking-wider text-xs">
<i class="pi pi-times-circle"></i> Слабые стороны
</h4>
<ul class="space-y-3">
<li v-for="(w, i) in slotProps.data.weaknesses || []" :key="i" class="flex items-start gap-3">
<div class="mt-0.5 w-5 h-5 rounded-full bg-rose-100 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400 flex items-center justify-center shrink-0">
<i class="pi pi-minus text-[10px]"></i>
</div>
<span class="text-sm text-surface-700 dark:text-surface-300 leading-tight">{{ w }}</span>
</li>
<li v-if="!slotProps.data.weaknesses?.length" class="text-sm italic text-surface-400">Нет данных</li>
</ul>
</div>
</div>
</div>
</template>
</DataTable>
</div>
<!-- Scatter Plot -->
<div class="bg-surface-0 dark:bg-surface-800 rounded-2xl p-6 border border-surface-100 dark:border-surface-700 shadow-sm relative overflow-hidden">
<div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-primary-500/5 rounded-full blur-[100px] pointer-events-none"></div>
<h3 class="text-xs font-bold text-surface-500 dark:text-surface-400 mb-6 uppercase tracking-widest text-center">Scatter Plot: Активность vs Вовлечённость</h3>
<div class="relative z-10 max-w-4xl mx-auto">
<ScatterPlotSvg :competitors="competitors" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,76 @@
<script setup>
const props = defineProps({
conclusions: {
type: Array,
default: () => []
},
rationale: {
type: String,
default: ''
}
});
</script>
<template>
<div class="space-y-8">
<!-- AI Conclusions -->
<div>
<h3 class="text-xs font-bold text-surface-500 dark:text-surface-400 uppercase tracking-widest flex items-center gap-2 mb-6">
<i class="pi pi-sparkles text-violet-500"></i>
Структурированные выводы ИИ
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div v-for="(item, idx) in conclusions" :key="idx"
class="group relative bg-surface-0 dark:bg-surface-800 p-5 rounded-2xl border border-surface-200 dark:border-surface-700 shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all duration-300 overflow-hidden">
<div class="absolute -right-6 -top-6 w-24 h-24 rounded-full blur-[40px] pointer-events-none transition-colors"
:class="idx % 3 === 0 ? 'bg-primary-500/10 group-hover:bg-primary-500/20' : idx % 3 === 1 ? 'bg-emerald-500/10 group-hover:bg-emerald-500/20' : 'bg-violet-500/10 group-hover:bg-violet-500/20'">
</div>
<div class="flex items-start gap-3 relative z-10">
<i class="pi pi-check-circle mt-0.5 text-lg"
:class="idx % 3 === 0 ? 'text-primary-500' : idx % 3 === 1 ? 'text-emerald-500' : 'text-violet-500'">
</i>
<p class="text-sm font-medium text-surface-700 dark:text-surface-300 leading-relaxed">{{ item }}</p>
</div>
</div>
</div>
</div>
<!-- Strategy Rationale -->
<div v-if="rationale" class="relative rounded-3xl overflow-hidden shadow-xl border border-primary-200/50 dark:border-primary-800/50">
<!-- Animated background gradient -->
<div class="absolute inset-0 bg-gradient-to-br from-primary-900 via-indigo-900 to-violet-900 animate-[gradient_8s_ease_infinite] bg-[length:200%_200%]"></div>
<!-- Glassmorphism overlay -->
<div class="absolute inset-0 bg-black/20 backdrop-blur-[2px]"></div>
<!-- Pattern overlay -->
<div class="absolute inset-0 opacity-10 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMiIgY3k9IjIiIHI9IjEiIGZpbGw9IiNmZmYiLz48L3N2Zz4=')]"></div>
<div class="relative z-10 p-8 md:p-12 text-white">
<div class="flex items-center gap-3 mb-6">
<div class="w-12 h-12 rounded-2xl bg-white/10 backdrop-blur-md flex items-center justify-center border border-white/20 shadow-inner">
<i class="pi pi-compass text-2xl text-primary-200"></i>
</div>
<div>
<h2 class="text-2xl font-black tracking-tight text-white m-0">Резюме и обоснование стратегии</h2>
<p class="text-primary-200 text-sm mt-1 uppercase tracking-widest font-bold">Финальный вердикт</p>
</div>
</div>
<div class="prose prose-invert prose-p:text-primary-50/90 max-w-none text-lg leading-relaxed font-medium">
<p class="whitespace-pre-line">{{ rationale }}</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@keyframes gradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
</style>
@@ -0,0 +1,101 @@
<script setup>
import { computed } from 'vue';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import ChartPlaceholder from '@/components/marketing/analysis/ChartPlaceholder.vue';
import Chart from 'primevue/chart';
const props = defineProps({
data: {
type: Object,
default: () => ({})
},
palette: {
type: Array,
default: () => ['#4F46E5', '#7C3AED', '#EC4899', '#F97316', '#10B981', '#06B6D4', '#EAB308']
},
chartOptions: {
type: Object,
default: () => ({})
}
});
const contentDistribution = computed(() => {
const d = props.data;
if (d?.demoContentPercent == null && !d?.distribution) return null;
return {
Демо: d.demoContentPercent ?? d.distribution?.['Демо/Процесс'] ?? 0,
Экспертный: d.expertContentPercent ?? d.distribution?.['Экспертный'] ?? 0,
Продающий: d.salesContentPercent ?? d.distribution?.['Промо/Акции'] ?? 0,
Отзывы: d.reviewsContentPercent ?? d.distribution?.['Отзывы'] ?? 0,
Вовлекающий: d.engagementContentPercent ?? d.distribution?.['Вовлечение'] ?? 0
};
});
const contentChartData = computed(() => {
const d = contentDistribution.value || {};
const entries = Object.entries(d).filter(([, v]) => v > 0);
return {
labels: entries.map(([k]) => k),
datasets: [{ data: entries.map(([, v]) => v), backgroundColor: props.palette }]
};
});
const fmtPct = (n) => (n != null ? parseFloat(n).toFixed(2) + '%' : '—');
const fmtNum = (n) => (n != null ? Number(n).toLocaleString('ru-RU') : '—');
</script>
<template>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-surface-0 dark:bg-surface-800 rounded-2xl p-6 border border-surface-100 dark:border-surface-700 shadow-sm relative overflow-hidden flex flex-col items-center">
<div class="absolute -left-20 top-1/2 -translate-y-1/2 w-64 h-64 bg-pink-500/5 rounded-full blur-[80px] pointer-events-none"></div>
<div class="flex items-center gap-2 mb-6 w-full">
<h3 class="text-xs font-bold text-surface-500 dark:text-surface-400 uppercase tracking-widest">Типы контента</h3>
<i class="pi pi-info-circle text-xs text-primary-400 cursor-help ml-auto" v-tooltip="'На основе анализа последних 100 постов конкурентов ИИ определил тематику контента.'" />
</div>
<div class="h-64 w-full flex items-center justify-center relative z-10">
<Chart v-if="contentChartData?.labels?.length" type="pie" :data="contentChartData" :options="chartOptions.pie" :plugins="[ChartDataLabels]" class="max-h-64" />
<ChartPlaceholder v-else text="Нет данных по контенту" />
</div>
</div>
<div class="space-y-6">
<h3 class="text-xs font-bold text-surface-500 dark:text-surface-400 uppercase tracking-widest">Ключевые показатели</h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="rounded-2xl border border-surface-100 dark:border-surface-700 bg-surface-0 dark:bg-surface-800 p-5 text-center shadow-sm relative overflow-hidden group hover:border-primary-300 dark:hover:border-primary-700 transition-colors">
<div class="absolute inset-0 bg-gradient-to-br from-primary-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
<p class="text-[11px] font-bold text-surface-500 dark:text-surface-400 uppercase tracking-wide mb-2">Доля видео</p>
<p class="text-3xl font-black text-primary-600 dark:text-primary-400">{{ fmtPct(data.videoShare) }}</p>
</div>
<div class="rounded-2xl border border-surface-100 dark:border-surface-700 bg-surface-0 dark:bg-surface-800 p-5 text-center shadow-sm relative overflow-hidden group hover:border-violet-300 dark:hover:border-violet-700 transition-colors">
<div class="absolute inset-0 bg-gradient-to-br from-violet-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
<p class="text-[11px] font-bold text-surface-500 dark:text-surface-400 uppercase tracking-wide mb-2">Ср. длина текста</p>
<p class="text-3xl font-black text-violet-600 dark:text-violet-400">{{ fmtNum(data.avgTextLengthCharacters) }}</p>
<p class="text-[10px] text-surface-400 uppercase font-bold mt-1">символов</p>
</div>
<div class="rounded-2xl border border-surface-100 dark:border-surface-700 bg-surface-0 dark:bg-surface-800 p-5 text-center shadow-sm relative overflow-hidden group hover:border-emerald-300 dark:hover:border-emerald-700 transition-colors">
<div class="absolute inset-0 bg-gradient-to-br from-emerald-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
<p class="text-[11px] font-bold text-surface-500 dark:text-surface-400 uppercase tracking-wide mb-2">Наличие CTA</p>
<p class="text-3xl font-black text-emerald-600 dark:text-emerald-400">{{ fmtPct(data.ctaUsagePercent) }}</p>
</div>
</div>
<!-- Content type bars -->
<div v-if="contentDistribution" class="bg-surface-0 dark:bg-surface-800 rounded-2xl border border-surface-100 dark:border-surface-700 shadow-sm p-6 mt-4">
<h4 class="text-xs font-bold text-surface-500 dark:text-surface-400 uppercase tracking-widest mb-4">Детализация распределения</h4>
<div class="space-y-4">
<div v-for="(val, key, i) in contentDistribution" :key="key" class="flex items-center gap-4 group">
<span class="text-sm font-semibold w-28 text-surface-700 dark:text-surface-300 capitalize">{{ key }}</span>
<div class="flex-1 bg-surface-100 dark:bg-surface-700 rounded-full h-2 overflow-hidden">
<div class="h-full rounded-full transition-all duration-1000 ease-out"
:style="{ width: (val || 0) + '%', backgroundColor: palette[i % palette.length] }">
</div>
</div>
<span class="text-sm font-bold text-surface-900 dark:text-surface-100 w-12 text-right">{{ fmtPct(val) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,50 @@
<script setup>
import { computed } from 'vue';
import ExecCard from '@/components/marketing/analysis/ExecCard.vue';
const props = defineProps({
data: {
type: Object,
default: () => ({})
},
metricInfo: {
type: Object,
default: () => ({})
}
});
const fmtPct = (n) => (n != null ? parseFloat(n).toFixed(2) + '%' : '—');
</script>
<template>
<div class="space-y-6">
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
<ExecCard label="Стадия бизнеса" :value="data.businessStage || '—'" icon="pi pi-briefcase" color="indigo" />
<ExecCard label="География" :value="data.geography || '—'" icon="pi pi-map-marker" color="blue" />
<ExecCard label="Конкурентов" :value="String(data.activeCompetitors ?? '—')" icon="pi pi-users" color="violet" :trend="data.activeCompetitors > 10 ? 'up' : 'neutral'" :tooltip="metricInfo.activePlayers" />
<ExecCard label="Уровень конкурен." :value="data.competitionLevel || '—'" icon="pi pi-bolt" color="amber" />
<ExecCard label="Средний ER" :value="fmtPct(data.averageNicheEr)" icon="pi pi-percentage" color="emerald" :trend="parseFloat(data.averageNicheEr) >= 5 ? 'up' : 'down'" :tooltip="metricInfo.er" />
<ExecCard label="Рейтинг ★" :value="data.averageRating ? data.averageRating.toFixed(1) : '—'" icon="pi pi-star-fill" color="orange" :trend="data.averageRating >= 4 ? 'up' : 'down'" :tooltip="metricInfo.rating" />
</div>
<!-- Key figures (Premium Glassmorphism Style) -->
<div v-if="data.keyFigures?.length" class="mt-6">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 mb-4 tracking-wider uppercase flex items-center gap-2">
<i class="pi pi-sparkles text-primary-500"></i>
Главные инсайты
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="(item, idx) in data.keyFigures" :key="idx"
class="group relative overflow-hidden rounded-xl bg-surface-0 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 p-4 transition-all hover:shadow-lg hover:border-primary-300 dark:hover:border-primary-700">
<div class="absolute top-0 right-0 w-32 h-32 bg-primary-500/5 rounded-full blur-3xl -mr-16 -mt-16 transition-all group-hover:bg-primary-500/10"></div>
<div class="flex items-start gap-3 relative z-10">
<div class="w-8 h-8 rounded-lg bg-primary-50 dark:bg-primary-500/10 flex items-center justify-center shrink-0">
<i class="pi pi-check-circle text-primary-600 dark:text-primary-400"></i>
</div>
<p class="text-sm text-surface-700 dark:text-surface-300 leading-relaxed">{{ item }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,113 @@
<script setup>
import { computed, ref } from 'vue';
import GaugeChart from '@/components/marketing/analysis/GaugeChart.vue';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
const props = defineProps({
data: {
type: Object,
default: () => ({})
},
metricInfo: {
type: Object,
default: () => ({})
}
});
const geoSort = ref({ field: 'activePlayers', order: -1 });
const geoTable = computed(() => {
return props.data?.cityComparison || [];
});
const onGeoSort = (e) => {
geoSort.value = { field: e.sortField, order: e.sortOrder };
};
const fmtPct = (n) => (n != null ? parseFloat(n).toFixed(2) + '%' : '—');
const erClass = (er) => {
const v = parseFloat(er);
if (v >= 6) return 'text-emerald-600 dark:text-emerald-400 font-bold';
if (v >= 3) return 'text-amber-600 dark:text-amber-400 font-bold';
return 'text-rose-600 dark:text-rose-400 font-bold';
};
</script>
<template>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div class="xl:col-span-2 bg-surface-0 dark:bg-surface-800 rounded-2xl border border-surface-100 dark:border-surface-700 shadow-sm overflow-hidden">
<div class="p-5 border-b border-surface-100 dark:border-surface-700">
<h3 class="text-xs font-bold text-surface-500 dark:text-surface-400 uppercase tracking-widest">Сравнение городов</h3>
</div>
<DataTable :value="geoTable" :sortField="geoSort.field" :sortOrder="geoSort.order" @sort="onGeoSort"
class="p-datatable-sm p-datatable-borderless text-sm" responsiveLayout="scroll"
:pt="{
headerRow: 'bg-surface-50 dark:bg-surface-900/50',
table: 'min-w-full'
}">
<Column field="city" header="Город" sortable :pt="{ bodyCell: 'font-medium text-surface-900 dark:text-surface-0' }" />
<Column field="activePlayers" header="Игроков" sortable />
<Column field="avgEr" sortable>
<template #header>
<div class="flex items-center gap-1.5 group">
<span class="text-surface-500 dark:text-surface-400">Ср. ER</span>
<i class="pi pi-info-circle text-[10px] text-surface-300 dark:text-surface-600 transition-colors group-hover:text-primary-500 cursor-help" v-tooltip="metricInfo.er" />
</div>
</template>
<template #body="{ data }">
<div class="py-1.5 px-3 rounded-lg bg-surface-50 dark:bg-surface-900/50 border border-surface-100 dark:border-surface-800 inline-block shadow-sm">
<span :class="erClass(data.avgEr)">{{ fmtPct(data.avgEr) }}</span>
</div>
</template>
</Column>
<Column field="avgRating" sortable>
<template #header>
<div class="flex items-center gap-1.5 group">
<span class="text-surface-500 dark:text-surface-400">Рейтинг</span>
<i class="pi pi-info-circle text-[10px] text-surface-300 dark:text-surface-600 transition-colors group-hover:text-primary-500 cursor-help" v-tooltip="metricInfo.rating" />
</div>
</template>
<template #body="{ data }">
<div class="py-1.5 px-3 rounded-lg bg-amber-50 dark:bg-amber-500/10 border border-amber-100/50 dark:border-amber-500/20 inline-block">
<span class="flex items-center gap-1.5 font-bold text-amber-700 dark:text-amber-400">
<i class="pi pi-star-fill text-amber-500 text-[10px]" />
{{ data.avgRating?.toFixed(1) || '—' }}
</span>
</div>
</template>
</Column>
<Column field="avgPostsPerMonth" sortable align="center">
<template #header>
<div class="flex items-center gap-1.5 group justify-center w-full">
<span class="text-surface-500 dark:text-surface-400">Постов/мес</span>
<i class="pi pi-info-circle text-[10px] text-surface-300 dark:text-surface-600 transition-colors group-hover:text-primary-500 cursor-help" v-tooltip="metricInfo.postsPerMonth" />
</div>
</template>
<template #body="{ data }">
<div class="py-1.5 px-3 rounded-lg bg-blue-50 dark:bg-blue-500/10 border border-blue-100/50 dark:border-blue-500/20 inline-block w-full text-center max-w-[80px]">
<span class="font-bold text-blue-700 dark:text-blue-400">{{ data.avgPostsPerMonth }}</span>
</div>
</template>
</Column>
</DataTable>
</div>
<div class="bg-surface-0 dark:bg-surface-800 rounded-2xl border border-surface-100 dark:border-surface-700 shadow-sm p-6 flex flex-col items-center justify-center relative overflow-hidden">
<div class="absolute inset-x-0 -top-20 h-40 bg-gradient-to-b from-primary-500/5 to-transparent pointer-events-none"></div>
<div class="flex items-center gap-2 mb-8 w-full">
<h3 class="text-xs font-bold text-surface-500 dark:text-surface-400 uppercase tracking-widest">Индекс плотности</h3>
<i class="pi pi-info-circle text-xs text-primary-400 cursor-help ml-auto" v-tooltip="metricInfo.densityIndex" />
</div>
<div class="relative w-full flex justify-center scale-110 mb-4">
<GaugeChart :value="data.densityIndex ?? 0" />
</div>
<div class="mt-4 p-4 rounded-xl bg-surface-50 dark:bg-surface-900/50 border border-surface-100 dark:border-surface-800 w-full text-center">
<p class="text-[11px] text-surface-500 dark:text-surface-400 leading-relaxed font-medium">Вероятность столкнуться с жесткой конкуренцией за клиента в изучаемом гео</p>
</div>
</div>
</div>
</template>
@@ -0,0 +1,136 @@
<script setup>
import { computed } from 'vue';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import ChartPlaceholder from '@/components/marketing/analysis/ChartPlaceholder.vue';
import Chart from 'primevue/chart';
const props = defineProps({
landscape: {
type: Object,
default: () => ({})
},
demand: {
type: Object,
default: () => ({})
},
palette: {
type: Array,
default: () => ['#4F46E5', '#7C3AED', '#EC4899', '#F97316', '#10B981', '#06B6D4', '#EAB308']
},
chartOptions: {
type: Object,
default: () => ({})
}
});
const heatColor = (pct) => {
if (pct >= 75) return '#4F46E5';
if (pct >= 50) return '#7C3AED';
if (pct >= 25) return '#A78BFA';
return '#C4B5FD';
};
const platformChartData = computed(() => {
const pl = props.landscape?.activePlayersByPlatform || {};
const entries = Object.entries(pl);
return {
labels: entries.map(([k]) => k),
datasets: [{ data: entries.map(([, v]) => v), backgroundColor: entries.map((_, i) => props.palette[i % props.palette.length]) }]
};
});
const demandChartData = computed(() => {
const s = props.demand?.seasonality || props.landscape?.demandDynamics || props.landscape?.demandTrend || [];
return {
labels: s.map((p) => p.period ?? p.month),
datasets: [{ label: 'Спрос', data: s.map((p) => p.value), borderColor: '#4F46E5', backgroundColor: 'rgba(79,70,229,0.08)', tension: 0.4, fill: true }]
};
});
const cityHeatmap = computed(() => {
const map = props.landscape?.cityDistribution || {};
const entries = Object.entries(map);
if (!entries.length) return [];
const max = Math.max(...entries.map(([, v]) => v), 1);
return entries
.map(([city, count]) => ({
city,
count,
pct: Math.round((count / max) * 100)
}))
.sort((a, b) => b.count - a.count)
.slice(0, 8);
});
</script>
<template>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Donut: платформы -->
<div class="flex flex-col bg-surface-0 dark:bg-surface-800 rounded-2xl p-5 border border-surface-100 dark:border-surface-700 shadow-sm relative overflow-hidden">
<div class="absolute -right-10 -bottom-10 w-40 h-40 bg-violet-500/5 rounded-full blur-3xl pointer-events-none"></div>
<h3 class="text-xs font-bold text-surface-500 dark:text-surface-400 mb-4 uppercase tracking-widest">Распределение по платформам</h3>
<div class="flex-1 flex items-center justify-center min-h-[240px]">
<Chart v-if="platformChartData?.labels?.length" type="doughnut" :data="platformChartData" :options="chartOptions.doughnut" :plugins="[ChartDataLabels]" class="w-full max-w-xs" />
<ChartPlaceholder v-else text="Нет данных по платформам" />
</div>
</div>
<!-- Line: спрос 12 мес -->
<div class="flex flex-col bg-surface-0 dark:bg-surface-800 rounded-2xl p-5 border border-surface-100 dark:border-surface-700 shadow-sm relative overflow-hidden">
<div class="absolute -left-10 -top-10 w-40 h-40 bg-indigo-500/5 rounded-full blur-3xl pointer-events-none"></div>
<h3 class="text-xs font-bold text-surface-500 dark:text-surface-400 mb-4 uppercase tracking-widest">Тренд спроса (12 месяцев)</h3>
<div class="flex-1 min-h-[240px]">
<Chart v-if="demandChartData?.labels?.length" type="line" :data="demandChartData" :options="chartOptions.line" class="w-full h-full" />
<ChartPlaceholder v-else text="Нет данных по сезонности" />
</div>
</div>
<!-- Heatmap города -->
<div class="bg-surface-0 dark:bg-surface-800 rounded-2xl p-5 border border-surface-100 dark:border-surface-700 shadow-sm">
<h3 class="text-xs font-bold text-surface-500 dark:text-surface-400 mb-5 uppercase tracking-widest flex items-center gap-2">
<i class="pi pi-map-marker text-primary-500"></i>
Распределение по городам
</h3>
<div class="space-y-4">
<div v-for="(item, idx) in cityHeatmap" :key="idx" class="group flex items-center gap-4">
<span class="w-32 text-sm font-medium text-surface-700 dark:text-surface-300 truncate">{{ item.city }}</span>
<div class="flex-1 bg-surface-100 dark:bg-surface-700 rounded-full h-2.5 overflow-hidden">
<div class="h-full rounded-full transition-all duration-1000 ease-out relative overflow-hidden" :style="{ width: (item.pct || 0) + '%', background: heatColor(item.pct || 0) }">
<div class="absolute inset-0 bg-white/20 w-full h-full -translate-x-full group-hover:animate-[shimmer_1.5s_infinite]"></div>
</div>
</div>
<span class="text-sm font-bold text-surface-900 dark:text-surface-100 w-10 text-right">{{ item.count || 0 }}</span>
</div>
<ChartPlaceholder v-if="!cityHeatmap?.length" text="Нет данных по городам" class="py-8" />
</div>
</div>
<!-- Metric: niche reputation -->
<div v-if="landscape?.nicheReputationLevel" class="flex flex-col justify-center bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-900/20 dark:to-teal-900/20 rounded-2xl p-6 border border-emerald-100 dark:border-emerald-800/30 relative overflow-hidden">
<div class="absolute right-0 top-0 w-32 h-32 bg-emerald-400/10 rounded-full blur-3xl -mr-10 -mt-10 pointer-events-none"></div>
<h3 class="text-xs font-bold text-emerald-800 dark:text-emerald-400 mb-6 uppercase tracking-widest">Уровень репутации ниши</h3>
<div class="flex items-center justify-between z-10">
<div>
<div class="flex items-baseline gap-2">
<p class="text-5xl font-black text-emerald-600 dark:text-emerald-400">{{ landscape.nicheReputationLevel }}</p>
<span class="text-2xl text-emerald-500"></span>
</div>
<p class="text-sm font-medium text-emerald-700/70 dark:text-emerald-300/70 mt-2">в среднем по рынку</p>
</div>
<div class="w-16 h-16 rounded-2xl bg-white/50 dark:bg-surface-900/50 backdrop-blur-sm flex items-center justify-center shadow-sm border border-white/20 dark:border-surface-700/50">
<i class="pi pi-shield text-3xl text-emerald-500" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
</style>
@@ -0,0 +1,100 @@
<script setup>
import { computed } from 'vue';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import ChartPlaceholder from '@/components/marketing/analysis/ChartPlaceholder.vue';
import Chart from 'primevue/chart';
const props = defineProps({
data: {
type: Object,
default: () => ({})
},
chartOptions: {
type: Object,
default: () => ({})
}
});
const starChartData = computed(() => {
const d = props.data?.starDistribution || {};
return {
labels: ['1★', '2★', '3★', '4★', '5★'],
datasets: [
{
label: 'Отзывов (%)',
data: [d['1'] ?? 0, d['2'] ?? 0, d['3'] ?? 0, d['4'] ?? 0, d['5'] ?? 0],
backgroundColor: ['#EF4444', '#F97316', '#EAB308', '#84CC16', '#22C55E']
}
]
};
});
const barOptions = computed(() => {
return {
...props.chartOptions.bar,
indexAxis: 'y',
plugins: {
legend: { display: false },
datalabels: {
color: '#4B5563', // dark-surface-300 in dark mode usually
anchor: 'end',
align: 'right',
formatter: (val) => val + '%',
font: { weight: 'bold', size: 11 }
}
},
scales: {
x: { display: false, max: 100 },
y: { grid: { display: false }, border: { display: false }, ticks: { font: { weight: 'bold' } } }
}
};
});
const fmtPct = (n) => (n != null ? parseFloat(n * 100).toFixed(0) + '%' : '—');
const fmtNum = (n) => (n != null ? Number(n).toLocaleString('ru-RU') : '—');
</script>
<template>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="space-y-6">
<h3 class="text-xs font-bold text-surface-500 dark:text-surface-400 uppercase tracking-widest">Метрики доверия</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="rounded-2xl border border-surface-100 dark:border-surface-700 bg-surface-0 dark:bg-surface-800 p-5 shadow-sm flex flex-col items-center justify-center text-center relative overflow-hidden group hover:border-amber-300 dark:hover:border-amber-700 transition-colors">
<div class="absolute inset-x-0 -top-10 h-20 bg-gradient-to-b from-amber-500/10 to-transparent pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity"></div>
<i class="pi pi-star-fill text-2xl text-amber-500 mb-2"></i>
<p class="text-3xl font-black text-surface-900 dark:text-surface-100 mb-1">{{ data.avgNicheRating?.toFixed(1) || '—' }}</p>
<p class="text-[11px] font-bold text-surface-500 dark:text-surface-400 uppercase tracking-wide">Средний рейтинг ниши</p>
</div>
<div class="rounded-2xl border border-surface-100 dark:border-surface-700 bg-surface-0 dark:bg-surface-800 p-5 shadow-sm flex flex-col items-center justify-center text-center relative overflow-hidden group hover:border-blue-300 dark:hover:border-blue-700 transition-colors">
<div class="absolute inset-x-0 -top-10 h-20 bg-gradient-to-b from-blue-500/10 to-transparent pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity"></div>
<i class="pi pi-comments text-2xl text-blue-500 mb-2"></i>
<p class="text-3xl font-black text-surface-900 dark:text-surface-100 mb-1">{{ fmtNum(data.medianReviews) }}</p>
<p class="text-[11px] font-bold text-surface-500 dark:text-surface-400 uppercase tracking-wide">Медиана отзывов</p>
</div>
<div class="rounded-2xl border border-surface-100 dark:border-surface-700 bg-surface-0 dark:bg-surface-800 p-5 shadow-sm flex flex-col items-center justify-center text-center relative overflow-hidden group hover:border-emerald-300 dark:hover:border-emerald-700 transition-colors">
<div class="absolute inset-x-0 -top-10 h-20 bg-gradient-to-b from-emerald-500/10 to-transparent pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity"></div>
<i class="pi pi-shield text-2xl text-emerald-500 mb-2"></i>
<p class="text-3xl font-black text-surface-900 dark:text-surface-100 mb-1">{{ fmtPct(data.highTrustBusinessShare) }}</p>
<p class="text-[11px] font-bold text-surface-500 dark:text-surface-400 uppercase tracking-wide">Бизнесов с высоким доверием</p>
</div>
<div class="rounded-2xl border border-surface-100 dark:border-surface-700 bg-surface-0 dark:bg-surface-800 p-5 shadow-sm flex flex-col items-center justify-center text-center relative overflow-hidden group hover:border-violet-300 dark:hover:border-violet-700 transition-colors">
<div class="absolute inset-x-0 -top-10 h-20 bg-gradient-to-b from-violet-500/10 to-transparent pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity"></div>
<i class="pi pi-clock text-2xl text-violet-500 mb-2"></i>
<p class="text-3xl font-black text-surface-900 dark:text-surface-100 mb-1">{{ data.avgOwnerResponseTimeHours || '—' }} <span class="text-sm">ч.</span></p>
<p class="text-[11px] font-bold text-surface-500 dark:text-surface-400 uppercase tracking-wide">Ср. время ответа владельца</p>
</div>
</div>
</div>
<div class="bg-surface-0 dark:bg-surface-800 rounded-2xl p-6 border border-surface-100 dark:border-surface-700 shadow-sm">
<h3 class="text-xs font-bold text-surface-500 dark:text-surface-400 uppercase tracking-widest mb-6">Распределение оценок</h3>
<div class="h-64 px-2">
<Chart v-if="starChartData.datasets[0].data.some(v => v > 0)" type="bar" :data="starChartData" :options="barOptions" :plugins="[ChartDataLabels]" class="h-full w-full" />
<ChartPlaceholder v-else text="Нет данных по оценкам" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,138 @@
<script setup>
import { computed } from 'vue';
import ChartPlaceholder from '@/components/marketing/analysis/ChartPlaceholder.vue';
import Chart from 'primevue/chart';
const props = defineProps({
data: {
type: Object,
default: () => ({})
},
chartOptions: {
type: Object,
default: () => ({})
}
});
const seasonChartData = computed(() => {
const s = props.data?.seasonality || [];
const peaks = props.data?.peakPeriods || [];
return {
labels: s.map((p) => p.period ?? p.month),
datasets: [
{
label: 'Индекс спроса',
data: s.map((p) => p.value),
borderColor: '#4F46E5', // indigo-600
backgroundColor: (context) => {
const ctx = context.chart.ctx;
const gradient = ctx.createLinearGradient(0, 0, 0, 300);
gradient.addColorStop(0, 'rgba(79, 70, 229, 0.4)');
gradient.addColorStop(1, 'rgba(79, 70, 229, 0.0)');
return gradient;
},
borderWidth: 3,
tension: 0.4,
fill: true,
pointRadius: s.map((p) => (peaks.includes(p.period ?? p.month) ? 8 : 4)),
pointHoverRadius: s.map((p) => (peaks.includes(p.period ?? p.month) ? 10 : 6)),
pointBackgroundColor: s.map((p) => (peaks.includes(p.period ?? p.month) ? '#EC4899' : '#ffffff')),
pointBorderColor: s.map((p) => (peaks.includes(p.period ?? p.month) ? '#ffffff' : '#4F46E5')),
pointBorderWidth: 2
}
]
};
});
const areaOptions = computed(() => {
return {
...props.chartOptions.area,
plugins: {
legend: { display: false },
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += context.parsed.y;
}
const period = context.chart.data.labels[context.dataIndex];
if (props.data?.peakPeriods?.includes(period)) {
label += ' (ПИК)';
}
return label;
}
}
}
},
scales: {
y: {
beginAtZero: true,
grid: { borderDash: [5, 5], color: 'rgba(107,114,128,0.1)' },
ticks: { color: '#9CA3AF' },
border: { display: false }
},
x: {
grid: { display: false },
ticks: { color: '#9CA3AF', maxRotation: 45 },
border: { display: false }
}
}
};
});
const fmtNum = (n) => (n != null ? Number(n).toLocaleString('ru-RU') : '—');
</script>
<template>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div class="xl:col-span-2 bg-surface-0 dark:bg-surface-800 rounded-2xl p-6 border border-surface-100 dark:border-surface-700 shadow-sm relative overflow-hidden">
<div class="absolute inset-x-0 -bottom-20 h-40 bg-gradient-to-t from-primary-500/5 to-transparent pointer-events-none"></div>
<div class="flex items-center justify-between mb-6 relative z-10">
<h3 class="text-xs font-bold text-surface-500 dark:text-surface-400 uppercase tracking-widest">Сезонность и тренды</h3>
<div class="flex items-center gap-2 text-xs font-semibold">
<span class="flex items-center gap-1.5 text-surface-600 dark:text-surface-300">
<span class="w-3 h-3 rounded-full bg-primary-500 border-2 border-white dark:border-surface-800"></span>
Обычный спрос
</span>
<span class="flex items-center gap-1.5 text-pink-600 dark:text-pink-400 ml-3">
<span class="w-3 h-3 rounded-full bg-pink-500 border-2 border-white dark:border-surface-800"></span>
Пик
</span>
</div>
</div>
<div class="h-72 w-full relative z-10">
<Chart v-if="seasonChartData.labels.length" type="line" :data="seasonChartData" :options="areaOptions" class="h-full w-full" />
<ChartPlaceholder v-else text="Нет данных по сезонности" />
</div>
</div>
<div class="space-y-6">
<div class="bg-gradient-to-br from-indigo-50 to-blue-50 dark:from-indigo-900/30 dark:to-blue-900/20 rounded-2xl p-6 border border-indigo-100 dark:border-indigo-800/30 shadow-sm relative overflow-hidden flex flex-col items-center justify-center text-center">
<div class="absolute right-0 top-0 w-32 h-32 bg-indigo-400/10 rounded-full blur-3xl -mr-10 -mt-10 pointer-events-none"></div>
<i class="pi pi-search text-3xl text-indigo-500 mb-4"></i>
<p class="text-[11px] font-bold text-indigo-700/70 dark:text-indigo-300/70 uppercase tracking-widest mb-1">Ср. запросов в месяц</p>
<p class="text-5xl font-black text-indigo-700 dark:text-indigo-400">{{ fmtNum(data.avgMonthlySearches) }}</p>
</div>
<div class="bg-surface-0 dark:bg-surface-800 rounded-2xl p-6 border border-surface-100 dark:border-surface-700 shadow-sm">
<h3 class="text-xs font-bold text-surface-500 dark:text-surface-400 uppercase tracking-widest mb-4">Смежные запросы</h3>
<div class="flex flex-wrap gap-2">
<span v-for="(q, idx) in data.relatedQueries || []" :key="idx"
class="px-3 py-1.5 rounded-lg bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300 text-xs font-medium border border-surface-200 dark:border-surface-600 hover:bg-surface-200 dark:hover:bg-surface-600 transition-colors cursor-default">
<i class="pi pi-search text-[10px] mr-1 opacity-50"></i> {{ q }}
</span>
<span v-if="!data.relatedQueries?.length" class="text-xs italic text-surface-500">Нет данных</span>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,115 @@
<script setup>
import { computed } from 'vue';
import ChartPlaceholder from '@/components/marketing/analysis/ChartPlaceholder.vue';
import Chart from 'primevue/chart';
const props = defineProps({
data: {
type: Object,
default: () => ({})
},
chartOptions: {
type: Object,
default: () => ({})
}
});
const radarAxes = computed(() => {
const p = props.data || {};
const user = p.userMetrics || p.radarChart || {};
const market = p.marketMetrics || { activity: 50, engagement: 50, video: 50, reputation: 50, frequency: 50 };
const axes = ['activity', 'engagement', 'video', 'reputation', 'frequency'];
const labels = { activity: 'Активность', engagement: 'Вовлечённость', video: 'Видео', reputation: 'Репутация', frequency: 'Частота' };
return axes.map((k) => ({
key: k,
label: labels[k],
user: user[k] ?? 0,
market: market[k] ?? 0
}));
});
const radarChartData = computed(() => {
const a = radarAxes.value;
const isBenchmark = props.data?.mode === 'BENCHMARK';
const datasets = [
{
label: 'Рынок (Ориентир)',
data: a.map((x) => x.market),
backgroundColor: 'rgba(234,179,8,0.15)', // amber
borderColor: '#F59E0B',
pointBackgroundColor: '#F59E0B',
pointBorderColor: '#fff',
pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: '#F59E0B',
borderWidth: 2
}
];
if (!isBenchmark) {
datasets.push({
label: 'Ваша позиция',
data: a.map((x) => x.user),
backgroundColor: 'rgba(79,70,229,0.35)', // primary
borderColor: '#4F46E5',
pointBackgroundColor: '#4F46E5',
pointBorderColor: '#fff',
pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: '#4F46E5',
borderWidth: 3
});
}
return {
labels: a.map((x) => x.label),
datasets
};
});
</script>
<template>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-surface-0 dark:bg-surface-800 rounded-2xl p-6 border border-surface-100 dark:border-surface-700 shadow-sm flex flex-col items-center relative overflow-hidden">
<div class="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-primary-500/5 via-transparent to-transparent pointer-events-none"></div>
<h3 class="text-xs font-bold text-surface-500 dark:text-surface-400 uppercase tracking-widest w-full text-center mb-6">Радар конкурентоспособности</h3>
<div class="w-full max-w-sm aspect-square relative z-10">
<Chart v-if="radarChartData.labels.length" type="radar" :data="radarChartData" :options="chartOptions.radar" class="w-full h-full" />
<ChartPlaceholder v-else text="Нет данных позиционирования" />
</div>
<div class="mt-6 flex flex-wrap justify-center gap-4 text-xs font-bold uppercase tracking-wider">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded bg-amber-500/50 border border-amber-500"></span>
<span class="text-surface-600 dark:text-surface-300">Рынок</span>
</div>
<div v-if="data?.mode !== 'BENCHMARK'" class="flex items-center gap-2">
<span class="w-3 h-3 rounded bg-primary-500/50 border border-primary-500"></span>
<span class="text-surface-600 dark:text-surface-300">Вы</span>
</div>
</div>
</div>
<div class="space-y-6">
<div class="bg-surface-0 dark:bg-surface-800 rounded-2xl p-6 border border-surface-100 dark:border-surface-700 shadow-sm h-full flex flex-col">
<h3 class="text-xs font-bold text-primary-500 uppercase tracking-widest mb-6 flex items-center gap-2">
<i class="pi pi-bullseye"></i> Стратегические фокусы
</h3>
<div class="flex-1 space-y-4">
<div v-for="(focus, idx) in data.strategicFocus || []" :key="idx"
class="group flex items-start gap-4 p-4 rounded-xl bg-primary-50 dark:bg-primary-900/10 border border-primary-100 dark:border-primary-900/30 hover:shadow-md hover:border-primary-300 dark:hover:border-primary-700/50 transition-all duration-300">
<div class="w-8 h-8 rounded-full bg-primary-500 text-white flex items-center justify-center font-bold text-sm shrink-0 shadow-sm group-hover:scale-110 transition-transform">
{{ idx + 1 }}
</div>
<p class="text-sm font-semibold text-surface-800 dark:text-surface-200 leading-relaxed mt-1">
{{ focus }}
</p>
</div>
<div v-if="!data.strategicFocus?.length" class="text-sm italic text-surface-500 py-8 text-center bg-surface-50 dark:bg-surface-900/50 rounded-xl">
Нет данных по фокусам
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,25 @@
<script setup>
defineProps({
id: { type: String, default: '' },
title: { type: String, required: true },
subtitle: { type: String, default: '' },
num: { type: [String, Number], default: '' }
});
</script>
<template>
<section :id="id" class="kai-section scroll-mt-20">
<div class="kai-section-header">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="kai-section-title">{{ title }}</h2>
<p v-if="subtitle" class="kai-section-sub">{{ subtitle }}</p>
</div>
<span v-if="num" class="kai-section-badge">{{ String(num).padStart(2, '0') }}</span>
</div>
</div>
<div class="kai-section-body">
<slot />
</div>
</section>
</template>
@@ -0,0 +1,63 @@
<script setup>
import SectionWrapper from './SectionWrapper.vue';
import { computed } from 'vue';
const props = defineProps({ id: String, data: Object });
const fmtPct = (n) => n != null ? parseFloat(n).toFixed(1) + '%' : '—';
const behaviors = computed(() => [
{ icon:'🏷', label:'Акции и скидки', val: props.data?.promoUsagePercent || 0, desc:'Конкурентов используют акции', color:'#FF7A2D', tip: 'Процент успешных конкурентов, активно использующих промо-предложения' },
{ icon:'💰', label:'Открытые цены', val: props.data?.priceVisibilityPercent || 0, desc:'Указывают цены открыто (остальные — в Direct)', color:'#A855F7', tip: 'Процент компаний, которые пишут цены прямо в постах, а не просят писать в Директ' },
{ icon:'📅', label:'Прямое бронирование', val: props.data?.directBookingPercent || 0, desc:'Постов заканчиваются призывом записаться', color:'#2D7BFF', tip: 'Наличие прямой ссылки на бронирование, заявку или номер телефона' },
]);
</script>
<template>
<SectionWrapper :id="id" title="Поведение рынка" subtitle="Как конкуренты общаются с клиентами" num="07">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;">
<!-- Behavior bars -->
<div style="display:flex;flex-direction:column;gap:20px;">
<div v-for="b in behaviors" :key="b.label">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<div style="display:flex;align-items:center;gap:8px;">
<span>{{ b.icon }}</span>
<span style="font-size:13px;font-weight:600;color:rgba(255,255,255,0.8);">
{{ b.label }}
<i class="pi pi-info-circle kai-term-icon" v-tooltip.top="b.tip"></i>
</span>
</div>
<span style="font-family:Unbounded,sans-serif;font-size:16px;font-weight:900;" :style="{ color: b.color }">{{ fmtPct(b.val) }}</span>
</div>
<div class="kai-progress-track" style="height:10px;"><div class="kai-progress-fill" :style="{ width: b.val+'%', background: b.color }" /></div>
<div style="font-size:11px;color:rgba(255,255,255,0.4);margin-top:4px;">{{ b.desc }}</div>
</div>
<!-- Cycle days -->
<div style="padding:14px 16px;background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.07);border-radius:12px;display:flex;align-items:center;gap:14px;">
<span style="font-size:24px;">📆</span>
<div>
<div style="font-size:11px;color:rgba(255,255,255,0.4);font-weight:600;text-transform:uppercase;letter-spacing:.05em;">
Цикл публикаций
<i class="pi pi-info-circle kai-term-icon" v-tooltip.top="'Среднее время, проходящее между постами конкурентов'"></i>
</div>
<div style="font-family:Unbounded,sans-serif;font-size:22px;font-weight:900;color:rgba(255,255,255,0.9);">{{ data?.avgCycleDays || '—' }} <span style="font-size:13px;font-weight:400;">дней</span></div>
</div>
</div>
</div>
<!-- CTA bubbles -->
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:rgba(255,255,255,0.4);margin-bottom:14px;">Частые призывы к действию (CTA)</div>
<div style="display:flex;flex-direction:column;gap:10px;">
<div v-for="(cta, i) in (data?.commonCta || [])" :key="i" class="kai-cta-bubble" :style="{ fontSize: Math.max(16 - i * 2, 11) + 'px' }">
<span style="opacity:.5;margin-right:6px;">#{{ i+1 }}</span> "{{ cta }}"
</div>
<div v-if="!data?.commonCta?.length" style="color:rgba(255,255,255,0.3);font-size:13px;font-style:italic;text-align:center;padding:20px;">Нет данных</div>
</div>
<div style="margin-top:16px;padding:10px 14px;background:rgba(0,212,139,0.06);border:1px solid rgba(0,212,139,0.15);border-radius:10px;font-size:11px;color:rgba(0,212,139,0.8);">
💡 Используйте эти формулировки они уже проверены рынком
</div>
</div>
</div>
</SectionWrapper>
</template>
@@ -0,0 +1,81 @@
<script setup>
import SectionWrapper from './SectionWrapper.vue';
import { computed } from 'vue';
const props = defineProps({ id: String, data: Object });
const cii = computed(() => Math.round(parseFloat(props.data?.ciiIndex) || 0));
const label = computed(() => props.data?.intensityLabel || '');
const factors = computed(() => props.data?.contributingFactors || []);
const ciiColor = computed(() => {
const v = cii.value;
if (v <= 25) return '#00D48B';
if (v <= 50) return '#F59E0B';
if (v <= 75) return '#FF7A2D';
return '#EF4444';
});
const ciiDesc = computed(() => {
const v = cii.value;
if (v <= 25) return '✅ Хорошие условия для старта. Рынок не перегружен.';
if (v <= 50) return '⚡ Конкуренция есть, но место найдётся. Важен контент.';
if (v <= 75) return '🔥 Рынок активный. Нужна чёткая стратегия отличия.';
return '⚠️ Высокая конкуренция. Требуется серьёзное SMM-присутствие.';
});
const FACTOR_ICONS = ['👥','📅','❤️','⭐'];
// SVG half-gauge
const arcD = computed(() => {
const pct = cii.value / 100;
const angle = pct * 180 - 180;
const rad = angle * Math.PI / 180;
const r = 80;
const x = 100 + r * Math.cos(rad);
const y = 100 + r * Math.sin(rad);
// A half-circle never exceeds 180 degrees, so the large-arc-flag should always be 0
const lg = 0;
return `M 20 100 A 80 80 0 ${lg} 1 ${x} ${y}`;
});
</script>
<template>
<SectionWrapper :id="id" title="Индекс интенсивности конкуренции" subtitle="Насколько сложно войти и удержаться в этой нише" num="05">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:28px;align-items:start;">
<!-- Half gauge -->
<div class="kai-cii-ring-wrap">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:rgba(255,255,255,0.4);margin-bottom:12px;text-align:center;">
Общий индекс CII
<i class="pi pi-info-circle kai-term-icon" v-tooltip.right="'Competition Intensity Index (СII) комплексный показатель жесткости конкуренции, рассчитанный ИИ. Учитывает плотность игроков, их активность и бюджеты.'"></i>
</div>
<svg width="200" height="120" viewBox="0 0 200 120">
<path d="M 20 100 A 80 80 0 0 1 180 100" fill="none" stroke="rgba(255,255,255,0.05)" stroke-width="14" stroke-linecap="round"/>
<path :d="arcD" fill="none" :stroke="ciiColor" stroke-width="14" stroke-linecap="round"/>
<!-- Zones -->
<text x="24" y="118" fill="#00D48B" font-size="8" font-family="Onest">LOW</text>
<text x="172" y="118" fill="#EF4444" font-size="8" font-family="Onest" text-anchor="end">HIGH</text>
</svg>
<div class="kai-cii-num" :style="{ color: ciiColor }">{{ cii }}</div>
<div class="kai-cii-label" :style="{ color: ciiColor }">{{ label }}</div>
<div class="kai-cii-desc">{{ ciiDesc }}</div>
</div>
<!-- Factors -->
<div style="display:flex;flex-direction:column;gap:12px;">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:rgba(255,255,255,0.4);margin-bottom:4px;">Ключевые факторы</div>
<div v-for="(f, i) in factors" :key="i" class="kai-factor">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
<span style="font-size:20px;">{{ FACTOR_ICONS[i % FACTOR_ICONS.length] }}</span>
<div>
<div class="kai-factor-label">{{ ['Плотность игроков','Активность контента','Вовлечённость','Репутационный уровень'][i] || 'Фактор ' + (i+1) }}</div>
<div class="kai-factor-val">{{ f }}</div>
</div>
</div>
<div class="kai-progress-track"><div class="kai-progress-fill" :style="{ width: [35,25,20,20][i % 4]+'%', background: ciiColor }" /></div>
</div>
<div v-if="!factors.length" style="color:rgba(255,255,255,0.3);font-size:13px;text-align:center;padding:20px 0;">Нет данных</div>
</div>
</div>
</SectionWrapper>
</template>
@@ -0,0 +1,138 @@
<script setup>
import SectionWrapper from './SectionWrapper.vue';
import { computed, ref } from 'vue';
const props = defineProps({ id: String, data: [Array, Object] });
const normalizedData = computed(() => {
const arr = Array.isArray(props.data) ? props.data : (props.data?.competitors || []);
return arr.map(c => ({
...c,
erVal: parseFloat(c.engagementRate ?? c.er) || 0,
ratingVal: parseFloat(c.averageRating ?? c.rating) || 0,
followersVal: parseInt(c.followers) || 0,
postsVal: parseInt(c.postsPerMonth) || 0
}));
});
const expanded = ref(null);
const toggleRow = (name) => { expanded.value = expanded.value === name ? null : name; };
const fmtNum = (n) => n != null ? Number(n).toLocaleString('ru-RU') : '—';
const fmtPct = (n) => n != null ? parseFloat(n).toFixed(2) + '%' : '—';
const erColor = (er) => parseFloat(er) >= 6 ? '#00D48B' : parseFloat(er) >= 3 ? '#F59E0B' : '#EF4444';
const PLAT_COLORS = { Instagram:'#E1306C', TikTok:'#69C9D0', Telegram:'#2AABEE', '2GIS':'#00B140', YouTube:'#FF0000' };
const platColor = (p) => PLAT_COLORS[p] || '#808080';
const sorted = computed(() => [...normalizedData.value].sort((a, b) => b.followersVal - a.followersVal));
// Scatter data
const scatter = computed(() => normalizedData.value.map((c, i) => ({
x: c.postsVal || 0,
y: c.erVal || 0,
r: Math.max(Math.sqrt((c.followersVal || 0) / 1000) * 5, 4),
name: c.name,
color: platColor(c.platform)
})));
const maxX = computed(() => Math.max(Math.max(...scatter.value.map(d => d.x)) * 1.1, 10));
const maxY = computed(() => Math.max(Math.max(...scatter.value.map(d => d.y)) * 1.2, 5));
</script>
<template>
<SectionWrapper :id="id" title="Карта конкурентов" subtitle="Кто ваши главные конкуренты и где они стоят" num="03">
<!-- Scatter plot (SVG) -->
<div style="background:rgba(255,255,255,0.02);border:1px solid rgba(45,123,255,0.1);border-radius:12px;padding:16px;margin-bottom:20px;overflow:hidden;position:relative;">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:rgba(255,255,255,0.35);margin-bottom:12px;">
Активность (посты/мес) vs Вовлечённость (ER %)
<i class="pi pi-info-circle kai-term-icon" v-tooltip.right="'Чем правее точка тем чаще бренд публикует контент. Чем выше точка тем активнее аудитория реагирует. Лидеры (справа вверху) делают много постов и получают хороший отклик.'"></i>
</div>
<!-- Leader zone label -->
<div style="position:absolute;top:36px;right:24px;font-size:10px;font-weight:700;color:rgba(45,123,255,0.5);border:1px dashed rgba(45,123,255,0.2);padding:4px 10px;border-radius:6px;">ЛИДЕРЫ</div>
<svg style="width:100%;height:240px;" :viewBox="`0 0 500 220`" preserveAspectRatio="none">
<!-- Grid -->
<line x1="50" y1="10" x2="50" y2="200" stroke="rgba(255,255,255,0.05)" stroke-width="1"/>
<line x1="50" y1="200" x2="490" y2="200" stroke="rgba(255,255,255,0.05)" stroke-width="1"/>
<!-- Leader quadrant -->
<rect x="270" y="10" width="220" height="100" fill="rgba(45,123,255,0.03)" rx="4"/>
<!-- Data points -->
<g v-for="(pt, i) in scatter" :key="i">
<circle
:cx="50 + (pt.x / maxX) * 440"
:cy="195 - (pt.y / maxY) * 185"
:r="Math.max(pt.r, 6)"
:fill="pt.color"
fill-opacity="0.8"
style="cursor:pointer;"
/>
<text
:x="50 + (pt.x / maxX) * 440"
:y="195 - (pt.y / maxY) * 185 - Math.max(pt.r, 6) - 4"
text-anchor="middle"
fill="rgba(255,255,255,0.6)"
font-size="9"
font-family="Onest,sans-serif"
>{{ pt.name.slice(0, 12) }}</text>
</g>
</svg>
</div>
<!-- Table -->
<div style="overflow-x:auto;">
<table class="kai-table">
<thead><tr>
<th>#</th><th>Бренд</th><th>Платформа</th><th>Подписчики</th>
<th>ER <i class="pi pi-info-circle kai-term-icon" v-tooltip.top="'Вовлечённость (Engagement Rate)'"></i></th>
<th> Рейтинг</th>
<th>Постов/мес <i class="pi pi-info-circle kai-term-icon" v-tooltip.top="'Среднее количество публикаций в месяц'"></i></th>
<th></th>
</tr></thead>
<tbody>
<template v-for="(c, i) in sorted" :key="c.name">
<tr :style="i < 3 ? { borderLeft: `3px solid ${ ['#F59E0B','#9CA3AF','#CD7F32'][i] }` } : {}">
<td><span style="font-family:Unbounded,sans-serif;font-size:11px;font-weight:700;color:rgba(255,255,255,0.3);">{{ String(i+1).padStart(2,'0') }}</span></td>
<td>
<div style="display:flex;align-items:center;gap:10px;">
<div :style="{ width:'30px', height:'30px', borderRadius:'8px', background: platColor(c.platform)+'20', border:`1px solid ${platColor(c.platform)}40`, display:'flex', alignItems:'center', justifyContent:'center', fontSize:'14px', flexShrink:0 }">🏢</div>
<span style="font-weight:700;">{{ c.name }}</span>
</div>
</td>
<td><span class="kai-plat-badge" :style="{ background: platColor(c.platform)+'20', color: platColor(c.platform), border: `1px solid ${platColor(c.platform)}40` }">{{ c.platform }}</span></td>
<td style="font-weight:600;">{{ fmtNum(c.followersVal) }}</td>
<td><span style="font-weight:700;" :style="{ color: erColor(c.erVal) }">{{ fmtPct(c.erVal) }}</span></td>
<td><span style="color:#F59E0B;font-weight:700;"> {{ (c.ratingVal).toFixed(1) }}</span></td>
<td style="font-weight:600;">{{ c.postsVal }}</td>
<td>
<button class="kai-expand-btn" @click="toggleRow(c.name)">
<i :class="['pi', expanded===c.name ? 'pi-chevron-up' : 'pi-chevron-down']" style="font-size:12px;"></i>
</button>
</td>
</tr>
<!-- Expanded row -->
<tr v-if="expanded === c.name">
<td colspan="8" class="kai-expand-row" style="padding:0;">
<div class="kai-expand-inner">
<div>
<div class="kai-sw-title" style="color:#00D48B;"> Сильные стороны</div>
<ul class="kai-sw-list">
<li v-for="(s, si) in (c.strengths||[])" :key="si"><span class="kai-sw-dot green"/><span>{{ s }}</span></li>
<li v-if="!c.strengths?.length" style="color:rgba(255,255,255,0.3);font-style:italic;">Нет данных</li>
</ul>
</div>
<div>
<div class="kai-sw-title" style="color:#EF4444;"> Слабые стороны</div>
<ul class="kai-sw-list">
<li v-for="(w, wi) in (c.weaknesses||[])" :key="wi"><span class="kai-sw-dot red"/><span>{{ w }}</span></li>
<li v-if="!c.weaknesses?.length" style="color:rgba(255,255,255,0.3);font-style:italic;">Нет данных</li>
</ul>
</div>
</div>
</td>
</tr>
</template>
<tr v-if="!sorted.length"><td colspan="8" style="text-align:center;color:rgba(255,255,255,0.3);padding:32px;">Нет данных</td></tr>
</tbody>
</table>
</div>
</SectionWrapper>
</template>
@@ -0,0 +1,47 @@
<script setup>
import SectionWrapper from './SectionWrapper.vue';
import { computed } from 'vue';
const props = defineProps({ id: String, data: Array });
const ICONS = ['👥','🗂','⭐','📈','💲','🎬','⚡'];
const conclusions = computed(() => {
const arr = props.data || [];
return arr.map((text, i) => {
// try to guess the theme based on text
const t = text.toLowerCase();
let themeIdx = 1; // default layout/content
if (t.includes('конкурент') || t.includes('игрок')) themeIdx = 0;
else if (t.includes('репутац') || t.includes('отзыв') || t.includes('рейтинг')) themeIdx = 2;
else if (t.includes('спрос') || t.includes('пик')) themeIdx = 3;
else if (t.includes('цен') || t.includes('акци') || t.includes('скидк')) themeIdx = 4;
else if (t.includes('видео') || t.includes('reels') || t.includes('tiktok')) themeIdx = 5;
else if (t.includes('возможност') || t.includes('шанс') || t.includes('слаб')) themeIdx = 6;
return { text, icon: ICONS[themeIdx], number: String(i+1).padStart(2,'0') };
});
});
</script>
<template>
<SectionWrapper :id="id" title="Выводы анализа" subtitle="7 ключевых фактов о вашем рынке — простым языком" num="10">
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(320px, 1fr));gap:16px;">
<div v-for="(c, i) in conclusions" :key="i" class="kai-conclusion-card" :style="i === conclusions.length - 1 && conclusions.length % 2 !== 0 ? 'grid-column: 1 / -1;' : ''">
<div style="display:flex;align-items:flex-start;gap:16px;">
<div style="width:40px;height:40px;border-radius:10px;background:rgba(45,123,255,0.1);border:1px solid rgba(45,123,255,0.2);display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0;">
{{ c.icon }}
</div>
<div style="flex:1;">
<div style="font-size:10px;font-family:Unbounded,sans-serif;font-weight:700;color:rgba(45,123,255,0.7);margin-bottom:6px;letter-spacing:.05em;">ВЫВОД {{ c.number }}</div>
<div style="font-size:14px;line-height:1.5;color:rgba(255,255,255,0.9);font-weight:500;">{{ c.text }}</div>
</div>
</div>
</div>
<div v-if="!conclusions.length" style="grid-column: 1 / -1;text-align:center;color:rgba(255,255,255,0.3);padding:40px;">Выводы формируются...</div>
</div>
</SectionWrapper>
</template>
@@ -0,0 +1,101 @@
<script setup>
import SectionWrapper from './SectionWrapper.vue';
import { computed } from 'vue';
const props = defineProps({ id: String, data: Object });
const fmtPct = (n) => n != null ? parseFloat(n).toFixed(1) + '%' : '—';
const fmtNum = (n) => n != null ? Number(n).toLocaleString('ru-RU') : '—';
const SEGMENTS = [
{ key: 'demoContentPercent', label: 'Демонстрация', color: '#2D7BFF' },
{ key: 'expertContentPercent', label: 'Экспертный', color: '#00D48B' },
{ key: 'salesContentPercent', label: 'Продающий', color: '#FF7A2D' },
{ key: 'reviewsContentPercent', label: 'Отзывы', color: '#A855F7' },
{ key: 'engagementContentPercent', label: 'Вовлекающий', color: '#F59E0B' },
];
const segments = computed(() => SEGMENTS.map(s => ({ ...s, value: props.data?.[s.key] || 0 })).filter(s => s.value > 0));
// SVG donut chart
const donut = computed(() => {
const total = segments.value.reduce((a, s) => a + s.value, 0) || 1;
const r = 70, cx = 90, cy = 90, stroke = 28;
let cum = 0;
return segments.value.map(s => {
const pct = s.value / total;
const circ = 2 * Math.PI * r;
const dash = circ * pct;
const gap = circ * (1 - pct);
const offset = circ * (0.25 - cum); // start from top
cum += pct;
return { ...s, pct: Math.round(pct * 100), dash, gap, offset, r, cx, cy, stroke, circ };
});
});
</script>
<template>
<SectionWrapper :id="id" title="Профиль контента рынка" subtitle="Что и как публикуют конкуренты" num="04">
<div style="display:grid;grid-template-columns:200px 1fr;gap:28px;align-items:start;">
<!-- Donut -->
<div style="display:flex;flex-direction:column;align-items:center;gap:12px;">
<div style="position:relative;">
<svg width="180" height="180" viewBox="0 0 180 180">
<g v-for="seg in donut" :key="seg.key">
<circle
:cx="seg.cx"
:cy="seg.cy"
:r="seg.r"
fill="none"
:stroke="seg.color"
:stroke-width="seg.stroke"
:stroke-dasharray="`${seg.dash} ${seg.gap}`"
:stroke-dashoffset="seg.offset * seg.r"
/>
</g>
<text x="90" y="86" text-anchor="middle" fill="rgba(255,255,255,0.8)" font-family="Unbounded,sans-serif" font-size="11" font-weight="700">Контент</text>
<text x="90" y="100" text-anchor="middle" fill="rgba(255,255,255,0.5)" font-family="Onest,sans-serif" font-size="10">рынка</text>
</svg>
</div>
<div style="display:flex;flex-direction:column;gap:8px;width:100%;">
<div v-for="seg in donut" :key="seg.key" style="display:flex;align-items:center;gap:8px;">
<div :style="{ width:'10px', height:'10px', borderRadius:'3px', background: seg.color, flexShrink:0 }"/>
<span style="font-size:11px;color:rgba(255,255,255,0.7);flex:1;">{{ seg.label }}</span>
<span style="font-size:12px;font-weight:700;" :style="{ color: seg.color }">{{ seg.pct }}%</span>
</div>
</div>
</div>
<!-- Meta-metrics -->
<div style="display:flex;flex-direction:column;gap:16px;">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:rgba(255,255,255,0.4);">Ключевые показатели контента</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">
<div v-for="card in [
{ label:'Доля видео', value: fmtPct(data?.videoShare), color:'#2D7BFF', icon:'🎬', tip: 'Процент видео-контента (Reels, TikTok, Shorts) от общего числа постов' },
{ label:'CTA в постах', value: fmtPct(data?.ctaUsagePercent), color:'#00D48B', icon:'📣', tip: 'Процент постов, содержащих призыв к действию (CTA)' },
{ label:'Длина текста', value: fmtNum(data?.avgTextLengthCharacters) + ' симв.', color:'#A855F7', icon:'📝', small:true, tip: 'Среднее количество символов в тексте публикаций' },
]" :key="card.label" style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:12px;padding:16px;text-align:center;">
<div style="font-size:20px;margin-bottom:8px;">{{ card.icon }}</div>
<div style="font-family:Unbounded,sans-serif;font-size:20px;font-weight:900;line-height:1;margin-bottom:4px;" :style="{ color: card.color }">{{ card.value }}</div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:rgba(255,255,255,0.4);">
{{ card.label }}
<i class="pi pi-info-circle kai-term-icon" v-tooltip.top="card.tip"></i>
</div>
</div>
</div>
<!-- Detailed bars -->
<div style="display:flex;flex-direction:column;gap:10px;">
<div v-for="seg in segments" :key="seg.key" style="display:flex;align-items:center;gap:12px;">
<span style="font-size:12px;font-weight:600;color:rgba(255,255,255,0.7);width:110px;flex-shrink:0;">{{ seg.label }}</span>
<div class="kai-progress-track" style="flex:1;height:8px;">
<div class="kai-progress-fill" :style="{ width: seg.value + '%', background: seg.color }" />
</div>
<span style="font-size:12px;font-weight:700;width:42px;text-align:right;" :style="{ color: seg.color }">{{ fmtPct(seg.value) }}</span>
</div>
</div>
</div>
</div>
</SectionWrapper>
</template>
@@ -0,0 +1,124 @@
<script setup>
import SectionWrapper from './SectionWrapper.vue';
import { computed } from 'vue';
const props = defineProps({ id: String, data: Object });
const fmtNum = (n) => n != null ? Number(n).toLocaleString('ru-RU') : '—';
const seasonality = computed(() => props.data?.seasonality || []);
const demandTrend = computed(() => {
if (seasonality.value.length < 2) return 0;
const first = seasonality.value[0].value;
const last = seasonality.value[seasonality.value.length - 1].value;
return first ? Math.round(((last - first) / first) * 100) : 0;
});
const trendDir = computed(() => demandTrend.value >= 0 ? '▲ +' + demandTrend.value + '%' : '▼ ' + demandTrend.value + '%');
const trendColor = computed(() => demandTrend.value >= 0 ? '#00D48B' : '#EF4444');
// Area chart (SVG)
const chartPath = computed(() => {
if (seasonality.value.length === 0) return '';
const pts = seasonality.value.map(s => s.value);
const max = Math.max(...pts, 100);
const min = Math.min(...pts, 0);
const range = max - min || 1;
const w = 400, h = 180;
const step = w / (pts.length - 1 || 1);
const coords = pts.map((val, i) => {
const x = i * step;
const y = h - ((val - min) / range) * (h - 20) - 10;
return {x, y};
});
let d = `M ${coords[0].x} ${coords[0].y}`;
for (let i = 1; i < coords.length; i++) {
const cur = coords[i], prev = coords[i-1];
const mx = prev.x + (cur.x - prev.x) / 2;
d += ` C ${mx} ${prev.y}, ${mx} ${cur.y}, ${cur.x} ${cur.y}`;
}
return { stroke: d, fill: `${d} L ${w} ${h} L 0 ${h} Z`, pts: coords };
});
</script>
<template>
<SectionWrapper :id="id" title="Поисковый спрос" subtitle="Как часто ищут ваш продукт и когда пиковый сезон" num="08">
<!-- Top metrics -->
<div style="display:flex;flex-wrap:wrap;gap:12px;margin-bottom:20px;">
<div class="kai-query-card" style="display:flex;align-items:center;gap:12px;background:rgba(45,123,255,0.08);border-color:rgba(45,123,255,0.3);">
<span style="font-size:20px;">📊</span>
<div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;color:rgba(45,123,255,0.8);">
Запросов/мес
<i class="pi pi-info-circle kai-term-icon" v-tooltip.top="'Среднее количество поисковых запросов в месяц (из Яндекс.Вордстат / Google Keyword Planner)'"></i>
</div>
<div style="font-family:Unbounded,sans-serif;font-size:16px;font-weight:900;color:#2D7BFF;">{{ fmtNum(data?.avgMonthlySearches) }}</div>
</div>
</div>
<div class="kai-query-card" style="display:flex;align-items:center;gap:12px;">
<span style="font-size:20px;">📈</span>
<div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;color:rgba(255,255,255,0.4);">Тренд</div>
<div style="font-family:Unbounded,sans-serif;font-size:16px;font-weight:900;" :style="{ color: trendColor }">{{ trendDir }}</div>
</div>
</div>
<div class="kai-query-card" style="display:flex;align-items:center;gap:12px;" v-if="data?.peakPeriods?.length">
<span style="font-size:20px;">📅</span>
<div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;color:rgba(255,255,255,0.4);">Пик сезона</div>
<div style="font-family:Unbounded,sans-serif;font-size:14px;font-weight:700;color:rgba(255,255,255,0.9);">{{ data.peakPeriods[0] }}</div>
</div>
</div>
</div>
<!-- Chart -->
<div class="kai-area-chart-wrap" style="margin-bottom:24px;">
<svg v-if="seasonality.length" viewBox="0 0 400 200" style="width:100%;height:220px;overflow:visible;">
<defs>
<linearGradient id="areaG" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#2D7BFF" stop-opacity="0.3"/>
<stop offset="100%" stop-color="#2D7BFF" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- Grid -->
<g stroke="rgba(255,255,255,0.05)" stroke-width="1">
<line x1="0" y1="45" x2="400" y2="45" stroke-dasharray="4 4"/>
<line x1="0" y1="90" x2="400" y2="90" stroke-dasharray="4 4"/>
<line x1="0" y1="135" x2="400" y2="135" stroke-dasharray="4 4"/>
</g>
<!-- Line and Area -->
<path :d="chartPath.fill" fill="url(#areaG)" />
<path :d="chartPath.stroke" fill="none" stroke="#2D7BFF" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
<!-- Data dots & X labels -->
<g v-for="(s, i) in seasonality" :key="i">
<circle :cx="chartPath.pts[i].x" :cy="chartPath.pts[i].y" r="4" fill="#2D7BFF" stroke="#0A0E1A" stroke-width="2"/>
<text :x="chartPath.pts[i].x" y="195" text-anchor="middle" fill="rgba(255,255,255,0.5)" font-size="10" font-family="Onest,sans-serif">{{ s.period.substring(0,3) }}</text>
</g>
</svg>
<div v-else style="color:rgba(255,255,255,0.3);text-align:center;padding:40px;">Нет данных по сезонности</div>
<!-- Peak cards below chart -->
<div v-if="data?.peakPeriods?.length" style="display:flex;flex-wrap:wrap;gap:12px;margin-top:20px;">
<div v-for="p in data.peakPeriods" :key="p" class="kai-peak-card">
<div class="kai-peak-title">🔥 {{ p }}</div>
<div class="kai-peak-sub">Готовиться за 46 недель</div>
</div>
</div>
</div>
<!-- Related Queries -->
<div v-if="data?.relatedQueries?.length">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:rgba(255,255,255,0.4);margin-bottom:12px;">Связанные запросы</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(240px, 1fr));gap:12px;">
<div v-for="(q, i) in data.relatedQueries" :key="q" class="kai-query-card">
<div class="kai-query-label">🔍 "{{ q }}"</div>
<div style="display:flex;align-items:center;gap:8px;">
<div class="kai-progress-track" style="flex:1;height:4px;"><div class="kai-progress-fill" :style="{ width: Math.max(10, 100 - i*15)+'%' }" /></div>
<span style="font-size:10px;color:rgba(255,255,255,0.4);">Популярно</span>
</div>
</div>
</div>
</div>
</SectionWrapper>
</template>
@@ -0,0 +1,66 @@
<script setup>
import SectionWrapper from './SectionWrapper.vue';
import { computed } from 'vue';
const props = defineProps({ id: String, data: Object });
const lvlClass = (lvl) => {
const l = (lvl || '').toLowerCase();
if (l.includes('высок') || l.includes('high')) return 'kai-level-vhigh';
if (l.includes('средн') || l.includes('medium')) return 'kai-level-medium';
if (l.includes('низк') || l.includes('low')) return 'kai-level-low';
return 'kai-level-high';
};
const erColor = (v) => parseFloat(v) >= 4 ? '#00D48B' : parseFloat(v) >= 2 ? '#F59E0B' : '#EF4444';
const stars = (r) => {
const n = parseFloat(r) || 0;
return '★'.repeat(Math.round(n)) + '☆'.repeat(5 - Math.round(n));
};
const fmtPct = (n) => n != null ? parseFloat(n).toFixed(1) + '%' : '—';
const kpis = computed(() => {
const d = props.data || {};
return [
{ icon: '👥', num: d.activeCompetitors ?? '—', label: 'активных конкурентов', sub: d.competitionLevel, subClass: lvlClass(d.competitionLevel), color: '#2D7BFF' },
{ icon: '📈', num: fmtPct(d.averageNicheEr), label: 'средний ER в нише', sub: 'Уровень вовлечённости аудитории', numColor: erColor(d.averageNicheEr), color: '#00D48B', term: 'er' },
{ icon: '⭐', num: (parseFloat(d.averageRating) || 0).toFixed(1), label: 'средний рейтинг на картах', sub: stars(d.averageRating), color: '#F59E0B', term: 'rating' },
{ icon: '📊', num: d.demandTrend || '—', label: 'тренд спроса', sub: 'Изменение за период', color: '#A855F7', small: true, term: 'trend' },
{ icon: '🎯', num: d.businessStage || '—', label: 'этап бизнеса', sub: d.geography || '', color: '#FF7A2D', small: true },
{ icon: '⚡', num: null, label: 'главный инсайт', sub: (d.keyFigures || [])[0] || '—', special: true, color: '#2D7BFF' },
];
});
</script>
<template>
<SectionWrapper :id="id" title="Краткий обзор рынка" subtitle="Ключевые показатели ниши — всё самое важное на одном экране" num="00">
<div class="kai-kpi-grid">
<div v-for="(k, i) in kpis" :key="i" class="kai-kpi" :style="k.special ? 'border-color:rgba(45,123,255,0.4);background:rgba(45,123,255,0.06);' : ''">
<div class="kai-kpi-icon" :style="{ background: k.color + '18' }">
<span>{{ k.icon }}</span>
</div>
<div v-if="!k.special">
<div class="kai-kpi-num" :style="k.numColor ? { color: k.numColor } : {}" :class="k.small ? 'text-2xl' : ''">{{ k.num }}</div>
<div class="kai-kpi-label">
{{ k.label }}
<i v-if="k.term === 'er'" class="pi pi-info-circle kai-term-icon" v-tooltip.top="'Engagement Rate: Процент аудителей, который активно взаимодействует с контентом (лайки, комментарии, репосты)'"></i>
<i v-if="k.term === 'rating'" class="pi pi-info-circle kai-term-icon" v-tooltip.top="'Средний рейтинг конкурентов в таких сервисах, как 2ГИС, Яндекс.Карты и Google Maps'"></i>
<i v-if="k.term === 'trend'" class="pi pi-info-circle kai-term-icon" v-tooltip.top="'Показывает общую динамику спроса в вашей нише: растет, падает или стабилен'"></i>
</div>
<div v-if="k.subClass" class="kai-level-badge" :class="k.subClass" style="font-size:11px;padding:3px 10px;">{{ k.sub }}</div>
<div v-else class="kai-kpi-sub">{{ k.sub }}</div>
</div>
<div v-else>
<div class="kai-kpi-label" style="color:#2D7BFF;text-transform:uppercase;letter-spacing:.08em;"> Главный инсайт</div>
<div class="kai-kpi-sub" style="color:rgba(255,255,255,0.8);font-size:13px;line-height:1.6;">{{ k.sub }}</div>
</div>
</div>
</div>
<!-- Key insights strip -->
<div v-if="(data?.keyFigures || []).length" class="kai-insight-strip" style="margin-top:20px;">
<div v-for="(f, i) in (data?.keyFigures || []).slice(0,4)" :key="i" class="kai-insight-tag">
<span style="opacity:.5;margin-right:4px;">{{ ['📊','⭐','📈','🎯'][i] }}</span> {{ f }}
</div>
</div>
</SectionWrapper>
</template>
@@ -0,0 +1,114 @@
<script setup>
import SectionWrapper from './SectionWrapper.vue';
import { computed } from 'vue';
const props = defineProps({ id: String, data: Object });
const GAUGE_COLORS = ['#00D48B','#F59E0B','#FF7A2D','#EF4444'];
const gaugeColor = (v) => {
const n = parseFloat(v) || 0;
if (n <= 40) return GAUGE_COLORS[0];
if (n <= 70) return GAUGE_COLORS[1];
if (n <= 85) return GAUGE_COLORS[2];
return GAUGE_COLORS[3];
};
const gaugeLabel = (v) => {
const n = parseFloat(v) || 0;
if (n <= 30) return '🟢 Свободный рынок';
if (n <= 60) return '🟡 Умеренная конкуренция';
if (n <= 85) return '🟠 Высокая конкуренция';
return '🔴 Перегретый рынок';
};
const cityStatus = (row) => {
if ((row.activePlayers > 30 && row.avgEr > 4) || row.activePlayers > 25) return { text: 'Высококонкурентный', color: '#EF4444' };
if (row.activePlayers > 15) return { text: 'Конкурентный', color: '#FF7A2D' };
return { text: 'Свободный', color: '#00D48B' };
};
const fmtPct = (n) => n != null ? parseFloat(n).toFixed(2) + '%' : '—';
const cities = computed(() => {
const arr = props.data?.cityComparison || [];
return arr.map(c => ({
...c,
avgEr: parseFloat(c.averageEr ?? c.avgEr) || 0,
avgRating: parseFloat(c.averageRating ?? c.avgRating) || 0,
activePlayers: parseInt(c.activePlayers) || 0,
avgPostsPerMonth: parseInt(c.averagePostsPerMonth ?? c.avgPostsPerMonth) || 0
}));
});
const density = computed(() => parseFloat(props.data?.densityIndex) || 0);
// SVG gauge arc calculation
const arcPath = computed(() => {
const r = 80, cx = 100, cy = 100;
const startAngle = -180, sweepAngle = 180 * (density.value / 100);
const toRad = (d) => d * Math.PI / 180;
const x1 = cx + r * Math.cos(toRad(startAngle));
const y1 = cy + r * Math.sin(toRad(startAngle));
const x2 = cx + r * Math.cos(toRad(startAngle + sweepAngle));
const y2 = cy + r * Math.sin(toRad(startAngle + sweepAngle));
const lg = sweepAngle > 180 ? 1 : 0;
return `M ${x1} ${y1} A ${r} ${r} 0 ${lg} 1 ${x2} ${y2}`;
});
const sortKey = { key: 'activePlayers', dir: -1 };
const sortedCities = computed(() => [...cities.value].sort((a,b) => (b[sortKey.key] - a[sortKey.key]) * sortKey.dir));
</script>
<template>
<SectionWrapper :id="id" title="Конкуренция по городам" subtitle="Детальное сравнение рынков в каждом городе" num="02">
<div style="display:grid;grid-template-columns:200px 1fr;gap:28px;align-items:start;">
<!-- Gauge -->
<div class="kai-gauge-wrap">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:rgba(255,255,255,0.4);margin-bottom:12px;text-align:center;">
Индекс плотности
<i class="pi pi-info-circle kai-term-icon" v-tooltip.right="'Показывает, насколько сложно зайти на рынок в этом городе. Выше 70 рынок перегрет, нужны большие бюджеты.'"></i>
</div>
<svg width="200" height="120" viewBox="0 0 200 120">
<!-- Track -->
<path d="M 20 100 A 80 80 0 0 1 180 100" fill="none" stroke="rgba(255,255,255,0.06)" stroke-width="14" stroke-linecap="round"/>
<!-- Fill -->
<path :d="arcPath.replace('M 20 100', 'M 20 100')" fill="none" :stroke="gaugeColor(density)" stroke-width="14" stroke-linecap="round">
<animateTransform attributeName="transform" type="rotate" from="0 100 100" to="0 100 100" dur="1s"/>
</path>
<!-- Value -->
<text x="100" y="96" text-anchor="middle" :fill="gaugeColor(density)" font-family="Unbounded,sans-serif" font-size="28" font-weight="900">{{ Math.round(density) }}</text>
<text x="100" y="112" text-anchor="middle" fill="rgba(255,255,255,0.4)" font-family="Onest,sans-serif" font-size="10">из 100</text>
</svg>
<div style="font-size:11px;font-weight:700;text-align:center;padding:4px 12px;border-radius:999px;background:rgba(255,255,255,0.05);color:rgba(255,255,255,0.7);">{{ gaugeLabel(density) }}</div>
</div>
<!-- Table -->
<div style="overflow-x:auto;">
<table class="kai-table">
<thead><tr>
<th>Город</th><th>Игроков</th>
<th>
Ср. ER
<i class="pi pi-info-circle kai-term-icon" v-tooltip.top="'Средняя вовлечённость аудитории'"></i>
</th>
<th>Рейтинг</th><th>Постов/мес</th><th>Статус</th>
</tr></thead>
<tbody>
<tr v-for="c in sortedCities" :key="c.city">
<td><span style="font-weight:700;">🏙 {{ c.city }}</span></td>
<td><span style="font-family:Unbounded,sans-serif;font-weight:700;color:#2D7BFF;">{{ c.activePlayers }}</span></td>
<td><span :style="{ color: parseFloat(c.avgEr)>=4?'#00D48B':parseFloat(c.avgEr)>=2?'#F59E0B':'#EF4444', fontWeight:700 }">{{ fmtPct(c.avgEr) }}</span></td>
<td><span style="color:#F59E0B;font-weight:700;"> {{ (c.avgRating||0).toFixed(1) }}</span></td>
<td>
<div style="display:flex;align-items:center;gap:8px;">
<div style="width:60px;height:4px;background:rgba(255,255,255,0.06);border-radius:2px;">
<div :style="{ width:Math.min(c.avgPostsPerMonth/30*100,100)+'%', height:'100%', background:'#2D7BFF', borderRadius:'2px' }"/>
</div>
<span style="font-weight:600;font-size:12px;">{{ c.avgPostsPerMonth }}</span>
</div>
</td>
<td><span style="padding:3px 10px;border-radius:999px;font-size:10px;font-weight:700;" :style="{ background: cityStatus(c).color+'18', color: cityStatus(c).color }">{{ cityStatus(c).text }}</span></td>
</tr>
<tr v-if="!sortedCities.length"><td colspan="6" style="text-align:center;color:rgba(255,255,255,0.3);padding:24px;">Нет данных</td></tr>
</tbody>
</table>
</div>
</div>
</SectionWrapper>
</template>
@@ -0,0 +1,26 @@
<script setup>
import { computed } from 'vue';
defineProps({ status: String, exec: Object });
const emit = defineEmits(['strategy', 'back']);
const statusOk = computed(() => ({ COMPLETED: true })[props?.status]);
</script>
<script>
export default { name: 'V4Header' };
</script>
<template>
<header class="kai-header">
<div class="kai-logo">KAI</div>
<div class="kai-title-area">
<div class="kai-title-niche">Анализ рынка · {{ exec?.geography || '...' }}</div>
<div class="kai-title-sub">{{ exec?.businessStage || '' }} · KONTURAI ANALYTICS v4.0</div>
</div>
<div style="display:flex;align-items:center;gap:10px;margin-left:auto;">
<span v-if="status === 'COMPLETED'" class="kai-badge-status green"> Анализ завершён</span>
<span v-else-if="status === 'PROCESSING' || status === 'QUEUED'" class="kai-badge-status yellow"> Обработка...</span>
<button class="kai-pdf-btn" @click="$emit('back')"> Список</button>
<button v-if="status === 'COMPLETED'" class="kai-cta-btn" style="padding:8px 18px;font-size:12px;margin:0;" @click="$emit('strategy')">
Создать Стратегию
</button>
</div>
</header>
</template>
@@ -0,0 +1,69 @@
<script setup>
import SectionWrapper from './SectionWrapper.vue';
import { computed } from 'vue';
const props = defineProps({ id: String, data: Object, demand: Object });
const PLATFORM_COLORS = { Instagram:'#E1306C', TikTok:'#69C9D0', Telegram:'#2AABEE', '2GIS':'#00B140', YouTube:'#FF0000', GoogleMaps:'#4285F4' };
const platforms = computed(() => {
const pl = props.data?.activePlayersByPlatform || {};
const max = Math.max(...Object.values(pl), 1);
return Object.entries(pl).map(([k, v]) => ({ name: k, count: v, pct: Math.round(v / max * 100), color: PLATFORM_COLORS[k] || '#2D7BFF' })).sort((a,b)=>b.count-a.count);
});
const cities = computed(() => {
const cd = props.data?.cityDistribution || {};
const max = Math.max(...Object.values(cd), 1);
return Object.entries(cd).map(([k, v]) => ({ city: k, count: v, pct: Math.round(v / max * 100) })).sort((a,b)=>b.count-a.count).slice(0, 7);
});
</script>
<template>
<SectionWrapper :id="id" title="Ландшафт рынка" subtitle="Где и сколько конкурентов присутствуют" num="01">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;">
<!-- Platforms horizontal bar chart -->
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:rgba(255,255,255,0.4);margin-bottom:16px;">Конкуренты по платформам</div>
<div style="display:flex;flex-direction:column;gap:12px;">
<div v-for="p in platforms" :key="p.name" style="display:flex;align-items:center;gap:10px;">
<div style="width:90px;font-size:12px;font-weight:600;color:rgba(255,255,255,0.7);text-align:right;flex-shrink:0;">{{ p.name }}</div>
<div style="flex:1;height:28px;background:rgba(255,255,255,0.04);border-radius:6px;overflow:hidden;position:relative;">
<div :style="{ width: p.pct+'%', height:'100%', background: p.color, borderRadius:'6px', transition:'width 1.2s ease-out', display:'flex', alignItems:'center', paddingLeft:'10px' }">
<span style="font-size:12px;font-weight:700;color:#fff;white-space:nowrap;">{{ p.count }}</span>
</div>
</div>
</div>
<div v-if="!platforms.length" style="color:rgba(255,255,255,0.3);font-size:13px;text-align:center;padding:20px 0;">Нет данных</div>
</div>
</div>
<!-- Cities table -->
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:rgba(255,255,255,0.4);margin-bottom:16px;">Распределение по городам</div>
<div style="display:flex;flex-direction:column;gap:10px;">
<div v-for="c in cities" :key="c.city" style="display:grid;grid-template-columns:100px 40px 1fr;align-items:center;gap:10px;">
<span style="font-size:12px;font-weight:600;color:rgba(255,255,255,0.8);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ c.city }}</span>
<span style="font-size:13px;font-weight:700;color:#2D7BFF;text-align:right;">{{ c.count }}</span>
<div class="kai-progress-track">
<div class="kai-progress-fill" :style="{ width: c.pct + '%' }" />
</div>
</div>
<div v-if="!cities.length" style="color:rgba(255,255,255,0.3);font-size:13px;text-align:center;padding:20px 0;">Нет данных</div>
</div>
<!-- Niche reputation -->
<div v-if="data?.nicheReputationLevel" style="margin-top:20px;padding:14px 18px;background:rgba(0,212,139,0.06);border:1px solid rgba(0,212,139,0.2);border-radius:12px;display:flex;align-items:center;justify-content:space-between;">
<div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:rgba(0,212,139,0.7);margin-bottom:4px;">
Репутация ниши
<i class="pi pi-info-circle kai-term-icon" v-tooltip.top="'Средний рейтинг среди всех конкурентов. Отражает общее доверие клиентов к данному рынку.'"></i>
</div>
<div style="font-family:Unbounded,sans-serif;font-size:24px;font-weight:900;color:#00D48B;">{{ data.nicheReputationLevel }} <span style="font-size:14px;">/ 5.0</span></div>
</div>
<div style="font-size:28px;">🏆</div>
</div>
</div>
</div>
</SectionWrapper>
</template>
@@ -0,0 +1,117 @@
<script setup>
import SectionWrapper from './SectionWrapper.vue';
import { computed } from 'vue';
const props = defineProps({ id: String, data: Object });
const mode = computed(() => props.data?.mode || 'COMPARISON');
const isBench = computed(() => mode.value === 'BENCHMARK');
const axes = [
{ key: 'activity', label: '📊 Активность', tip: 'Количество и регулярность публикаций' },
{ key: 'engagement', label: '💬 Вовлечённость', tip: 'Количество лайков, комментариев, репостов' },
{ key: 'video', label: '📹 Видео', tip: 'Доля современного формата Reels / TikTok в контенте' },
{ key: 'reputation', label: '⭐ Репутация', tip: 'Рейтинг на агрегаторах и картах (2ГИС, Яндекс)' },
{ key: 'frequency', label: '📅 Частота', tip: 'Сколько проходит времени между постами' }
];
const uMet = computed(() => props.data?.userMetrics || {});
const mMet = computed(() => props.data?.marketMetrics || {});
const gapData = computed(() => axes.map(a => {
const m = parseFloat(mMet.value[a.key]) || 0;
const u = parseFloat(uMet.value[a.key]) || 0;
const diff = u - m;
let prio = 'NEED', prioText = '🟡 Нужно';
if (diff < -60) { prio = 'CRIT'; prioText = '🔴 Критично'; }
else if (diff < -40) { prio = 'IMP'; prioText = '🟠 Важно'; }
else if (diff >= 0) { prio = 'AHEAD'; prioText = '🟢 Вы впереди'; }
return { label: a.label, m, u, diff, prio, prioText };
}));
// Radar Chart Math
const radarPath = (metrics, scale=0.8) => {
const pts = axes.map((a, i) => {
const v = parseFloat(metrics[a.key]) || 0;
const r = (v / 100) * 100 * scale; // max radius 100
const angle = (Math.PI * 2 * i) / axes.length - Math.PI / 2;
return `${150 + r * Math.cos(angle)},${150 + r * Math.sin(angle)}`;
});
return `M ${pts.join(' L ')} Z`;
};
const marketPath = computed(() => radarPath(mMet.value));
const userPath = computed(() => isBench.value ? '' : radarPath(uMet.value));
</script>
<template>
<SectionWrapper :id="id" title="Ваша позиция на рынке" :subtitle="isBench ? 'Вы ещё не в соцсетях — вот как выглядит рынок, который вас ждёт' : 'Сравнение вашего аккаунта со средним по рынку'" num="09">
<div style="display:grid;grid-template-columns:300px 1fr;gap:32px;align-items:start;">
<!-- Radar Chart -->
<div>
<div style="position:relative;width:300px;height:300px;background:rgba(255,255,255,0.02);border-radius:50%;border:1px solid rgba(255,255,255,0.05);">
<!-- Web -->
<svg width="300" height="300" viewBox="0 0 300 300">
<polygon points="150,50 245,119 209,230 91,230 54,119" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>
<polygon points="150,100 197,135 178,190 120,190 102,135" fill="none" stroke="rgba(255,255,255,0.05)" stroke-width="1"/>
<line x1="150" y1="150" x2="150" y2="50" stroke="rgba(255,255,255,0.1)" />
<line x1="150" y1="150" x2="245" y2="119" stroke="rgba(255,255,255,0.1)" />
<line x1="150" y1="150" x2="209" y2="230" stroke="rgba(255,255,255,0.1)" />
<line x1="150" y1="150" x2="91" y2="230" stroke="rgba(255,255,255,0.1)" />
<line x1="150" y1="150" x2="54" y2="119" stroke="rgba(255,255,255,0.1)" />
<!-- Market -->
<path :d="marketPath" fill="rgba(45,123,255,0.2)" stroke="#2D7BFF" stroke-width="2" />
<!-- User -->
<path v-if="!isBench" :d="userPath" fill="rgba(0,212,139,0.3)" stroke="#00D48B" stroke-width="3" />
<!-- Labels -->
<text x="150" y="35" text-anchor="middle" fill="rgba(255,255,255,0.6)" font-size="10" font-weight="700">Активность</text>
<text x="260" y="115" text-anchor="start" fill="rgba(255,255,255,0.6)" font-size="10" font-weight="700">Вовлечённость</text>
<text x="215" y="245" text-anchor="start" fill="rgba(255,255,255,0.6)" font-size="10" font-weight="700">Видео</text>
<text x="85" y="245" text-anchor="end" fill="rgba(255,255,255,0.6)" font-size="10" font-weight="700">Репутация</text>
<text x="40" y="115" text-anchor="end" fill="rgba(255,255,255,0.6)" font-size="10" font-weight="700">Частота</text>
<text v-if="isBench" x="150" y="150" text-anchor="middle" fill="#2D7BFF" font-family="Unbounded,sans-serif" font-size="11" font-weight="700">СТАРТ</text>
</svg>
</div>
<div class="kai-radar-labels">
<div class="kai-radar-label"><div class="kai-radar-dot" style="background:#2D7BFF;" /> Средний рынок</div>
<div class="kai-radar-label" v-if="!isBench"><div class="kai-radar-dot" style="background:#00D48B;" /> Ваш аккаунт</div>
</div>
</div>
<!-- Gap Table & Focus -->
<div>
<div style="font-size:14px;font-family:Unbounded,sans-serif;font-weight:700;color:rgba(255,255,255,0.9);margin-bottom:12px;">Где вы отстаёте от рынка</div>
<table class="kai-table kai-gap-table" style="margin-bottom:24px;">
<thead><tr><th>Параметр</th><th>Рынок</th><th v-if="!isBench">Вы</th><th>Разрыв</th><th>Приоритет</th></tr></thead>
<tbody>
<tr v-for="g in gapData" :key="g.label">
<td style="font-weight:600;">
{{ g.label }}
<i class="pi pi-info-circle kai-term-icon" v-tooltip.top="g.tip"></i>
</td>
<td style="font-weight:700;color:#2D7BFF;">{{ Math.round(g.m) }}</td>
<td v-if="!isBench"><span style="font-weight:700;color:#00D48B;">{{ Math.round(g.u) }}</span></td>
<td style="font-family:Unbounded,sans-serif;font-weight:700;" :style="{ color: g.diff >= 0 ? '#00D48B' : (g.diff < -40 ? '#EF4444' : '#F59E0B') }">{{ g.diff >= 0 ? '+'+Math.round(g.diff) : Math.round(g.diff) }}</td>
<td>
<span class="kai-prio-badge" :class="{'kai-prio-crit':g.prio==='CRIT', 'kai-prio-imp':g.prio==='IMP', 'kai-prio-need':g.prio==='NEED', 'kai-prio-ahead':g.prio==='AHEAD'}">{{ g.prioText }}</span>
</td>
</tr>
</tbody>
</table>
<div v-if="data?.strategicFocus?.length">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:rgba(255,255,255,0.4);margin-bottom:12px;">Стратегические фокусы</div>
<div style="display:flex;flex-direction:column;gap:10px;">
<div v-for="(f, i) in data.strategicFocus" :key="i" class="kai-focus-card">
<div class="kai-focus-num">{{ i+1 }}</div>
<div class="kai-focus-text">{{ f }}</div>
</div>
</div>
</div>
</div>
</div>
</SectionWrapper>
</template>
@@ -0,0 +1,47 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({ id: String, data: String });
defineEmits(['strategy']);
const pars = computed(() => {
if (!props.data) return [];
return props.data.split('\n\n').filter(p => p.trim());
});
</script>
<template>
<div :id="id" class="kai-rationale-section">
<div class="kai-rationale-content">
<div class="kai-rationale-header">Итоговый план действий</div>
<div class="kai-rationale-title">Почему SMM это ваш следующий шаг</div>
<div class="kai-rationale-subtitle">Обоснование стратегии на основе данных анализа</div>
<div class="kai-rationale-body">
<p v-for="(p, i) in pars" :key="i">{{ p }}</p>
<div v-if="!pars.length" style="color:rgba(255,255,255,0.3);font-style:italic;">Стратегия формируется...</div>
</div>
</div>
<!-- CTA Block -->
<div class="kai-rationale-cta-block">
<div style="font-family:Unbounded,sans-serif;font-size:24px;font-weight:900;color:#fff;margin-bottom:8px;text-align:center;">🎯 Готовы к запуску?</div>
<div style="text-align:center;color:rgba(255,255,255,0.7);font-size:14px;margin-bottom:24px;">На основе этого анализа мы создадим для вас:</div>
<div style="display:flex;flex-direction:column;gap:12px;margin-bottom:32px;max-width:320px;margin-left:auto;margin-right:auto;">
<div style="display:flex;align-items:center;gap:12px;"><span style="color:#00D48B;"></span> Контент-стратегию на 3 месяца</div>
<div style="display:flex;align-items:center;gap:12px;"><span style="color:#00D48B;"></span> Шаблоны для видео и постов</div>
<div style="display:flex;align-items:center;gap:12px;"><span style="color:#00D48B;"></span> График с лучшим временем публикаций</div>
<div style="display:flex;align-items:center;gap:12px;"><span style="color:#00D48B;"></span> Скрипты для ответов в Direct</div>
</div>
<div style="text-align:center;">
<button class="kai-cta-btn" style="font-size:16px;padding:16px 36px;" @click="$emit('strategy')">
Создать SMM-стратегию
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,65 @@
<script setup>
import SectionWrapper from './SectionWrapper.vue';
import { computed } from 'vue';
const props = defineProps({ id: String, data: Object });
const fmtNum = (n) => n != null ? Number(n).toLocaleString('ru-RU') : '—';
const fmtPct = (n) => n != null ? (parseFloat(n) * 100).toFixed(0) + '%' : '—';
const stars = (r) => { const n = parseFloat(r)||0; return '★'.repeat(Math.round(n))+'☆'.repeat(5-Math.round(n)); };
const starDist = computed(() => {
const d = props.data?.starDistribution || {};
const max = Math.max(...Object.values(d).map(v => parseFloat(v) || 0), 1);
return [5,4,3,2,1].map(n => ({ star: n, pct: parseFloat(d[String(n)]) || 0, fill: max, color: ['#22C55E','#86EFAC','#F59E0B','#F97316','#EF4444'][5-n] }));
});
</script>
<template>
<SectionWrapper :id="id" title="Репутация ниши" subtitle="Насколько клиенты доверяют бизнесам в вашей сфере" num="06">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;">
<!-- Metrics -->
<div style="display:flex;flex-direction:column;gap:12px;">
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;">
<div v-for="card in [
{ icon:'⭐', val:(data?.avgNicheRating||0).toFixed(1), label:'Ср. рейтинг', sub: stars(data?.avgNicheRating), color:'#F59E0B', tip: 'Средняя оценка конкурентов (например, в 2ГИС, Яндекс)' },
{ icon:'💬', val: fmtNum(data?.medianReviews), label:'Медиана отзывов', sub:'типичное кол-во у бизнеса', color:'#2D7BFF', tip: 'Количество отзывов, которое есть у типичного (среднего) игрока на рынке' },
{ icon:'🛡', val: fmtPct(data?.highTrustBusinessShare), label:'Высокое доверие', sub:'бизнесов с рейтингом >4.3', color:'#00D48B', tip: 'Процент компаний, которые имеют высокий уровень доверия клиентов' },
]" :key="card.label" style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:12px;padding:14px;text-align:center;">
<div style="font-size:22px;margin-bottom:6px;">{{ card.icon }}</div>
<div style="font-family:Unbounded,sans-serif;font-size:22px;font-weight:900;line-height:1;margin-bottom:4px;" :style="{ color: card.color }">{{ card.val }}</div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:rgba(255,255,255,0.4);margin-bottom:2px;">
{{ card.label }}
<i class="pi pi-info-circle kai-term-icon" v-tooltip.top="card.tip"></i>
</div>
<div style="font-size:11px;color:rgba(255,255,255,0.5);">{{ card.sub }}</div>
</div>
</div>
<!-- Response time -->
<div style="display:flex;align-items:center;gap:14px;padding:14px 16px;background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.06);border-radius:12px;">
<span style="font-size:24px;">⏱</span>
<div>
<div style="font-size:11px;color:rgba(255,255,255,0.4);font-weight:600;text-transform:uppercase;letter-spacing:.05em;">
Ср. время ответа владельца
<i class="pi pi-info-circle kai-term-icon" v-tooltip.top="'Как быстро конкуренты реагируют на новые отзывы клиентов'"></i>
</div>
<div style="font-family:Unbounded,sans-serif;font-size:20px;font-weight:900;color:rgba(255,255,255,0.9);">{{ data?.avgOwnerResponseTimeHours || '—' }} <span style="font-size:12px;font-weight:400;">часов</span></div>
</div>
</div>
</div>
<!-- Star distribution -->
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:rgba(255,255,255,0.4);margin-bottom:14px;">Распределение оценок</div>
<div style="display:flex;flex-direction:column;gap:10px;">
<div v-for="s in starDist" :key="s.star" style="display:flex;align-items:center;gap:10px;">
<span style="font-size:12px;font-weight:700;width:28px;text-align:right;" :style="{ color: s.color }">{{ s.star }}★</span>
<div class="kai-progress-track" style="flex:1;height:10px;">
<div class="kai-progress-fill" :style="{ width: s.pct + '%', background: s.color, maxWidth:'100%' }" />
</div>
<span style="font-size:12px;font-weight:700;width:36px;" :style="{ color: s.color }">{{ s.pct.toFixed(1) }}%</span>
</div>
</div>
</div>
</div>
</SectionWrapper>
</template>
@@ -0,0 +1,26 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
const sections = [
{ id: 's0', label: '0' }, { id: 's1', label: '1' }, { id: 's2', label: '2' },
{ id: 's3', label: '3' }, { id: 's4', label: '4' }, { id: 's5', label: '5' },
{ id: 's6', label: '6' }, { id: 's7', label: '7' }, { id: 's8', label: '8' },
{ id: 's9', label: '9' }, { id: 's10', label: '10' }, { id: 's11', label: '11' },
];
const active = ref('s0');
const go = (id) => document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
let obs;
onMounted(() => {
obs = new IntersectionObserver(entries => {
entries.forEach(e => { if (e.isIntersecting) active.value = e.target.id; });
}, { threshold: 0.3 });
sections.forEach(s => { const el = document.getElementById(s.id); if (el) obs.observe(el); });
});
onUnmounted(() => obs?.disconnect());
</script>
<template>
<nav class="kai-sidenav">
<button v-for="s in sections" :key="s.id" class="kai-sidenav-dot" :class="{ active: active === s.id }"
:title="s.label" @click="go(s.id)" />
</nav>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
// vite.config.mjs
import { fileURLToPath, URL } from "node:url";
import { PrimeVueResolver } from "file:///C:/Users/777/IdeaProjects/marketing/node_modules/@primevue/auto-import-resolver/index.mjs";
import vue from "file:///C:/Users/777/IdeaProjects/marketing/node_modules/@vitejs/plugin-vue/dist/index.mjs";
import Components from "file:///C:/Users/777/IdeaProjects/marketing/node_modules/unplugin-vue-components/dist/vite.js";
import { defineConfig } from "file:///C:/Users/777/IdeaProjects/marketing/node_modules/vite/dist/node/index.js";
var __vite_injected_original_import_meta_url = "file:///C:/Users/777/IdeaProjects/marketing/vite.config.mjs";
var vite_config_default = defineConfig({
optimizeDeps: {
noDiscovery: true
},
plugins: [
vue(),
Components({
resolvers: [PrimeVueResolver()]
})
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", __vite_injected_original_import_meta_url))
}
},
server: {
proxy: {
"/api": {
target: "https://api.konturai.kz",
changeOrigin: true,
secure: true,
configure: (proxy, _options) => {
proxy.on("error", (err, _req, _res) => {
console.log("proxy error", err);
});
proxy.on("proxyReq", (proxyReq, req, _res) => {
console.log("Sending Request to the Target:", req.method, req.url);
});
proxy.on("proxyRes", (proxyRes, req, _res) => {
console.log("Received Response from the Target:", proxyRes.statusCode, req.url);
});
}
}
}
}
});
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcubWpzIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyJjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZGlybmFtZSA9IFwiQzpcXFxcVXNlcnNcXFxcNzc3XFxcXElkZWFQcm9qZWN0c1xcXFxtYXJrZXRpbmdcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIkM6XFxcXFVzZXJzXFxcXDc3N1xcXFxJZGVhUHJvamVjdHNcXFxcbWFya2V0aW5nXFxcXHZpdGUuY29uZmlnLm1qc1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vQzovVXNlcnMvNzc3L0lkZWFQcm9qZWN0cy9tYXJrZXRpbmcvdml0ZS5jb25maWcubWpzXCI7aW1wb3J0IHsgZmlsZVVSTFRvUGF0aCwgVVJMIH0gZnJvbSAnbm9kZTp1cmwnO1xuXG5pbXBvcnQgeyBQcmltZVZ1ZVJlc29sdmVyIH0gZnJvbSAnQHByaW1ldnVlL2F1dG8taW1wb3J0LXJlc29sdmVyJztcbmltcG9ydCB2dWUgZnJvbSAnQHZpdGVqcy9wbHVnaW4tdnVlJztcbmltcG9ydCBDb21wb25lbnRzIGZyb20gJ3VucGx1Z2luLXZ1ZS1jb21wb25lbnRzL3ZpdGUnO1xuaW1wb3J0IHsgZGVmaW5lQ29uZmlnIH0gZnJvbSAndml0ZSc7XG5cbi8vIGh0dHBzOi8vdml0ZWpzLmRldi9jb25maWcvXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICAgIG9wdGltaXplRGVwczoge1xuICAgICAgICBub0Rpc2NvdmVyeTogdHJ1ZVxuICAgIH0sXG4gICAgcGx1Z2luczogW1xuICAgICAgICB2dWUoKSxcbiAgICAgICAgQ29tcG9uZW50cyh7XG4gICAgICAgICAgICByZXNvbHZlcnM6IFtQcmltZVZ1ZVJlc29sdmVyKCldXG4gICAgICAgIH0pXG4gICAgXSxcbiAgICByZXNvbHZlOiB7XG4gICAgICAgIGFsaWFzOiB7XG4gICAgICAgICAgICAnQCc6IGZpbGVVUkxUb1BhdGgobmV3IFVSTCgnLi9zcmMnLCBpbXBvcnQubWV0YS51cmwpKVxuICAgICAgICB9XG4gICAgfSxcbiAgICBzZXJ2ZXI6IHtcbiAgICAgICAgcHJveHk6IHtcbiAgICAgICAgICAgICcvYXBpJzoge1xuICAgICAgICAgICAgICAgIHRhcmdldDogJ2h0dHBzOi8vYXBpLmtvbnR1cmFpLmt6JyxcbiAgICAgICAgICAgICAgICBjaGFuZ2VPcmlnaW46IHRydWUsXG4gICAgICAgICAgICAgICAgc2VjdXJlOiB0cnVlLFxuICAgICAgICAgICAgICAgIGNvbmZpZ3VyZTogKHByb3h5LCBfb3B0aW9ucykgPT4ge1xuICAgICAgICAgICAgICAgICAgICBwcm94eS5vbignZXJyb3InLCAoZXJyLCBfcmVxLCBfcmVzKSA9PiB7XG4gICAgICAgICAgICAgICAgICAgICAgICBjb25zb2xlLmxvZygncHJveHkgZXJyb3InLCBlcnIpO1xuICAgICAgICAgICAgICAgICAgICB9KTtcbiAgICAgICAgICAgICAgICAgICAgcHJveHkub24oJ3Byb3h5UmVxJywgKHByb3h5UmVxLCByZXEsIF9yZXMpID0+IHtcbiAgICAgICAgICAgICAgICAgICAgICAgIGNvbnNvbGUubG9nKCdTZW5kaW5nIFJlcXVlc3QgdG8gdGhlIFRhcmdldDonLCByZXEubWV0aG9kLCByZXEudXJsKTtcbiAgICAgICAgICAgICAgICAgICAgfSk7XG4gICAgICAgICAgICAgICAgICAgIHByb3h5Lm9uKCdwcm94eVJlcycsIChwcm94eVJlcywgcmVxLCBfcmVzKSA9PiB7XG4gICAgICAgICAgICAgICAgICAgICAgICBjb25zb2xlLmxvZygnUmVjZWl2ZWQgUmVzcG9uc2UgZnJvbSB0aGUgVGFyZ2V0OicsIHByb3h5UmVzLnN0YXR1c0NvZGUsIHJlcS51cmwpO1xuICAgICAgICAgICAgICAgICAgICB9KTtcbiAgICAgICAgICAgICAgICB9XG4gICAgICAgICAgICB9XG4gICAgICAgIH1cbiAgICB9XG59KTtcbiJdLAogICJtYXBwaW5ncyI6ICI7QUFBdVMsU0FBUyxlQUFlLFdBQVc7QUFFMVUsU0FBUyx3QkFBd0I7QUFDakMsT0FBTyxTQUFTO0FBQ2hCLE9BQU8sZ0JBQWdCO0FBQ3ZCLFNBQVMsb0JBQW9CO0FBTDJKLElBQU0sMkNBQTJDO0FBUXpPLElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQ3hCLGNBQWM7QUFBQSxJQUNWLGFBQWE7QUFBQSxFQUNqQjtBQUFBLEVBQ0EsU0FBUztBQUFBLElBQ0wsSUFBSTtBQUFBLElBQ0osV0FBVztBQUFBLE1BQ1AsV0FBVyxDQUFDLGlCQUFpQixDQUFDO0FBQUEsSUFDbEMsQ0FBQztBQUFBLEVBQ0w7QUFBQSxFQUNBLFNBQVM7QUFBQSxJQUNMLE9BQU87QUFBQSxNQUNILEtBQUssY0FBYyxJQUFJLElBQUksU0FBUyx3Q0FBZSxDQUFDO0FBQUEsSUFDeEQ7QUFBQSxFQUNKO0FBQUEsRUFDQSxRQUFRO0FBQUEsSUFDSixPQUFPO0FBQUEsTUFDSCxRQUFRO0FBQUEsUUFDSixRQUFRO0FBQUEsUUFDUixjQUFjO0FBQUEsUUFDZCxRQUFRO0FBQUEsUUFDUixXQUFXLENBQUMsT0FBTyxhQUFhO0FBQzVCLGdCQUFNLEdBQUcsU0FBUyxDQUFDLEtBQUssTUFBTSxTQUFTO0FBQ25DLG9CQUFRLElBQUksZUFBZSxHQUFHO0FBQUEsVUFDbEMsQ0FBQztBQUNELGdCQUFNLEdBQUcsWUFBWSxDQUFDLFVBQVUsS0FBSyxTQUFTO0FBQzFDLG9CQUFRLElBQUksa0NBQWtDLElBQUksUUFBUSxJQUFJLEdBQUc7QUFBQSxVQUNyRSxDQUFDO0FBQ0QsZ0JBQU0sR0FBRyxZQUFZLENBQUMsVUFBVSxLQUFLLFNBQVM7QUFDMUMsb0JBQVEsSUFBSSxzQ0FBc0MsU0FBUyxZQUFZLElBQUksR0FBRztBQUFBLFVBQ2xGLENBQUM7QUFBQSxRQUNMO0FBQUEsTUFDSjtBQUFBLElBQ0o7QUFBQSxFQUNKO0FBQ0osQ0FBQzsiLAogICJuYW1lcyI6IFtdCn0K