3485 lines
160 KiB
Vue
3485 lines
160 KiB
Vue
<template>
|
||
<div class="marketing-analysis-page bg-surface-50 dark:bg-surface-900 min-h-screen p-6">
|
||
<Toast />
|
||
<div class="max-w-[1200px] mx-auto">
|
||
<!-- Форма запуска анализа -->
|
||
<div v-if="!analysisId" class="card">
|
||
<div class="card-header mb-4">
|
||
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Запуск анализа</h2>
|
||
</div>
|
||
<div class="card-content">
|
||
<form @submit.prevent="handleSubmit" class="space-y-4 max-w-2xl">
|
||
<div class="field">
|
||
<label for="businessNiche" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Ниша бизнеса * </label>
|
||
<InputText id="businessNiche" v-model="formData.businessNiche" placeholder="Например: E-commerce платформы" class="w-full" :class="{ 'p-invalid': errors.businessNiche }" />
|
||
<small v-if="errors.businessNiche" class="p-error">{{ errors.businessNiche }}</small>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="product" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Что вы продаете? * </label>
|
||
<InputText id="product" v-model="formData.product" placeholder="Введите название продукта или услуги" class="w-full" :class="{ 'p-invalid': errors.product }" />
|
||
<small v-if="errors.product" class="p-error">{{ errors.product }}</small>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Целевая аудитория * </label>
|
||
|
||
<!-- Гендер -->
|
||
<div class="mb-3">
|
||
<label class="block text-sm font-medium mb-2 text-surface-600 dark:text-surface-400">Гендер:</label>
|
||
<div class="flex gap-4 flex-wrap">
|
||
<div v-for="gender in VALID_GENDERS" :key="gender" class="flex align-items-center">
|
||
<Checkbox :inputId="`gender-${gender}`" :value="gender" v-model="formData.targetAudience.genders" />
|
||
<label :for="`gender-${gender}`" class="ml-2 cursor-pointer">{{ gender }}</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Возраст -->
|
||
<div class="mb-3">
|
||
<label class="block text-sm font-medium mb-2 text-surface-600 dark:text-surface-400">Возраст:</label>
|
||
<div class="flex gap-4 flex-wrap">
|
||
<div v-for="age in VALID_AGE_RANGES" :key="age" class="flex align-items-center">
|
||
<Checkbox :inputId="`age-${age}`" :value="age" v-model="formData.targetAudience.ageRanges" />
|
||
<label :for="`age-${age}`" class="ml-2 cursor-pointer">{{ age }}</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Тип -->
|
||
<div class="mb-3">
|
||
<label class="block text-sm font-medium mb-2 text-surface-600 dark:text-surface-400">Тип:</label>
|
||
<div class="flex gap-4 flex-wrap">
|
||
<div v-for="type in VALID_AUDIENCE_TYPES" :key="type" class="flex align-items-center">
|
||
<Checkbox :inputId="`type-${type}`" :value="type" v-model="formData.targetAudience.types" />
|
||
<label :for="`type-${type}`" class="ml-2 cursor-pointer">{{ type }}</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Отображение выбранных значений -->
|
||
<div v-if="formData.targetAudience.genders.length > 0 || formData.targetAudience.ageRanges.length > 0 || formData.targetAudience.types.length > 0" class="mt-2 p-2 border-round bg-surface-100 dark:bg-surface-800">
|
||
<small class="text-surface-600 dark:text-surface-400">
|
||
<strong>Выбрано:</strong>
|
||
<span v-if="formData.targetAudience.genders.length > 0"> Гендер: {{ formData.targetAudience.genders.join(', ') }}</span>
|
||
<span v-if="formData.targetAudience.ageRanges.length > 0"> Возраст: {{ formData.targetAudience.ageRanges.join(', ') }}</span>
|
||
<span v-if="formData.targetAudience.types.length > 0"> Тип: {{ formData.targetAudience.types.join(', ') }}</span>
|
||
</small>
|
||
</div>
|
||
|
||
<small v-if="errors.targetAudience" class="p-error">{{ errors.targetAudience }}</small>
|
||
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Выберите хотя бы одну опцию для целевой аудитории</small>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="region" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Регион (город Казахстана) * </label>
|
||
<MultiSelect id="region" v-model="formData.region" :options="VALID_REGIONS" placeholder="Выберите регионы" class="w-full" display="chip" :class="{ 'p-invalid': errors.region }" />
|
||
<small v-if="errors.region" class="p-error">{{ errors.region }}</small>
|
||
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Выберите один или несколько регионов</small>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="goal" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Цель на 6-12 месяцев * </label>
|
||
<Textarea id="goal" v-model="formData.goal" placeholder="Опишите вашу цель на ближайшие 6-12 месяцев" rows="3" class="w-full" :class="{ 'p-invalid': errors.goal }" />
|
||
<small v-if="errors.goal" class="p-error">{{ errors.goal }}</small>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Уровень детализации анализа * </label>
|
||
<div class="flex gap-4">
|
||
<div v-for="level in DETAIL_LEVELS" :key="level" class="flex align-items-center">
|
||
<RadioButton :inputId="`detailLevel-${level}`" v-model="formData.detailLevel" :value="level" :class="{ 'p-invalid': errors.detailLevel }" />
|
||
<label :for="`detailLevel-${level}`" class="ml-2 cursor-pointer">
|
||
{{ level === 'КРАТКО' ? 'Кратко' : level === 'СТАНДАРТНО' ? 'Стандартно' : 'Подробно' }}
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<small v-if="errors.detailLevel" class="p-error">{{ errors.detailLevel }}</small>
|
||
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Выберите уровень детализации анализа</small>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="strongSide" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Сильная сторона бизнеса </label>
|
||
<Textarea id="strongSide" v-model="formData.strongSide" placeholder="Опишите сильные стороны вашего бизнеса (опционально)" rows="2" class="w-full" :class="{ 'p-invalid': errors.strongSide }" />
|
||
<small v-if="errors.strongSide" class="p-error">{{ errors.strongSide }}</small>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="weakSide" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Слабая сторона бизнеса </label>
|
||
<Textarea id="weakSide" v-model="formData.weakSide" placeholder="Опишите слабые стороны вашего бизнеса (опционально)" rows="2" class="w-full" :class="{ 'p-invalid': errors.weakSide }" />
|
||
<small v-if="errors.weakSide" class="p-error">{{ errors.weakSide }}</small>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="analysisType" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Тип анализа * </label>
|
||
<MultiSelect id="analysisType" v-model="formData.analysisType" :options="VALID_ANALYSIS_TYPES" placeholder="Выберите типы анализа" class="w-full" display="chip" :class="{ 'p-invalid': errors.analysisType }">
|
||
<template #value="slotProps">
|
||
<div v-if="slotProps.value && slotProps.value.length > 0" class="flex flex-wrap gap-2">
|
||
<Tag v-for="type in slotProps.value" :key="type" :value="getAnalysisTypeLabel(type)" severity="info" />
|
||
</div>
|
||
<span v-else class="text-surface-400">{{ slotProps.placeholder }}</span>
|
||
</template>
|
||
<template #option="slotProps">
|
||
<div class="flex align-items-center">
|
||
<span>{{ getAnalysisTypeLabel(slotProps.option) }}</span>
|
||
</div>
|
||
</template>
|
||
</MultiSelect>
|
||
<small v-if="errors.analysisType" class="p-error">{{ errors.analysisType }}</small>
|
||
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Выберите один или несколько типов анализа. При выборе нескольких типов будут созданы отдельные анализы для каждого типа.</small>
|
||
</div>
|
||
|
||
<Button type="submit" label="Начать анализ" icon="pi pi-send" class="w-full p-button-primary" :loading="submitting" />
|
||
|
||
<p class="text-sm text-surface-500 dark:text-surface-400 mt-3 text-center">На основе ваших ответов будет создан подробный и полезный маркетинговый отчет</p>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Статус обработки -->
|
||
<div v-else-if="status === 'processing' || status === 'queued'" class="card">
|
||
<div class="card-header mb-4">
|
||
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Анализ выполняется</h2>
|
||
</div>
|
||
<div class="card-content">
|
||
<div class="text-center py-6">
|
||
<ProgressSpinner size="50" />
|
||
<p class="mt-4 text-lg text-surface-700 dark:text-surface-300">Анализ обрабатывается...</p>
|
||
<p class="mt-2 text-sm text-surface-500 dark:text-surface-400">Это может занять 5-10 минут. Пожалуйста, подождите.</p>
|
||
<p v-if="estimatedCompletionTime" class="mt-2 text-sm text-surface-500 dark:text-surface-400">Ожидаемое время завершения: {{ formatDateTime(estimatedCompletionTime) }}</p>
|
||
<Button label="Отменить" icon="pi pi-times" severity="secondary" class="mt-4" @click="resetAnalysis" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Ошибка -->
|
||
<div v-else-if="status === 'failed'" class="card">
|
||
<div class="card-header mb-4">
|
||
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Ошибка анализа</h2>
|
||
</div>
|
||
<div class="card-content">
|
||
<div class="text-center py-6">
|
||
<i class="pi pi-exclamation-triangle text-6xl text-red-500 mb-3"></i>
|
||
<p class="mt-4 text-lg text-surface-700 dark:text-surface-300">Анализ завершился с ошибкой</p>
|
||
<p class="mt-2 text-sm text-surface-500 dark:text-surface-400">Попробуйте запустить анализ снова или обратитесь в поддержку.</p>
|
||
<Button label="Попробовать снова" icon="pi pi-refresh" class="mt-4" @click="resetAnalysis" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Результаты анализа -->
|
||
<div v-else-if="status === 'completed' && report" class="space-y-4">
|
||
<!-- Заголовок с кнопкой скачивания -->
|
||
<div class="card">
|
||
<div class="card-header mb-4">
|
||
<div class="flex justify-content-between align-items-center">
|
||
<div>
|
||
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Результаты анализа</h2>
|
||
<div v-if="currentAnalysisType" class="mt-2">
|
||
<Tag :value="getAnalysisTypeLabel(currentAnalysisType)" severity="info" />
|
||
</div>
|
||
</div>
|
||
<Button label="Скачать PDF" icon="pi pi-download" @click="handleDownloadPdf" :loading="downloadingPdf" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Краткое резюме (Summary) -->
|
||
<div v-if="report.summary" class="card">
|
||
<div class="card-header mb-4">
|
||
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0"><i class="pi pi-file-edit mr-2"></i>Краткое резюме</h3>
|
||
</div>
|
||
<div class="card-content">
|
||
<div class="markdown-content" v-html="renderMarkdown(report.summary)"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Динамическое содержимое отчета (в одном контейнере) -->
|
||
<div v-if="parsedContent.length" class="card">
|
||
<div class="card-content">
|
||
<div class="space-y-6">
|
||
<div v-for="(segment, index) in displayContent" :key="index">
|
||
<!-- Two-column pair: paragraph text + adjacent json-chart/table -->
|
||
<div
|
||
v-if="segment.type === 'paired'"
|
||
class="report-pair"
|
||
:class="{
|
||
'report-pair--mobile-chart-first': segment.mobileOrder === 'chart-first',
|
||
'report-pair--mobile-text-first': segment.mobileOrder === 'text-first'
|
||
}"
|
||
>
|
||
<!-- IMPORTANT: media first so text can wrap around float -->
|
||
<div class="report-pair__media">
|
||
<div v-if="segment.media.type === 'chart'">
|
||
<div class="mb-2" v-if="segment.media.title">
|
||
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">{{ segment.media.title }}</h3>
|
||
</div>
|
||
<div class="report-pair__mediaChart">
|
||
<div class="chart-container report-pair__chart-container">
|
||
<Chart :type="segment.media.chartType" :data="segment.media.data" :options="toCompactChartOptions(segment.media.options)" />
|
||
</div>
|
||
</div>
|
||
<div v-if="segment.media.key === 'channelsPotential' && segment.media.channels?.length" class="mt-4 space-y-2">
|
||
<div v-for="channel in segment.media.channels" :key="channel.name" class="p-3 border-round bg-surface-50 dark:bg-surface-800">
|
||
<div class="flex justify-content-between align-items-center mb-2">
|
||
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ channel.name }}</span>
|
||
<Tag :value="`Потенциал: ${channel.potential}`" :severity="channel.potential >= 80 ? 'success' : channel.potential >= 60 ? 'warning' : 'info'" />
|
||
</div>
|
||
<p class="text-sm text-surface-600 dark:text-surface-400">{{ channel.justification }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="segment.media.type === 'marketShareChart'">
|
||
<div class="mb-2" v-if="segment.media.title">
|
||
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0"><i class="pi pi-chart-pie mr-2"></i>{{ segment.media.title }}</h3>
|
||
</div>
|
||
<div class="report-pair__mediaChart">
|
||
<div class="chart-container report-pair__chart-container">
|
||
<Chart :type="segment.media.chartType" :data="segment.media.data" :options="toCompactChartOptions(segment.media.options)" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="segment.media.type === 'comparisonTable'">
|
||
<div class="mb-4">
|
||
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0"><i class="pi pi-table mr-2"></i>Сравнительная таблица</h3>
|
||
</div>
|
||
<div class="overflow-x-auto border-round" style="border: 1px solid var(--surface-border)">
|
||
<table class="comparison-table w-full border-collapse">
|
||
<thead>
|
||
<tr>
|
||
<th class="comparison-table-th">Характеристика</th>
|
||
<th class="comparison-table-th text-center">Мы</th>
|
||
<th class="comparison-table-th text-center">Конкурент A</th>
|
||
<th class="comparison-table-th text-center">Конкурент B</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(row, idx) in segment.media.data" :key="idx" class="comparison-table-tr">
|
||
<td class="comparison-table-td font-semibold">{{ row.feature }}</td>
|
||
<td class="comparison-table-td text-center">
|
||
<Tag :value="row.us" severity="info" />
|
||
</td>
|
||
<td class="comparison-table-td text-center">
|
||
<Tag :value="row.compA" :severity="row.compA === 'Высокая' || row.compA === 'Премиум' ? 'danger' : row.compA === 'Низкая' ? 'success' : 'warning'" />
|
||
</td>
|
||
<td class="comparison-table-td text-center">
|
||
<Tag :value="row.compB" :severity="row.compB === 'Высокая' || row.compB === 'Премиум' ? 'danger' : row.compB === 'Низкая' ? 'success' : 'warning'" />
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="report-pair__text">
|
||
<div class="markdown-content" v-html="segment.text.html"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="segment.type === 'markdown'">
|
||
<div class="markdown-content" v-html="segment.html"></div>
|
||
</div>
|
||
|
||
<div v-else-if="segment.type === 'chart'">
|
||
<div class="mb-4" v-if="segment.title">
|
||
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">{{ segment.title }}</h3>
|
||
</div>
|
||
<div class="chart-container" :style="segment.chartHeight">
|
||
<Chart :type="segment.chartType" :data="segment.data" :options="segment.options" />
|
||
</div>
|
||
<div v-if="segment.key === 'channelsPotential' && segment.channels?.length" class="mt-4 space-y-2">
|
||
<div v-for="channel in segment.channels" :key="channel.name" class="p-3 border-round bg-surface-50 dark:bg-surface-800">
|
||
<div class="flex justify-content-between align-items-center mb-2">
|
||
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ channel.name }}</span>
|
||
<Tag :value="`Потенциал: ${channel.potential}`" :severity="channel.potential >= 80 ? 'success' : channel.potential >= 60 ? 'warning' : 'info'" />
|
||
</div>
|
||
<p class="text-sm text-surface-600 dark:text-surface-400">{{ channel.justification }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="segment.type === 'audienceSegmentation'">
|
||
<div class="mb-4">
|
||
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0"><i class="pi pi-users mr-2"></i>Сегментация целевой аудитории</h3>
|
||
</div>
|
||
<div class="grid">
|
||
<div v-if="segment.ageGroupsChartData" class="col-12 md:col-6 mb-4">
|
||
<div class="flex align-items-center mb-3">
|
||
<i class="pi pi-calendar text-primary-500 mr-2"></i>
|
||
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">По возрасту</h4>
|
||
</div>
|
||
<div class="chart-container" style="height: 300px">
|
||
<Chart type="pie" :data="segment.ageGroupsChartData" :options="pieChartOptions" />
|
||
</div>
|
||
</div>
|
||
<div v-if="segment.gendersChartData" class="col-12 md:col-6 mb-4">
|
||
<div class="flex align-items-center mb-3">
|
||
<i class="pi pi-user text-primary-500 mr-2"></i>
|
||
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">По полу</h4>
|
||
</div>
|
||
<div class="chart-container" style="height: 300px">
|
||
<Chart type="pie" :data="segment.gendersChartData" :options="pieChartOptions" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Segments Channel Matrix (Heatmap) -->
|
||
<div v-if="segment.segmentsChannelMatrix" class="mt-6">
|
||
<div class="flex align-items-center mb-3">
|
||
<i class="pi pi-th-large text-primary-500 mr-2"></i>
|
||
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">Матрица каналов по сегментам</h4>
|
||
</div>
|
||
<div class="overflow-x-auto border-round" style="border: 1px solid var(--surface-border)">
|
||
<table class="channel-matrix-table w-full border-collapse">
|
||
<thead>
|
||
<tr>
|
||
<th class="channel-matrix-th">Сегмент</th>
|
||
<th v-for="channel in segment.segmentsChannelMatrix.channels" :key="channel" class="channel-matrix-th text-center">
|
||
{{ channel }}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(row, idx) in segment.segmentsChannelMatrix.data" :key="idx" class="channel-matrix-tr">
|
||
<td class="channel-matrix-td font-semibold">{{ row.segmentName }}</td>
|
||
<td v-for="channel in segment.segmentsChannelMatrix.channels" :key="channel" class="channel-matrix-td text-center">
|
||
<span class="channel-matrix-badge" :style="{ backgroundColor: getChannelMatrixColor(row[channel]) }">
|
||
{{ row[channel] || '-' }}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Key Takeaways -->
|
||
<div v-if="segment.keyTakeaways && segment.keyTakeaways.length > 0" class="mt-6">
|
||
<div class="flex align-items-center mb-3">
|
||
<i class="pi pi-lightbulb text-primary-500 mr-2"></i>
|
||
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">Ключевые выводы</h4>
|
||
</div>
|
||
<div class="p-4 border-round bg-primary-50 dark:bg-primary-900 border-1 border-primary-200 dark:border-primary-700">
|
||
<ul class="list-none p-0 m-0">
|
||
<li v-for="(takeaway, idx) in segment.keyTakeaways" :key="idx" class="flex align-items-start mb-3">
|
||
<i class="pi pi-check-circle text-primary-500 mr-2 mt-1 flex-shrink-0"></i>
|
||
<span class="text-surface-700 dark:text-surface-300">{{ takeaway }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="segment.type === 'competitors'">
|
||
<div class="mb-4">
|
||
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0"><i class="pi pi-chart-line mr-2"></i>Анализ конкурентов</h3>
|
||
</div>
|
||
<!-- Market Share Chart (if included in competitors segment) -->
|
||
<div v-if="segment.marketShareChart" class="mb-6">
|
||
<div class="flex align-items-center mb-3">
|
||
<i class="pi pi-chart-pie text-primary-500 mr-2"></i>
|
||
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">{{ segment.marketShareChart.title }}</h4>
|
||
</div>
|
||
<div class="chart-container" :style="segment.marketShareChart.chartHeight">
|
||
<Chart :type="segment.marketShareChart.chartType" :data="segment.marketShareChart.data" :options="segment.marketShareChart.options" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Comparison Table (if included in competitors segment) -->
|
||
<div v-if="segment.comparisonTable" class="mb-6">
|
||
<div class="flex align-items-center mb-3">
|
||
<i class="pi pi-table text-primary-500 mr-2"></i>
|
||
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">Сравнительная таблица</h4>
|
||
</div>
|
||
<div class="overflow-x-auto border-round" style="border: 1px solid var(--surface-border)">
|
||
<table class="comparison-table w-full border-collapse">
|
||
<thead>
|
||
<tr>
|
||
<th class="comparison-table-th">Характеристика</th>
|
||
<th class="comparison-table-th text-center">Мы</th>
|
||
<th class="comparison-table-th text-center">Конкурент A</th>
|
||
<th class="comparison-table-th text-center">Конкурент B</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(row, idx) in segment.comparisonTable.data" :key="idx" class="comparison-table-tr">
|
||
<td class="comparison-table-td font-semibold">{{ row.feature }}</td>
|
||
<td class="comparison-table-td text-center">
|
||
<Tag :value="row.us" severity="info" />
|
||
</td>
|
||
<td class="comparison-table-td text-center">
|
||
<Tag :value="row.compA" :severity="row.compA === 'Высокая' || row.compA === 'Премиум' ? 'danger' : row.compA === 'Низкая' ? 'success' : 'warning'" />
|
||
</td>
|
||
<td class="comparison-table-td text-center">
|
||
<Tag :value="row.compB" :severity="row.compB === 'Высокая' || row.compB === 'Премиум' ? 'danger' : row.compB === 'Низкая' ? 'success' : 'warning'" />
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="grid">
|
||
<div v-for="competitor in segment.competitors" :key="competitor.name" class="col-12 md:col-6 lg:col-4 mb-4">
|
||
<div class="p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700 h-full flex flex-column">
|
||
<h4 class="text-lg font-semibold mb-3 text-surface-900 dark:text-surface-0 flex align-items-center"><i class="pi pi-building text-primary-500 mr-2"></i>{{ competitor.name }}</h4>
|
||
<div class="space-y-2 mb-3 flex-grow-1">
|
||
<div class="flex justify-content-between align-items-center">
|
||
<span class="text-surface-600 dark:text-surface-400 flex align-items-center"> <i class="pi pi-eye mr-1"></i>Охват: </span>
|
||
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ competitor.reach?.toLocaleString() || 0 }}</span>
|
||
</div>
|
||
<div v-if="competitor.followers" class="flex justify-content-between align-items-center">
|
||
<span class="text-surface-600 dark:text-surface-400 flex align-items-center"> <i class="pi pi-users mr-1"></i>Подписчики: </span>
|
||
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ competitor.followers?.toLocaleString() || 0 }}</span>
|
||
</div>
|
||
<div class="flex justify-content-between align-items-center">
|
||
<span class="text-surface-600 dark:text-surface-400 flex align-items-center"> <i class="pi pi-chart-bar mr-1"></i>Активность: </span>
|
||
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ competitor.activity }}%</span>
|
||
</div>
|
||
<div v-if="competitor.priceStrategy" class="flex justify-content-between align-items-center">
|
||
<span class="text-surface-600 dark:text-surface-400 flex align-items-center"> <i class="pi pi-tag mr-1"></i>Ценовая стратегия: </span>
|
||
<Tag
|
||
:value="competitor.priceStrategy"
|
||
:severity="competitor.priceStrategy === 'Высокая' || competitor.priceStrategy === 'Премиум' ? 'danger' : competitor.priceStrategy === 'Низкая' ? 'success' : 'warning'"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div v-if="competitor.strengths?.length" class="mb-3">
|
||
<p class="text-sm font-semibold mb-2 text-green-600 dark:text-green-400 flex align-items-center"><i class="pi pi-arrow-up mr-1"></i>Сильные стороны:</p>
|
||
<ul class="list-none p-0 m-0">
|
||
<li v-for="(strength, idx) in competitor.strengths" :key="idx" class="text-sm text-surface-700 dark:text-surface-300 mb-1 flex align-items-start">
|
||
<i class="pi pi-check-circle text-green-500 mr-2 mt-1 flex-shrink-0"></i>
|
||
<span>{{ strength }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
<div v-if="competitor.weaknesses?.length">
|
||
<p class="text-sm font-semibold mb-2 text-red-600 dark:text-red-400 flex align-items-center"><i class="pi pi-arrow-down mr-1"></i>Слабые стороны:</p>
|
||
<ul class="list-none p-0 m-0">
|
||
<li v-for="(weakness, idx) in competitor.weaknesses" :key="idx" class="text-sm text-surface-700 dark:text-surface-300 mb-1 flex align-items-start">
|
||
<i class="pi pi-times-circle text-red-500 mr-2 mt-1 flex-shrink-0"></i>
|
||
<span>{{ weakness }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Competitors Heatmap -->
|
||
<div v-if="segment.competitorsHeatmap" class="mt-6">
|
||
<div class="flex align-items-center mb-3">
|
||
<i class="pi pi-map text-primary-500 mr-2"></i>
|
||
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">Тепловая карта каналов конкурентов</h4>
|
||
</div>
|
||
<div class="overflow-x-auto border-round" style="border: 1px solid var(--surface-border)">
|
||
<table class="competitors-heatmap-table w-full border-collapse">
|
||
<thead>
|
||
<tr>
|
||
<th class="competitors-heatmap-th">Конкурент</th>
|
||
<th v-for="channel in segment.competitorsHeatmap.channels" :key="channel" class="competitors-heatmap-th text-center">
|
||
{{ channel }}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(compData, idx) in segment.competitorsHeatmap.data" :key="idx" class="competitors-heatmap-tr">
|
||
<td class="competitors-heatmap-td font-semibold">{{ compData.name }}</td>
|
||
<td v-for="channel in segment.competitorsHeatmap.channels" :key="channel" class="competitors-heatmap-td text-center">
|
||
<span class="competitors-heatmap-badge" :style="{ backgroundColor: getHeatmapColor(compData.channelsHeatmap[channel] || 0) }">
|
||
{{ compData.channelsHeatmap[channel] || 0 }}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Market Share Chart -->
|
||
<div v-else-if="segment.type === 'marketShareChart'">
|
||
<div class="mb-4" v-if="segment.title">
|
||
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0"><i class="pi pi-chart-pie mr-2"></i>{{ segment.title }}</h3>
|
||
</div>
|
||
<div class="chart-container" :style="segment.chartHeight">
|
||
<Chart :type="segment.chartType" :data="segment.data" :options="segment.options" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Comparison Table -->
|
||
<div v-else-if="segment.type === 'comparisonTable'">
|
||
<div class="mb-4">
|
||
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0"><i class="pi pi-table mr-2"></i>Сравнительная таблица</h3>
|
||
</div>
|
||
<div class="overflow-x-auto border-round" style="border: 1px solid var(--surface-border)">
|
||
<table class="comparison-table w-full border-collapse">
|
||
<thead>
|
||
<tr>
|
||
<th class="comparison-table-th">Характеристика</th>
|
||
<th class="comparison-table-th text-center">Мы</th>
|
||
<th class="comparison-table-th text-center">Конкурент A</th>
|
||
<th class="comparison-table-th text-center">Конкурент B</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(row, idx) in segment.data" :key="idx" class="comparison-table-tr">
|
||
<td class="comparison-table-td font-semibold">{{ row.feature }}</td>
|
||
<td class="comparison-table-td text-center">
|
||
<Tag :value="row.us" severity="info" />
|
||
</td>
|
||
<td class="comparison-table-td text-center">
|
||
<Tag :value="row.compA" :severity="row.compA === 'Высокая' || row.compA === 'Премиум' ? 'danger' : row.compA === 'Низкая' ? 'success' : 'warning'" />
|
||
</td>
|
||
<td class="comparison-table-td text-center">
|
||
<Tag :value="row.compB" :severity="row.compB === 'Высокая' || row.compB === 'Премиум' ? 'danger' : row.compB === 'Низкая' ? 'success' : 'warning'" />
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SWOT Competitors -->
|
||
<div v-else-if="segment.type === 'swotCompetitors'">
|
||
<div class="mb-4">
|
||
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0"><i class="pi pi-sitemap mr-2"></i>SWOT-анализ конкурентов</h3>
|
||
</div>
|
||
<div class="grid">
|
||
<div class="col-12 md:col-6 mb-3">
|
||
<div class="p-4 border-round bg-green-50 dark:bg-green-900 border-1 border-green-200 dark:border-green-700 h-full">
|
||
<h4 class="text-lg font-semibold mb-3 text-green-700 dark:text-green-300 flex align-items-center"><i class="pi pi-arrow-up mr-2"></i>Сильные стороны</h4>
|
||
<ul class="list-none p-0 m-0">
|
||
<li v-for="(strength, idx) in segment.data.strengths" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2 flex align-items-start">
|
||
<i class="pi pi-check-circle text-green-500 mr-2 mt-1 flex-shrink-0"></i>
|
||
<span>{{ strength }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<div class="col-12 md:col-6 mb-3">
|
||
<div class="p-4 border-round bg-red-50 dark:bg-red-900 border-1 border-red-200 dark:border-red-700 h-full">
|
||
<h4 class="text-lg font-semibold mb-3 text-red-700 dark:text-red-300 flex align-items-center"><i class="pi pi-arrow-down mr-2"></i>Слабые стороны</h4>
|
||
<ul class="list-none p-0 m-0">
|
||
<li v-for="(weakness, idx) in segment.data.weaknesses" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2 flex align-items-start">
|
||
<i class="pi pi-times-circle text-red-500 mr-2 mt-1 flex-shrink-0"></i>
|
||
<span>{{ weakness }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Market -->
|
||
<div v-else-if="segment.type === 'market'">
|
||
<div class="mb-4">
|
||
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0"><i class="pi pi-globe mr-2"></i>Анализ рынка</h3>
|
||
</div>
|
||
<div class="grid mb-4">
|
||
<div class="col-12 md:col-6 mb-3">
|
||
<div class="p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700">
|
||
<div class="flex align-items-center mb-2">
|
||
<i class="pi pi-chart-bar text-primary-500 mr-2"></i>
|
||
<h4 class="text-sm font-semibold text-surface-600 dark:text-surface-400 m-0">Размер рынка</h4>
|
||
</div>
|
||
<p class="text-lg font-bold text-surface-900 dark:text-surface-0 mt-2">{{ segment.data.size || 'Не указано' }}</p>
|
||
</div>
|
||
</div>
|
||
<div class="col-12 md:col-6 mb-3">
|
||
<div class="p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700">
|
||
<div class="flex align-items-center mb-2">
|
||
<i class="pi pi-arrow-up-right text-primary-500 mr-2"></i>
|
||
<h4 class="text-sm font-semibold text-surface-600 dark:text-surface-400 m-0">Темп роста</h4>
|
||
</div>
|
||
<p class="text-lg font-bold text-surface-900 dark:text-surface-0 mt-2">{{ segment.data.growthRate || 'Не указано' }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="segment.data.trends && segment.data.trends.length > 0" class="mb-4">
|
||
<div class="flex align-items-center mb-3">
|
||
<i class="pi pi-trending-up text-primary-500 mr-2"></i>
|
||
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">Тренды</h4>
|
||
</div>
|
||
<div class="p-4 border-round bg-blue-50 dark:bg-blue-900 border-1 border-blue-200 dark:border-blue-700">
|
||
<ul class="list-none p-0 m-0">
|
||
<li v-for="(trend, idx) in segment.data.trends" :key="idx" class="flex align-items-start mb-2">
|
||
<i class="pi pi-arrow-up-right text-blue-500 mr-2 mt-1 flex-shrink-0"></i>
|
||
<span class="text-surface-700 dark:text-surface-300">{{ trend }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid">
|
||
<div v-if="segment.data.opportunities && segment.data.opportunities.length > 0" class="col-12 md:col-6 mb-3">
|
||
<div class="p-4 border-round bg-blue-50 dark:bg-blue-900 border-1 border-blue-200 dark:border-blue-700">
|
||
<h4 class="text-lg font-semibold mb-3 text-blue-700 dark:text-blue-300">Возможности</h4>
|
||
<ul class="list-none p-0 m-0">
|
||
<li v-for="(opportunity, idx) in segment.data.opportunities" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2">
|
||
<i class="pi pi-arrow-up-circle text-blue-500 mr-2"></i>{{ opportunity }}
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<div v-if="segment.data.threats && segment.data.threats.length > 0" class="col-12 md:col-6 mb-3">
|
||
<div class="p-4 border-round bg-orange-50 dark:bg-orange-900 border-1 border-orange-200 dark:border-orange-700">
|
||
<h4 class="text-lg font-semibold mb-3 text-orange-700 dark:text-orange-300">Угрозы</h4>
|
||
<ul class="list-none p-0 m-0">
|
||
<li v-for="(threat, idx) in segment.data.threats" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2"><i class="pi pi-exclamation-triangle text-orange-500 mr-2"></i>{{ threat }}</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="segment.type === 'swot'">
|
||
<div class="mb-4">
|
||
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">SWOT-анализ</h3>
|
||
</div>
|
||
<div class="grid">
|
||
<div class="col-12 md:col-6 mb-3">
|
||
<div class="p-4 border-round bg-green-50 dark:bg-green-900 border-1 border-green-200 dark:border-green-700">
|
||
<h4 class="text-lg font-semibold mb-3 text-green-700 dark:text-green-300">Сильные стороны</h4>
|
||
<ul class="list-none p-0 m-0">
|
||
<li v-for="(strength, idx) in segment.data.strengths" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2"><i class="pi pi-check-circle text-green-500 mr-2"></i>{{ strength }}</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<div class="col-12 md:col-6 mb-3">
|
||
<div class="p-4 border-round bg-red-50 dark:bg-red-900 border-1 border-red-200 dark:border-red-700">
|
||
<h4 class="text-lg font-semibold mb-3 text-red-700 dark:text-red-300">Слабые стороны</h4>
|
||
<ul class="list-none p-0 m-0">
|
||
<li v-for="(weakness, idx) in segment.data.weaknesses" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2"><i class="pi pi-times-circle text-red-500 mr-2"></i>{{ weakness }}</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<div class="col-12 md:col-6 mb-3">
|
||
<div class="p-4 border-round bg-blue-50 dark:bg-blue-900 border-1 border-blue-200 dark:border-blue-700">
|
||
<h4 class="text-lg font-semibold mb-3 text-blue-700 dark:text-blue-300">Возможности</h4>
|
||
<ul class="list-none p-0 m-0">
|
||
<li v-for="(opportunity, idx) in segment.data.opportunities" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2">
|
||
<i class="pi pi-arrow-up-circle text-blue-500 mr-2"></i>{{ opportunity }}
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<div class="col-12 md:col-6 mb-3">
|
||
<div class="p-4 border-round bg-orange-50 dark:bg-orange-900 border-1 border-orange-200 dark:border-orange-700">
|
||
<h4 class="text-lg font-semibold mb-3 text-orange-700 dark:text-orange-300">Угрозы</h4>
|
||
<ul class="list-none p-0 m-0">
|
||
<li v-for="(threat, idx) in segment.data.threats" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2"><i class="pi pi-exclamation-triangle text-orange-500 mr-2"></i>{{ threat }}</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Fallback for unrecognized segment types - don't display raw JSON -->
|
||
<div v-else-if="segment && segment.type">
|
||
<p class="text-surface-600 dark:text-surface-400">Неизвестный тип сегмента: {{ segment.type }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Рекомендации -->
|
||
<div v-if="report.recommendations?.length" class="card">
|
||
<div class="card-header mb-4">
|
||
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">Рекомендации</h3>
|
||
</div>
|
||
<div class="card-content">
|
||
<ul class="list-none p-0 m-0">
|
||
<li v-for="(recommendation, index) in report.recommendations" :key="index" class="flex align-items-start mb-3">
|
||
<i class="pi pi-check-circle text-green-500 mr-2 mt-1"></i>
|
||
<span class="text-surface-700 dark:text-surface-300">{{ recommendation }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Кнопки действий -->
|
||
<div class="card">
|
||
<div class="card-content text-center">
|
||
<div class="flex gap-3 justify-content-center flex-wrap">
|
||
<Button label="Создать стратегию на основе анализа" icon="pi pi-magic" severity="success" @click="goToStrategy" />
|
||
<Button label="Создать новый анализ" icon="pi pi-plus" severity="secondary" @click="resetAnalysis" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import MarketingService from '@/service/MarketingService';
|
||
import { marked } from 'marked';
|
||
import Button from 'primevue/button';
|
||
import Chart from 'primevue/chart';
|
||
import Checkbox from 'primevue/checkbox';
|
||
import InputText from 'primevue/inputtext';
|
||
import MultiSelect from 'primevue/multiselect';
|
||
import ProgressSpinner from 'primevue/progressspinner';
|
||
import RadioButton from 'primevue/radiobutton';
|
||
import Tag from 'primevue/tag';
|
||
import Textarea from 'primevue/textarea';
|
||
import Toast from 'primevue/toast';
|
||
import { useToast } from 'primevue/usetoast';
|
||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||
import { useRoute, useRouter } from 'vue-router';
|
||
|
||
const toast = useToast();
|
||
const router = useRouter();
|
||
const route = useRoute();
|
||
|
||
// Constants
|
||
const VALID_REGIONS = [
|
||
'Алматы',
|
||
'Астана',
|
||
'Шымкент',
|
||
'Караганда',
|
||
'Актобе',
|
||
'Тараз',
|
||
'Павлодар',
|
||
'Усть-Каменогорск',
|
||
'Семей',
|
||
'Костанай',
|
||
'Кызылорда',
|
||
'Уральск',
|
||
'Петропавловск',
|
||
'Атырау',
|
||
'Актау',
|
||
'Туркестан',
|
||
'Кокшетау',
|
||
'Талдыкорган',
|
||
'Экибастуз',
|
||
'Рудный'
|
||
];
|
||
|
||
const VALID_GENDERS = ['Женщины', 'Мужчины'];
|
||
const VALID_AGE_RANGES = ['20-40', '25-45', '18-25', '40-60', '60+'];
|
||
const VALID_AUDIENCE_TYPES = ['Семьи', 'Молодёжь', 'Все подряд'];
|
||
const VALID_ANALYSIS_TYPES = ['РЫНОК', 'КОНКУРЕНТЫ', 'ЦА', 'КАНАЛЫ', 'SWOT'];
|
||
|
||
const DETAIL_LEVELS = ['КРАТКО', 'СТАНДАРТНО', 'ПОДРОБНО'];
|
||
|
||
// Form data
|
||
const formData = ref({
|
||
businessNiche: '',
|
||
product: '',
|
||
targetAudience: {
|
||
genders: [],
|
||
ageRanges: [],
|
||
types: []
|
||
},
|
||
region: [],
|
||
goal: '',
|
||
detailLevel: 'СТАНДАРТНО',
|
||
strongSide: '',
|
||
weakSide: '',
|
||
analysisType: []
|
||
});
|
||
|
||
const analysisTypeOptions = [
|
||
{ label: 'РЫНОК - Анализ внешней среды', value: 'РЫНОК' },
|
||
{ label: 'КОНКУРЕНТЫ - Анализ конкурентов', value: 'КОНКУРЕНТЫ' },
|
||
{ label: 'ЦА - Анализ целевой аудитории', value: 'ЦА' },
|
||
{ label: 'КАНАЛЫ - Анализ рекламных каналов', value: 'КАНАЛЫ' },
|
||
{ label: 'SWOT - SWOT-анализ', value: 'SWOT' }
|
||
];
|
||
|
||
const submitting = ref(false);
|
||
const errors = ref({});
|
||
|
||
// Analysis state
|
||
const analysisId = ref(null);
|
||
const status = ref(null);
|
||
const report = ref(null);
|
||
const currentAnalysisType = ref(null);
|
||
const estimatedCompletionTime = ref(null);
|
||
const pollingInterval = ref(null);
|
||
const pollingAttempts = ref(0);
|
||
const maxPollingAttempts = 60; // 10 minutes with 10 second intervals
|
||
const pollingIntervalMs = 10000; // 10 seconds
|
||
const downloadingPdf = ref(false);
|
||
|
||
// Validation
|
||
const validateForm = () => {
|
||
errors.value = {};
|
||
|
||
// BusinessNiche validation
|
||
if (!formData.value.businessNiche || formData.value.businessNiche.trim().length < 3) {
|
||
errors.value.businessNiche = 'Ниша бизнеса должна содержать минимум 3 символа';
|
||
} else if (formData.value.businessNiche.trim().length > 200) {
|
||
errors.value.businessNiche = 'Ниша бизнеса не должна превышать 200 символов';
|
||
}
|
||
|
||
// Product validation
|
||
if (!formData.value.product || formData.value.product.trim().length < 3) {
|
||
errors.value.product = 'Название продукта должно содержать минимум 3 символа';
|
||
} else if (formData.value.product.trim().length > 200) {
|
||
errors.value.product = 'Название продукта не должно превышать 200 символов';
|
||
}
|
||
|
||
// TargetAudience validation
|
||
const hasGenders = formData.value.targetAudience.genders && formData.value.targetAudience.genders.length > 0;
|
||
const hasAgeRanges = formData.value.targetAudience.ageRanges && formData.value.targetAudience.ageRanges.length > 0;
|
||
const hasTypes = formData.value.targetAudience.types && formData.value.targetAudience.types.length > 0;
|
||
|
||
if (!hasGenders && !hasAgeRanges && !hasTypes) {
|
||
errors.value.targetAudience = 'Выберите хотя бы одну опцию для целевой аудитории (гендер, возраст или тип)';
|
||
} else {
|
||
// Валидация значений
|
||
if (formData.value.targetAudience.genders) {
|
||
const invalidGenders = formData.value.targetAudience.genders.filter((g) => !VALID_GENDERS.includes(g));
|
||
if (invalidGenders.length > 0) {
|
||
errors.value.targetAudience = `Недопустимые значения гендера: ${invalidGenders.join(', ')}`;
|
||
}
|
||
}
|
||
if (formData.value.targetAudience.ageRanges) {
|
||
const invalidAges = formData.value.targetAudience.ageRanges.filter((a) => !VALID_AGE_RANGES.includes(a));
|
||
if (invalidAges.length > 0) {
|
||
errors.value.targetAudience = `Недопустимые значения возраста: ${invalidAges.join(', ')}`;
|
||
}
|
||
}
|
||
if (formData.value.targetAudience.types) {
|
||
const invalidTypes = formData.value.targetAudience.types.filter((t) => !VALID_AUDIENCE_TYPES.includes(t));
|
||
if (invalidTypes.length > 0) {
|
||
errors.value.targetAudience = `Недопустимые значения типа: ${invalidTypes.join(', ')}`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Region validation
|
||
if (!formData.value.region || formData.value.region.length === 0) {
|
||
errors.value.region = 'Выберите хотя бы один регион';
|
||
} else {
|
||
const invalidRegions = formData.value.region.filter((r) => !VALID_REGIONS.includes(r));
|
||
if (invalidRegions.length > 0) {
|
||
errors.value.region = `Недопустимые регионы: ${invalidRegions.join(', ')}`;
|
||
}
|
||
}
|
||
|
||
// Goal validation
|
||
if (!formData.value.goal || formData.value.goal.trim().length < 10) {
|
||
errors.value.goal = 'Цель должна содержать минимум 10 символов';
|
||
} else if (formData.value.goal.trim().length > 500) {
|
||
errors.value.goal = 'Цель не должна превышать 500 символов';
|
||
}
|
||
|
||
// DetailLevel validation
|
||
if (!formData.value.detailLevel || !DETAIL_LEVELS.includes(formData.value.detailLevel)) {
|
||
errors.value.detailLevel = 'Пожалуйста, выберите уровень детализации';
|
||
}
|
||
|
||
// StrongSide validation (optional)
|
||
if (formData.value.strongSide && formData.value.strongSide.trim().length > 500) {
|
||
errors.value.strongSide = 'Сильная сторона не должна превышать 500 символов';
|
||
}
|
||
|
||
// WeakSide validation (optional)
|
||
if (formData.value.weakSide && formData.value.weakSide.trim().length > 500) {
|
||
errors.value.weakSide = 'Слабая сторона не должна превышать 500 символов';
|
||
}
|
||
|
||
// AnalysisType validation
|
||
if (!formData.value.analysisType || formData.value.analysisType.length === 0) {
|
||
errors.value.analysisType = 'Выберите хотя бы один тип анализа';
|
||
} else {
|
||
const invalidTypes = formData.value.analysisType.filter((t) => !VALID_ANALYSIS_TYPES.includes(t));
|
||
if (invalidTypes.length > 0) {
|
||
errors.value.analysisType = `Недопустимые типы анализа: ${invalidTypes.join(', ')}`;
|
||
}
|
||
}
|
||
|
||
return Object.keys(errors.value).length === 0;
|
||
};
|
||
|
||
// Start polling for analysis status
|
||
const startPolling = (id) => {
|
||
pollingAttempts.value = 0;
|
||
pollingInterval.value = setInterval(async () => {
|
||
pollingAttempts.value++;
|
||
|
||
if (pollingAttempts.value > maxPollingAttempts) {
|
||
stopPolling();
|
||
toast.add({
|
||
severity: 'warn',
|
||
summary: 'Таймаут',
|
||
detail: 'Превышено время ожидания анализа. Попробуйте проверить результат позже.',
|
||
life: 5000
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const result = await MarketingService.getAnalysisResult(id);
|
||
status.value = result.status;
|
||
|
||
if (result.status === 'completed' && result.report) {
|
||
stopPolling();
|
||
report.value = result.report;
|
||
// Сохраняем тип анализа из результата, если он есть
|
||
if (result.analysisType) {
|
||
currentAnalysisType.value = result.analysisType;
|
||
}
|
||
toast.add({
|
||
severity: 'success',
|
||
summary: 'Анализ завершен',
|
||
detail: 'Маркетинговый анализ успешно завершен',
|
||
life: 3000
|
||
});
|
||
} else if (result.status === 'failed') {
|
||
stopPolling();
|
||
toast.add({
|
||
severity: 'error',
|
||
summary: 'Ошибка анализа',
|
||
detail: 'Анализ завершился с ошибкой',
|
||
life: 5000
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('Ошибка при проверке статуса анализа:', error);
|
||
// Continue polling on error, but log it
|
||
}
|
||
}, pollingIntervalMs);
|
||
};
|
||
|
||
// Stop polling
|
||
const stopPolling = () => {
|
||
if (pollingInterval.value) {
|
||
clearInterval(pollingInterval.value);
|
||
pollingInterval.value = null;
|
||
}
|
||
};
|
||
|
||
// Handle form submit
|
||
const handleSubmit = async () => {
|
||
if (!validateForm()) {
|
||
toast.add({
|
||
severity: 'error',
|
||
summary: 'Ошибка валидации',
|
||
detail: 'Пожалуйста, исправьте ошибки в форме',
|
||
life: 3000
|
||
});
|
||
return;
|
||
}
|
||
|
||
submitting.value = true;
|
||
|
||
try {
|
||
// Формируем объект targetAudience (удаляем пустые массивы)
|
||
const targetAudience = {};
|
||
if (formData.value.targetAudience.genders && formData.value.targetAudience.genders.length > 0) {
|
||
targetAudience.genders = formData.value.targetAudience.genders;
|
||
}
|
||
if (formData.value.targetAudience.ageRanges && formData.value.targetAudience.ageRanges.length > 0) {
|
||
targetAudience.ageRanges = formData.value.targetAudience.ageRanges;
|
||
}
|
||
if (formData.value.targetAudience.types && formData.value.targetAudience.types.length > 0) {
|
||
targetAudience.types = formData.value.targetAudience.types;
|
||
}
|
||
|
||
const requestData = {
|
||
businessNiche: formData.value.businessNiche.trim(),
|
||
product: formData.value.product.trim(),
|
||
targetAudience: targetAudience,
|
||
region: formData.value.region,
|
||
goal: formData.value.goal.trim(),
|
||
detailLevel: formData.value.detailLevel,
|
||
analysisType: formData.value.analysisType
|
||
};
|
||
|
||
// Добавляем опциональные поля только если они заполнены
|
||
if (formData.value.strongSide && formData.value.strongSide.trim()) {
|
||
requestData.strongSide = formData.value.strongSide.trim();
|
||
}
|
||
if (formData.value.weakSide && formData.value.weakSide.trim()) {
|
||
requestData.weakSide = formData.value.weakSide.trim();
|
||
}
|
||
|
||
const result = await MarketingService.startAnalysis(requestData);
|
||
|
||
// Обработка ответа: может быть массив или один объект
|
||
if (Array.isArray(result)) {
|
||
// Множественные анализы
|
||
const firstAnalysis = result[0];
|
||
analysisId.value = firstAnalysis.analysisId;
|
||
status.value = firstAnalysis.status;
|
||
estimatedCompletionTime.value = firstAnalysis.estimatedCompletion;
|
||
|
||
// Сохраняем типы анализов
|
||
currentAnalysisType.value = formData.value.analysisType.length === 1 ? formData.value.analysisType[0] : null;
|
||
|
||
toast.add({
|
||
severity: 'success',
|
||
summary: 'Анализы запущены',
|
||
detail: `Создано ${result.length} анализов. Они будут обработаны параллельно.`,
|
||
life: 5000
|
||
});
|
||
|
||
// Запускаем polling для первого анализа (можно расширить для всех)
|
||
startPolling(firstAnalysis.analysisId);
|
||
} else {
|
||
// Один анализ
|
||
analysisId.value = result.analysisId;
|
||
status.value = result.status;
|
||
currentAnalysisType.value = formData.value.analysisType.length === 1 ? formData.value.analysisType[0] : null;
|
||
estimatedCompletionTime.value = result.estimatedCompletion;
|
||
|
||
toast.add({
|
||
severity: 'success',
|
||
summary: 'Анализ запущен',
|
||
detail: result.message || 'Анализ успешно запущен. Результаты будут готовы в течение 5-10 минут.',
|
||
life: 5000
|
||
});
|
||
|
||
// Start polling
|
||
startPolling(result.analysisId);
|
||
}
|
||
} catch (error) {
|
||
console.error('Ошибка при запуске анализа:', error);
|
||
toast.add({
|
||
severity: 'error',
|
||
summary: 'Ошибка',
|
||
detail: error.message || 'Не удалось запустить анализ. Пожалуйста, попробуйте снова.',
|
||
life: 5000
|
||
});
|
||
} finally {
|
||
submitting.value = false;
|
||
}
|
||
};
|
||
|
||
// Handle PDF download
|
||
const handleDownloadPdf = async () => {
|
||
if (!analysisId.value) return;
|
||
|
||
downloadingPdf.value = true;
|
||
|
||
try {
|
||
await MarketingService.downloadPdfFile(analysisId.value);
|
||
toast.add({
|
||
severity: 'success',
|
||
summary: 'PDF скачан',
|
||
detail: 'PDF отчет успешно скачан',
|
||
life: 3000
|
||
});
|
||
} catch (error) {
|
||
console.error('Ошибка при скачивании PDF:', error);
|
||
toast.add({
|
||
severity: 'error',
|
||
summary: 'Ошибка',
|
||
detail: error.message || 'Не удалось скачать PDF отчет',
|
||
life: 5000
|
||
});
|
||
} finally {
|
||
downloadingPdf.value = false;
|
||
}
|
||
};
|
||
|
||
// Reset analysis and form
|
||
const resetAnalysis = () => {
|
||
stopPolling();
|
||
analysisId.value = null;
|
||
status.value = null;
|
||
report.value = null;
|
||
currentAnalysisType.value = null;
|
||
estimatedCompletionTime.value = null;
|
||
pollingAttempts.value = 0;
|
||
formData.value = {
|
||
businessNiche: '',
|
||
product: '',
|
||
targetAudience: {
|
||
genders: [],
|
||
ageRanges: [],
|
||
types: []
|
||
},
|
||
region: [],
|
||
goal: '',
|
||
detailLevel: 'СТАНДАРТНО',
|
||
strongSide: '',
|
||
weakSide: '',
|
||
analysisType: []
|
||
};
|
||
errors.value = {};
|
||
};
|
||
|
||
// Render markdown to HTML
|
||
// Helper function to extract JSON from rendered HTML (after markdown rendering)
|
||
const extractJsonFromRenderedHtml = (html) => {
|
||
if (!html || typeof html !== 'string') {
|
||
return { htmlSegments: [html], chartSegments: [] };
|
||
}
|
||
|
||
const chartSegments = [];
|
||
const htmlSegments = [];
|
||
let processedHtml = html;
|
||
const processedMatches = new Set();
|
||
|
||
// Find all <pre><code> blocks that might contain JSON
|
||
const codeBlockRegex = /<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi;
|
||
let match;
|
||
|
||
while ((match = codeBlockRegex.exec(html)) !== null) {
|
||
const [fullMatch, codeContent] = match;
|
||
if (processedMatches.has(fullMatch)) continue;
|
||
|
||
// Try to extract and parse JSON from code content
|
||
const jsonSegment = tryParseJsonSegment(codeContent);
|
||
if (jsonSegment) {
|
||
processedMatches.add(fullMatch);
|
||
chartSegments.push(jsonSegment);
|
||
// Remove the code block from HTML
|
||
processedHtml = processedHtml.replace(fullMatch, '');
|
||
}
|
||
}
|
||
|
||
// Also check for standalone <code> blocks
|
||
const standaloneCodeRegex = /<code[^>]*>([\s\S]*?)<\/code>/gi;
|
||
while ((match = standaloneCodeRegex.exec(processedHtml)) !== null) {
|
||
const [fullMatch, codeContent] = match;
|
||
if (processedMatches.has(fullMatch)) continue;
|
||
|
||
const jsonSegment = tryParseJsonSegment(codeContent);
|
||
if (jsonSegment) {
|
||
processedMatches.add(fullMatch);
|
||
chartSegments.push(jsonSegment);
|
||
processedHtml = processedHtml.replace(fullMatch, '');
|
||
}
|
||
}
|
||
|
||
// Also try to find JSON in plain text (not in code blocks)
|
||
// Look for JSON objects/arrays in the remaining HTML text
|
||
const findJsonInHtmlText = (text) => {
|
||
const results = [];
|
||
let depth = 0;
|
||
let start = -1;
|
||
let inString = false;
|
||
let stringChar = '';
|
||
let inTag = false;
|
||
|
||
for (let i = 0; i < text.length; i++) {
|
||
const char = text[i];
|
||
const prevChar = i > 0 ? text[i - 1] : '';
|
||
|
||
// Skip HTML tags
|
||
if (char === '<') inTag = true;
|
||
if (char === '>') inTag = false;
|
||
if (inTag) continue;
|
||
|
||
// Handle string escaping
|
||
if (prevChar === '\\') continue;
|
||
|
||
// Handle string boundaries
|
||
if ((char === '"' || char === "'") && !inString) {
|
||
inString = true;
|
||
stringChar = char;
|
||
} else if (char === stringChar && inString) {
|
||
inString = false;
|
||
stringChar = '';
|
||
}
|
||
|
||
if (inString) continue;
|
||
|
||
// Track bracket depth
|
||
if (char === '[' || char === '{') {
|
||
if (depth === 0) {
|
||
start = i;
|
||
}
|
||
depth++;
|
||
} else if (char === ']' || char === '}') {
|
||
depth--;
|
||
if (depth === 0 && start !== -1) {
|
||
const jsonText = text.substring(start, i + 1);
|
||
const jsonSegment = tryParseJsonSegment(jsonText);
|
||
if (jsonSegment) {
|
||
results.push({ text: jsonText, index: start, segment: jsonSegment });
|
||
}
|
||
start = -1;
|
||
}
|
||
}
|
||
}
|
||
|
||
return results;
|
||
};
|
||
|
||
const jsonMatches = findJsonInHtmlText(processedHtml);
|
||
// Process matches from end to start to preserve indices
|
||
jsonMatches.reverse().forEach(({ text, segment }) => {
|
||
if (!processedMatches.has(text)) {
|
||
processedMatches.add(text);
|
||
chartSegments.push(segment);
|
||
// Remove JSON from HTML
|
||
const index = processedHtml.indexOf(text);
|
||
if (index !== -1) {
|
||
processedHtml = processedHtml.substring(0, index) + processedHtml.substring(index + text.length);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Split remaining HTML by removed code blocks and add as segments
|
||
if (processedHtml.trim()) {
|
||
// Split by multiple newlines to create separate segments
|
||
const parts = processedHtml.split(/\n\s*\n/).filter((p) => p.trim());
|
||
htmlSegments.push(...parts);
|
||
}
|
||
|
||
return { htmlSegments, chartSegments };
|
||
};
|
||
|
||
// Helper function to extract JSON from markdown before rendering
|
||
const extractJsonFromMarkdown = (markdown) => {
|
||
if (!markdown || typeof markdown !== 'string') {
|
||
return { processedText: markdown, jsonSegments: [] };
|
||
}
|
||
|
||
const jsonSegments = [];
|
||
let processedText = markdown;
|
||
const processedMatches = new Set();
|
||
|
||
// Find all code blocks that might contain JSON
|
||
// Match ```json, ```json:identifier, or plain ``` blocks
|
||
const codeBlockRegex = /```(?:json(?::(\w+))?)?\n([\s\S]*?)\n```/g;
|
||
let match;
|
||
|
||
while ((match = codeBlockRegex.exec(markdown)) !== null) {
|
||
const [fullMatch, identifier, codeContent] = match;
|
||
const jsonSegment = tryParseJsonSegment(codeContent);
|
||
|
||
if (jsonSegment && !processedMatches.has(fullMatch)) {
|
||
processedMatches.add(fullMatch);
|
||
jsonSegments.push(jsonSegment);
|
||
// Replace the code block with empty string (we'll add the segment separately)
|
||
processedText = processedText.replace(fullMatch, '');
|
||
}
|
||
}
|
||
|
||
// Also check for JSON objects/arrays that are not in code blocks
|
||
// Look for JSON at the end of text or standalone
|
||
// Use a more sophisticated approach: find balanced brackets
|
||
const findJsonInText = (text) => {
|
||
const results = [];
|
||
let depth = 0;
|
||
let start = -1;
|
||
let inString = false;
|
||
let stringChar = '';
|
||
|
||
for (let i = 0; i < text.length; i++) {
|
||
const char = text[i];
|
||
const prevChar = i > 0 ? text[i - 1] : '';
|
||
|
||
// Handle string escaping
|
||
if (prevChar === '\\') continue;
|
||
|
||
// Handle string boundaries
|
||
if ((char === '"' || char === "'") && !inString) {
|
||
inString = true;
|
||
stringChar = char;
|
||
} else if (char === stringChar && inString) {
|
||
inString = false;
|
||
stringChar = '';
|
||
}
|
||
|
||
if (inString) continue;
|
||
|
||
// Track bracket depth
|
||
if (char === '[' || char === '{') {
|
||
if (depth === 0) {
|
||
start = i;
|
||
}
|
||
depth++;
|
||
} else if (char === ']' || char === '}') {
|
||
depth--;
|
||
if (depth === 0 && start !== -1) {
|
||
const jsonText = text.substring(start, i + 1);
|
||
const jsonSegment = tryParseJsonSegment(jsonText);
|
||
if (jsonSegment) {
|
||
results.push({ text: jsonText, index: start, segment: jsonSegment });
|
||
}
|
||
start = -1;
|
||
}
|
||
}
|
||
}
|
||
|
||
return results;
|
||
};
|
||
|
||
const jsonMatches = findJsonInText(processedText);
|
||
|
||
// Process matches from end to start to preserve indices
|
||
jsonMatches.reverse().forEach(({ text, segment }) => {
|
||
if (!processedMatches.has(text)) {
|
||
processedMatches.add(text);
|
||
jsonSegments.push(segment);
|
||
// Use a more precise replacement to avoid partial matches
|
||
processedText = processedText.substring(0, processedText.indexOf(text)) + processedText.substring(processedText.indexOf(text) + text.length);
|
||
}
|
||
});
|
||
|
||
// Also check if the entire remaining text is JSON (common case when JSON is at the end)
|
||
// This handles cases where JSON is at the very end of the text
|
||
const finalTrimmed = processedText.trim();
|
||
if (finalTrimmed && (finalTrimmed.startsWith('[') || finalTrimmed.startsWith('{'))) {
|
||
const finalJsonSegment = tryParseJsonSegment(finalTrimmed);
|
||
if (finalJsonSegment && !processedMatches.has(finalTrimmed)) {
|
||
jsonSegments.push(finalJsonSegment);
|
||
processedText = '';
|
||
}
|
||
}
|
||
|
||
// Also check for JSON that might be at the end but after some whitespace/newlines
|
||
// Look for JSON patterns at the end of the text (after removing trailing whitespace)
|
||
const lines = processedText.split('\n');
|
||
let jsonStartLine = -1;
|
||
for (let i = lines.length - 1; i >= 0; i--) {
|
||
const line = lines[i].trim();
|
||
if (line.startsWith('[') || line.startsWith('{')) {
|
||
jsonStartLine = i;
|
||
break;
|
||
}
|
||
if (line && !line.match(/^[\[\]{}:,\s"']+$/)) {
|
||
// Not a JSON line, stop searching
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (jsonStartLine >= 0) {
|
||
const jsonText = lines.slice(jsonStartLine).join('\n').trim();
|
||
const finalJsonSegment = tryParseJsonSegment(jsonText);
|
||
if (finalJsonSegment && !processedMatches.has(jsonText)) {
|
||
jsonSegments.push(finalJsonSegment);
|
||
processedText = lines.slice(0, jsonStartLine).join('\n').trim();
|
||
}
|
||
}
|
||
|
||
return { processedText: processedText.trim(), jsonSegments };
|
||
};
|
||
|
||
const renderMarkdown = (markdown) => {
|
||
if (!markdown || typeof markdown !== 'string') {
|
||
return '';
|
||
}
|
||
try {
|
||
// В marked v17 используется marked.parse() с опциями вторым параметром
|
||
return marked.parse(markdown, { breaks: true, gfm: true });
|
||
} catch (error) {
|
||
console.error('Error rendering markdown:', error);
|
||
// В случае ошибки возвращаем исходный текст
|
||
return markdown;
|
||
}
|
||
};
|
||
|
||
const seasonalityChartOptions = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
display: true,
|
||
position: 'top'
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
max: 100,
|
||
ticks: {
|
||
callback: function (value) {
|
||
return value + '%';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const conversionFunnelChartOptions = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
display: true,
|
||
position: 'top'
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
title: {
|
||
display: true,
|
||
text: 'Количество пользователей'
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const channelsPotentialChartOptions = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
display: false
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
max: 100,
|
||
ticks: {
|
||
callback: function (value) {
|
||
return value;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const competitorsBarChartOptions = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
display: true,
|
||
position: 'top'
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
ticks: {
|
||
callback: function (value) {
|
||
return value.toLocaleString();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const buildSeasonalityChartData = (seasonality) => {
|
||
if (!seasonality || typeof seasonality !== 'object') return null;
|
||
return {
|
||
labels: Object.keys(seasonality),
|
||
datasets: [
|
||
{
|
||
label: 'Индекс спроса',
|
||
data: Object.values(seasonality),
|
||
borderColor: '#3B82F6',
|
||
backgroundColor: 'rgba(59, 130, 246, 0.2)',
|
||
fill: true,
|
||
tension: 0.4
|
||
}
|
||
]
|
||
};
|
||
};
|
||
|
||
const buildConversionFunnelChartData = (funnel) => {
|
||
if (!Array.isArray(funnel) || funnel.length === 0) return null;
|
||
return {
|
||
labels: funnel.map((item) => item.stage),
|
||
datasets: [
|
||
{
|
||
label: 'Пользователей',
|
||
data: funnel.map((item) => item.value),
|
||
backgroundColor: '#3B82F6',
|
||
borderRadius: 4
|
||
}
|
||
]
|
||
};
|
||
};
|
||
|
||
const buildChannelsPotentialChartData = (channels) => {
|
||
if (!Array.isArray(channels) || channels.length === 0) return null;
|
||
|
||
return {
|
||
labels: channels.map((ch) => ch.name),
|
||
datasets: [
|
||
{
|
||
label: 'Потенциал канала',
|
||
data: channels.map((ch) => ch.potential),
|
||
backgroundColor: (ctx) => {
|
||
const val = ctx.raw;
|
||
return val >= 80 ? '#10B981' : val >= 50 ? '#F59E0B' : '#EF4444';
|
||
},
|
||
borderRadius: 4
|
||
}
|
||
]
|
||
};
|
||
};
|
||
|
||
const buildAudienceSegmentationChartData = (segmentation) => {
|
||
if (!segmentation || !segmentation.ageGroups) return { ageGroupsChartData: null, gendersChartData: null };
|
||
|
||
// Build chart data for age groups (primary chart)
|
||
const ageLabels = segmentation.ageGroups.map((i) => i.label);
|
||
const ageValues = segmentation.ageGroups.map((i) => parseFloat(i.value));
|
||
|
||
const ageGroupsChartData = {
|
||
labels: ageLabels,
|
||
datasets: [
|
||
{
|
||
data: ageValues,
|
||
backgroundColor: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6'],
|
||
borderWidth: 0
|
||
}
|
||
]
|
||
};
|
||
|
||
// Keep genders chart for backward compatibility if needed
|
||
const gendersChartData =
|
||
Array.isArray(segmentation.genders) && segmentation.genders.length > 0
|
||
? {
|
||
labels: segmentation.genders.map((g) => g?.label || ''),
|
||
datasets: [
|
||
{
|
||
data: segmentation.genders.map((g) => {
|
||
const value = g?.value;
|
||
return value != null ? parseFloat(value) : 0;
|
||
}),
|
||
backgroundColor: segmentation.genders.map((_, idx) => ['#EC4899', '#3B82F6'][idx % 2]),
|
||
borderWidth: 0
|
||
}
|
||
]
|
||
}
|
||
: null;
|
||
|
||
return { ageGroupsChartData, gendersChartData };
|
||
};
|
||
|
||
// Build segments channel matrix data for heatmap
|
||
const buildSegmentsChannelMatrix = (matrix) => {
|
||
if (!Array.isArray(matrix) || matrix.length === 0) return null;
|
||
|
||
// Extract all unique channels from matrix
|
||
const channels = new Set();
|
||
matrix.forEach((row) => {
|
||
Object.keys(row).forEach((key) => {
|
||
if (key !== 'segmentName') {
|
||
channels.add(key);
|
||
}
|
||
});
|
||
});
|
||
|
||
return {
|
||
segments: matrix.map((row) => row.segmentName),
|
||
channels: Array.from(channels),
|
||
data: matrix
|
||
};
|
||
};
|
||
|
||
// Build market share chart data
|
||
const buildMarketShareChartData = (marketShareChart) => {
|
||
if (!Array.isArray(marketShareChart) || marketShareChart.length === 0) return null;
|
||
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#F97316', '#06B6D4'];
|
||
|
||
return {
|
||
labels: marketShareChart.map((item) => item.name),
|
||
datasets: [
|
||
{
|
||
data: marketShareChart.map((item) => item.value),
|
||
backgroundColor: marketShareChart.map((_, idx) => colors[idx % colors.length]),
|
||
borderColor: '#fff',
|
||
borderWidth: 2
|
||
}
|
||
]
|
||
};
|
||
};
|
||
|
||
// Build competitors bar chart data for reach and followers
|
||
const buildCompetitorsBarChartData = (competitors, metricType = 'reach') => {
|
||
if (!Array.isArray(competitors) || competitors.length === 0) return null;
|
||
|
||
return {
|
||
labels: competitors.map((comp) => comp.name),
|
||
datasets: [
|
||
{
|
||
label: metricType === 'reach' ? 'Охват аудитории' : 'Подписчики',
|
||
// Check nested structure digitalMetrics!
|
||
data: competitors.map((comp) => (comp.digitalMetrics ? comp.digitalMetrics[metricType] || 0 : comp[metricType] || 0)),
|
||
backgroundColor: metricType === 'reach' ? '#6366F1' : '#3B82F6', // Purple for reach, blue for followers
|
||
borderRadius: 4
|
||
}
|
||
]
|
||
};
|
||
};
|
||
|
||
// Build competitors heatmap data
|
||
const buildCompetitorsHeatmapData = (competitors) => {
|
||
if (!Array.isArray(competitors) || competitors.length === 0) return null;
|
||
|
||
const channels = new Set();
|
||
competitors.forEach((comp) => {
|
||
if (comp.channelsHeatmap && typeof comp.channelsHeatmap === 'object') {
|
||
Object.keys(comp.channelsHeatmap).forEach((channel) => channels.add(channel));
|
||
}
|
||
});
|
||
|
||
return {
|
||
competitors: competitors.map((comp) => comp.name),
|
||
channels: Array.from(channels),
|
||
data: competitors.map((comp) => ({
|
||
name: comp.name,
|
||
channelsHeatmap: comp.channelsHeatmap || {}
|
||
}))
|
||
};
|
||
};
|
||
|
||
// Get heatmap color intensity based on value (0-100)
|
||
const getHeatmapColor = (value) => {
|
||
if (value >= 80) return 'rgba(16, 185, 129, 0.9)'; // green-500
|
||
if (value >= 60) return 'rgba(245, 158, 11, 0.9)'; // amber-500
|
||
if (value >= 40) return 'rgba(251, 191, 36, 0.9)'; // yellow-500
|
||
if (value >= 20) return 'rgba(249, 115, 22, 0.9)'; // orange-500
|
||
return 'rgba(239, 68, 68, 0.9)'; // red-500
|
||
};
|
||
|
||
// Get channel matrix color based on value (high/medium/low)
|
||
const getChannelMatrixColor = (value) => {
|
||
if (!value) return 'rgba(156, 163, 175, 0.5)'; // gray
|
||
const normalizedValue = String(value).toLowerCase();
|
||
if (normalizedValue === 'high' || normalizedValue === 'высокий' || normalizedValue === 'высокая') return 'rgba(16, 185, 129, 0.9)'; // green
|
||
if (normalizedValue === 'medium' || normalizedValue === 'средний' || normalizedValue === 'средняя') return 'rgba(245, 158, 11, 0.9)'; // amber
|
||
if (normalizedValue === 'low' || normalizedValue === 'низкий' || normalizedValue === 'низкая') return 'rgba(239, 68, 68, 0.9)'; // red
|
||
return 'rgba(156, 163, 175, 0.5)'; // gray
|
||
};
|
||
|
||
// Helper: detect Chart.js "data" shape (labels + datasets)
|
||
const isChartJsData = (value) => {
|
||
return !!(value && typeof value === 'object' && !Array.isArray(value) && Array.isArray(value.labels) && Array.isArray(value.datasets));
|
||
};
|
||
|
||
// Helper: normalize Chart.js datasets to have sane colors
|
||
const normalizeChartJsData = (raw) => {
|
||
if (!isChartJsData(raw)) return null;
|
||
const colors = ['#6366F1', '#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4'];
|
||
|
||
return {
|
||
labels: raw.labels,
|
||
datasets: raw.datasets.map((dataset, idx) => {
|
||
const fallback = colors[idx % colors.length];
|
||
const bg = dataset.backgroundColor || dataset.borderColor || fallback;
|
||
return {
|
||
...dataset,
|
||
backgroundColor: dataset.backgroundColor || bg,
|
||
borderColor: dataset.borderColor || bg,
|
||
borderRadius: dataset.borderRadius ?? 4
|
||
};
|
||
})
|
||
};
|
||
};
|
||
|
||
// Main function to map chartsData to segments
|
||
const mapChartsDataToSegments = (chartsData) => {
|
||
if (!chartsData || typeof chartsData !== 'object') return [];
|
||
|
||
const segments = [];
|
||
|
||
// Handle case when chartsData is an array (shouldn't happen, but handle it)
|
||
if (Array.isArray(chartsData)) {
|
||
// If it's an array of competitors, process it
|
||
if (chartsData.length > 0 && chartsData[0]?.name && (chartsData[0]?.channelsHeatmap || chartsData[0]?.reach || chartsData[0]?.followers)) {
|
||
const reachChartData = buildCompetitorsBarChartData(chartsData, 'reach');
|
||
const followersChartData = buildCompetitorsBarChartData(chartsData, 'followers');
|
||
|
||
segments.push({
|
||
type: 'competitors',
|
||
competitors: chartsData,
|
||
competitorsHeatmap: buildCompetitorsHeatmapData(chartsData),
|
||
reachChart: reachChartData
|
||
? {
|
||
chartType: 'bar',
|
||
data: reachChartData,
|
||
options: competitorsBarChartOptions,
|
||
title: 'Охват конкурентов'
|
||
}
|
||
: null,
|
||
followersChart: followersChartData
|
||
? {
|
||
chartType: 'bar',
|
||
data: followersChartData,
|
||
options: competitorsBarChartOptions,
|
||
title: 'Подписчики конкурентов'
|
||
}
|
||
: null
|
||
});
|
||
}
|
||
return segments;
|
||
}
|
||
|
||
// Handle case when chartsData is an audienceSegmentation object directly
|
||
// (has genders, ageGroups, segments, etc. but no audienceSegmentation property)
|
||
if (chartsData.genders || chartsData.ageGroups || chartsData.segments) {
|
||
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData);
|
||
if (ageGroupsChartData || gendersChartData) {
|
||
segments.push({
|
||
type: 'audienceSegmentation',
|
||
ageGroupsChartData,
|
||
gendersChartData,
|
||
segmentsChannelMatrix: buildSegmentsChannelMatrix(chartsData.segmentsChannelMatrix),
|
||
keyTakeaways: chartsData.keyTakeaways || []
|
||
});
|
||
// Return early to avoid processing it again as audienceSegmentation property
|
||
// But continue to check for other properties like competitors
|
||
}
|
||
}
|
||
|
||
// 1. Audience Segmentation (nested property)
|
||
if (chartsData.audienceSegmentation) {
|
||
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData.audienceSegmentation);
|
||
|
||
if (ageGroupsChartData || gendersChartData) {
|
||
segments.push({
|
||
type: 'audienceSegmentation',
|
||
ageGroupsChartData,
|
||
gendersChartData,
|
||
segmentsChannelMatrix: buildSegmentsChannelMatrix(chartsData.audienceSegmentation.segmentsChannelMatrix),
|
||
keyTakeaways: chartsData.audienceSegmentation.keyTakeaways || []
|
||
});
|
||
}
|
||
}
|
||
|
||
// 2. Market Share Chart (in root of chartsData)
|
||
if (chartsData.marketShareChart) {
|
||
const marketShareChartData = Array.isArray(chartsData.marketShareChart) ? buildMarketShareChartData(chartsData.marketShareChart) : normalizeChartJsData(chartsData.marketShareChart);
|
||
|
||
if (marketShareChartData) {
|
||
segments.push({
|
||
type: 'marketShareChart',
|
||
title: 'Доли рынка',
|
||
chartType: 'pie',
|
||
data: marketShareChartData,
|
||
options: pieChartOptions,
|
||
chartHeight: 'height: 300px'
|
||
});
|
||
}
|
||
}
|
||
|
||
// 3. Competitors Analysis
|
||
// Check if competitors is an array (new format) or object with competitors array (old format)
|
||
if (chartsData.competitors) {
|
||
let competitorsArray = null;
|
||
|
||
if (Array.isArray(chartsData.competitors)) {
|
||
// New format: competitors is directly an array
|
||
competitorsArray = chartsData.competitors;
|
||
} else if (chartsData.competitors.competitors && Array.isArray(chartsData.competitors.competitors)) {
|
||
// Old format: competitors is an object with competitors array inside
|
||
competitorsArray = chartsData.competitors.competitors;
|
||
}
|
||
|
||
if (competitorsArray && competitorsArray.length > 0) {
|
||
// Build bar charts for reach and followers using flat structure
|
||
const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach');
|
||
const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers');
|
||
|
||
segments.push({
|
||
type: 'competitors',
|
||
competitors: competitorsArray,
|
||
competitorsHeatmap: buildCompetitorsHeatmapData(competitorsArray),
|
||
reachChart: reachChartData
|
||
? {
|
||
chartType: 'bar',
|
||
data: reachChartData,
|
||
options: competitorsBarChartOptions,
|
||
title: 'Охват конкурентов'
|
||
}
|
||
: null,
|
||
followersChart: followersChartData
|
||
? {
|
||
chartType: 'bar',
|
||
data: followersChartData,
|
||
options: competitorsBarChartOptions,
|
||
title: 'Подписчики конкурентов'
|
||
}
|
||
: null
|
||
});
|
||
}
|
||
|
||
// SWOT Competitors (if exists in competitors object)
|
||
if (chartsData.competitors.swotCompetitors && typeof chartsData.competitors.swotCompetitors === 'object') {
|
||
segments.push({
|
||
type: 'swotCompetitors',
|
||
data: {
|
||
strengths: chartsData.competitors.swotCompetitors.strengths || [],
|
||
weaknesses: chartsData.competitors.swotCompetitors.weaknesses || []
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 4. Comparison Table (in root of chartsData)
|
||
if (chartsData.comparisonTable && Array.isArray(chartsData.comparisonTable) && chartsData.comparisonTable.length > 0) {
|
||
segments.push({
|
||
type: 'comparisonTable',
|
||
data: chartsData.comparisonTable
|
||
});
|
||
}
|
||
|
||
// 5. SWOT Competitors (in root of chartsData, if exists)
|
||
if (chartsData.swotCompetitors && typeof chartsData.swotCompetitors === 'object') {
|
||
segments.push({
|
||
type: 'swotCompetitors',
|
||
data: {
|
||
strengths: chartsData.swotCompetitors.strengths || [],
|
||
weaknesses: chartsData.swotCompetitors.weaknesses || []
|
||
}
|
||
});
|
||
}
|
||
|
||
// 6. Seasonality
|
||
if (chartsData.seasonality && typeof chartsData.seasonality === 'object') {
|
||
const chartData = buildSeasonalityChartData(chartsData.seasonality);
|
||
if (chartData) {
|
||
segments.push({
|
||
type: 'chart',
|
||
key: 'seasonality',
|
||
title: 'Сезонность спроса',
|
||
chartType: 'line',
|
||
data: chartData,
|
||
options: seasonalityChartOptions,
|
||
chartHeight: 'height: 300px'
|
||
});
|
||
}
|
||
}
|
||
|
||
// 7. Market
|
||
if (chartsData.market && typeof chartsData.market === 'object') {
|
||
segments.push({
|
||
type: 'market',
|
||
data: chartsData.market
|
||
});
|
||
}
|
||
|
||
// 8. Conversion Funnel (if exists for backward compatibility)
|
||
if (chartsData.conversionFunnel && Array.isArray(chartsData.conversionFunnel)) {
|
||
const chartData = buildConversionFunnelChartData(chartsData.conversionFunnel);
|
||
if (chartData) {
|
||
segments.push({
|
||
type: 'chart',
|
||
key: 'funnel',
|
||
title: 'Воронка конверсии',
|
||
chartType: 'bar',
|
||
data: chartData,
|
||
options: conversionFunnelChartOptions,
|
||
chartHeight: 'height: 400px'
|
||
});
|
||
}
|
||
}
|
||
|
||
// 9. Channels Potential (if exists for backward compatibility)
|
||
if (chartsData.channelsPotential && Array.isArray(chartsData.channelsPotential)) {
|
||
const chartData = buildChannelsPotentialChartData(chartsData.channelsPotential);
|
||
if (chartData) {
|
||
segments.push({
|
||
type: 'chart',
|
||
key: 'channelsPotential',
|
||
title: 'Потенциал каналов продвижения',
|
||
chartType: 'bar',
|
||
data: chartData,
|
||
options: channelsPotentialChartOptions,
|
||
chartHeight: 'height: 300px',
|
||
channels: chartsData.channelsPotential
|
||
});
|
||
}
|
||
}
|
||
|
||
// 11. Audience charts (Chart.js config)
|
||
if (chartsData.audienceAge) {
|
||
const chartData = normalizeChartJsData(chartsData.audienceAge);
|
||
if (chartData) {
|
||
segments.push({
|
||
type: 'chart',
|
||
key: 'audienceAge',
|
||
title: 'Распределение по возрасту',
|
||
chartType: 'bar',
|
||
data: chartData,
|
||
options: competitorsBarChartOptions,
|
||
chartHeight: 'height: 350px'
|
||
});
|
||
}
|
||
}
|
||
if (chartsData.audienceGender) {
|
||
const chartData = normalizeChartJsData(chartsData.audienceGender);
|
||
if (chartData) {
|
||
segments.push({
|
||
type: 'chart',
|
||
key: 'audienceGender',
|
||
title: 'Распределение по полу',
|
||
chartType: 'pie',
|
||
data: chartData,
|
||
options: pieChartOptions,
|
||
chartHeight: 'height: 350px'
|
||
});
|
||
}
|
||
}
|
||
|
||
// 10. SWOT (if exists for backward compatibility)
|
||
if (chartsData.swot && typeof chartsData.swot === 'object') {
|
||
segments.push({
|
||
type: 'swot',
|
||
data: {
|
||
strengths: chartsData.swot.strengths || [],
|
||
weaknesses: chartsData.swot.weaknesses || [],
|
||
opportunities: chartsData.swot.opportunities || [],
|
||
threats: chartsData.swot.threats || []
|
||
}
|
||
});
|
||
}
|
||
|
||
return segments;
|
||
};
|
||
|
||
const mapJsonBlockToSegment = (identifier, data, rawText) => {
|
||
const key = identifier?.toLowerCase();
|
||
switch (key) {
|
||
case 'audienceage': {
|
||
const chartData = normalizeChartJsData(data);
|
||
if (!chartData) return null;
|
||
return {
|
||
type: 'chart',
|
||
key: 'audienceAge',
|
||
title: 'Распределение по возрасту',
|
||
chartType: 'bar',
|
||
data: chartData,
|
||
options: competitorsBarChartOptions,
|
||
chartHeight: 'height: 350px'
|
||
};
|
||
}
|
||
case 'audiencegender': {
|
||
const chartData = normalizeChartJsData(data);
|
||
if (!chartData) return null;
|
||
return {
|
||
type: 'chart',
|
||
key: 'audienceGender',
|
||
title: 'Распределение по полу',
|
||
chartType: 'pie',
|
||
data: chartData,
|
||
options: pieChartOptions,
|
||
chartHeight: 'height: 350px'
|
||
};
|
||
}
|
||
case 'seasonality': {
|
||
const chartData = isChartJsData(data) ? normalizeChartJsData(data) : buildSeasonalityChartData(data);
|
||
if (!chartData) return null;
|
||
return {
|
||
type: 'chart',
|
||
key: 'seasonality',
|
||
title: 'Сезонность спроса',
|
||
chartType: 'line',
|
||
data: chartData,
|
||
options: seasonalityChartOptions,
|
||
chartHeight: 'height: 300px'
|
||
};
|
||
}
|
||
case 'funnel':
|
||
case 'conversionfunnel': {
|
||
const chartData = isChartJsData(data) ? normalizeChartJsData(data) : buildConversionFunnelChartData(data);
|
||
if (!chartData) return null;
|
||
return {
|
||
type: 'chart',
|
||
key: 'funnel',
|
||
title: 'Воронка конверсии',
|
||
chartType: 'bar',
|
||
data: chartData,
|
||
options: conversionFunnelChartOptions,
|
||
chartHeight: 'height: 400px'
|
||
};
|
||
}
|
||
case 'channelspotential': {
|
||
const chartData = isChartJsData(data) ? normalizeChartJsData(data) : buildChannelsPotentialChartData(data);
|
||
if (!chartData) return null;
|
||
return {
|
||
type: 'chart',
|
||
key: 'channelsPotential',
|
||
title: 'Потенциал каналов продвижения',
|
||
chartType: 'bar',
|
||
data: chartData,
|
||
options: channelsPotentialChartOptions,
|
||
chartHeight: 'height: 300px',
|
||
channels: Array.isArray(data) ? data : []
|
||
};
|
||
}
|
||
case 'marketsharechart': {
|
||
const chartData = isChartJsData(data) ? normalizeChartJsData(data) : null;
|
||
if (!chartData) return null;
|
||
return {
|
||
type: 'marketShareChart',
|
||
title: 'Доли рынка',
|
||
chartType: 'pie',
|
||
data: chartData,
|
||
options: pieChartOptions,
|
||
chartHeight: 'height: 300px'
|
||
};
|
||
}
|
||
case 'comparisontable': {
|
||
if (!Array.isArray(data) || data.length === 0) return null;
|
||
return {
|
||
type: 'comparisonTable',
|
||
data
|
||
};
|
||
}
|
||
case 'audiencesegmentation': {
|
||
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(data || {});
|
||
if (!ageGroupsChartData && !gendersChartData) return null;
|
||
return {
|
||
type: 'audienceSegmentation',
|
||
ageGroupsChartData,
|
||
gendersChartData
|
||
};
|
||
}
|
||
case 'competitors': {
|
||
if (!Array.isArray(data) || data.length === 0) return null;
|
||
const reachChartData = buildCompetitorsBarChartData(data, 'reach');
|
||
const followersChartData = buildCompetitorsBarChartData(data, 'followers');
|
||
|
||
return {
|
||
type: 'competitors',
|
||
competitors: data,
|
||
competitorsHeatmap: buildCompetitorsHeatmapData(data),
|
||
reachChart: reachChartData
|
||
? {
|
||
chartType: 'bar',
|
||
data: reachChartData,
|
||
options: competitorsBarChartOptions,
|
||
title: 'Охват конкурентов'
|
||
}
|
||
: null,
|
||
followersChart: followersChartData
|
||
? {
|
||
chartType: 'bar',
|
||
data: followersChartData,
|
||
options: competitorsBarChartOptions,
|
||
title: 'Подписчики конкурентов'
|
||
}
|
||
: null
|
||
};
|
||
}
|
||
case 'swot': {
|
||
if (!data || typeof data !== 'object') return null;
|
||
return {
|
||
type: 'swot',
|
||
data: {
|
||
strengths: data.strengths || [],
|
||
weaknesses: data.weaknesses || [],
|
||
opportunities: data.opportunities || [],
|
||
threats: data.threats || []
|
||
}
|
||
};
|
||
}
|
||
default:
|
||
// Unknown block type: render raw markdown so nothing is lost
|
||
return {
|
||
type: 'markdown',
|
||
html: renderMarkdown(rawText || '')
|
||
};
|
||
}
|
||
};
|
||
|
||
// Helper function to get chart segment by placeholder
|
||
const getChartSegmentByPlaceholder = (placeholder, chartsData) => {
|
||
if (!chartsData) return null;
|
||
|
||
switch (placeholder) {
|
||
case 'CHART_AUDIENCE':
|
||
if (chartsData.audienceSegmentation) {
|
||
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData.audienceSegmentation);
|
||
if (ageGroupsChartData || gendersChartData) {
|
||
return {
|
||
type: 'audienceSegmentation',
|
||
ageGroupsChartData,
|
||
gendersChartData,
|
||
segmentsChannelMatrix: buildSegmentsChannelMatrix(chartsData.audienceSegmentation.segmentsChannelMatrix),
|
||
keyTakeaways: chartsData.audienceSegmentation.keyTakeaways || []
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
|
||
case 'CHART_COMPETITORS':
|
||
const competitorsArray = Array.isArray(chartsData.competitors) ? chartsData.competitors : chartsData.competitors?.competitors || [];
|
||
|
||
if (competitorsArray.length > 0) {
|
||
const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach');
|
||
const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers');
|
||
|
||
const segment = {
|
||
type: 'competitors',
|
||
competitors: competitorsArray,
|
||
competitorsHeatmap: buildCompetitorsHeatmapData(competitorsArray),
|
||
reachChart: reachChartData
|
||
? {
|
||
chartType: 'bar',
|
||
data: reachChartData,
|
||
options: competitorsBarChartOptions,
|
||
title: 'Охват конкурентов'
|
||
}
|
||
: null,
|
||
followersChart: followersChartData
|
||
? {
|
||
chartType: 'bar',
|
||
data: followersChartData,
|
||
options: competitorsBarChartOptions,
|
||
title: 'Подписчики конкурентов'
|
||
}
|
||
: null
|
||
};
|
||
|
||
// Add marketShareChart if exists
|
||
if (chartsData.marketShareChart && Array.isArray(chartsData.marketShareChart)) {
|
||
const marketShareChartData = buildMarketShareChartData(chartsData.marketShareChart);
|
||
if (marketShareChartData) {
|
||
segment.marketShareChart = {
|
||
type: 'marketShareChart',
|
||
title: 'Доли рынка',
|
||
chartType: 'pie',
|
||
data: marketShareChartData,
|
||
options: pieChartOptions,
|
||
chartHeight: 'height: 300px'
|
||
};
|
||
}
|
||
}
|
||
|
||
// Add comparisonTable if exists
|
||
if (chartsData.comparisonTable && Array.isArray(chartsData.comparisonTable) && chartsData.comparisonTable.length > 0) {
|
||
segment.comparisonTable = {
|
||
type: 'comparisonTable',
|
||
data: chartsData.comparisonTable
|
||
};
|
||
}
|
||
|
||
return segment;
|
||
}
|
||
return null;
|
||
|
||
case 'CHART_SWOT':
|
||
// Check for swotCompetitors first
|
||
if (chartsData.swotCompetitors && typeof chartsData.swotCompetitors === 'object') {
|
||
return {
|
||
type: 'swotCompetitors',
|
||
data: {
|
||
strengths: chartsData.swotCompetitors.strengths || [],
|
||
weaknesses: chartsData.swotCompetitors.weaknesses || []
|
||
}
|
||
};
|
||
}
|
||
// Fallback to swot
|
||
if (chartsData.swot && typeof chartsData.swot === 'object') {
|
||
return {
|
||
type: 'swot',
|
||
data: {
|
||
strengths: chartsData.swot.strengths || [],
|
||
weaknesses: chartsData.swot.weaknesses || [],
|
||
opportunities: chartsData.swot.opportunities || [],
|
||
threats: chartsData.swot.threats || []
|
||
}
|
||
};
|
||
}
|
||
return null;
|
||
|
||
case 'CHART_CHANNELS':
|
||
if (chartsData.channelsPotential && Array.isArray(chartsData.channelsPotential)) {
|
||
const chartData = buildChannelsPotentialChartData(chartsData.channelsPotential);
|
||
if (chartData) {
|
||
return {
|
||
type: 'chart',
|
||
key: 'channelsPotential',
|
||
title: 'Потенциал каналов продвижения',
|
||
chartType: 'bar',
|
||
data: chartData,
|
||
options: channelsPotentialChartOptions,
|
||
chartHeight: 'height: 300px',
|
||
channels: chartsData.channelsPotential
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
|
||
case 'CHART_FUNNEL':
|
||
if (chartsData.conversionFunnel && Array.isArray(chartsData.conversionFunnel)) {
|
||
const chartData = buildConversionFunnelChartData(chartsData.conversionFunnel);
|
||
if (chartData) {
|
||
return {
|
||
type: 'chart',
|
||
key: 'funnel',
|
||
title: 'Воронка конверсии',
|
||
chartType: 'bar',
|
||
data: chartData,
|
||
options: conversionFunnelChartOptions,
|
||
chartHeight: 'height: 400px'
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
|
||
default:
|
||
return null;
|
||
}
|
||
};
|
||
|
||
// Helper function to get chart segment by JSON block identifier
|
||
const getChartSegmentByIdentifier = (identifier, chartsData) => {
|
||
if (!chartsData) return null;
|
||
const key = identifier?.toLowerCase();
|
||
|
||
switch (key) {
|
||
case 'audienceage': {
|
||
const chartData = normalizeChartJsData(chartsData.audienceAge);
|
||
if (!chartData) return null;
|
||
return {
|
||
type: 'chart',
|
||
key: 'audienceAge',
|
||
title: 'Распределение по возрасту',
|
||
chartType: 'bar',
|
||
data: chartData,
|
||
options: competitorsBarChartOptions,
|
||
chartHeight: 'height: 350px'
|
||
};
|
||
}
|
||
case 'audiencegender': {
|
||
const chartData = normalizeChartJsData(chartsData.audienceGender);
|
||
if (!chartData) return null;
|
||
return {
|
||
type: 'chart',
|
||
key: 'audienceGender',
|
||
title: 'Распределение по полу',
|
||
chartType: 'pie',
|
||
data: chartData,
|
||
options: pieChartOptions,
|
||
chartHeight: 'height: 350px'
|
||
};
|
||
}
|
||
case 'audiencesegmentation':
|
||
if (chartsData.audienceSegmentation) {
|
||
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData.audienceSegmentation);
|
||
if (ageGroupsChartData || gendersChartData) {
|
||
return {
|
||
type: 'audienceSegmentation',
|
||
ageGroupsChartData,
|
||
gendersChartData,
|
||
segmentsChannelMatrix: buildSegmentsChannelMatrix(chartsData.audienceSegmentation.segmentsChannelMatrix),
|
||
keyTakeaways: chartsData.audienceSegmentation.keyTakeaways || []
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
|
||
case 'competitors':
|
||
const competitorsArray = Array.isArray(chartsData.competitors) ? chartsData.competitors : chartsData.competitors?.competitors || [];
|
||
if (competitorsArray.length > 0) {
|
||
const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach');
|
||
const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers');
|
||
|
||
const segment = {
|
||
type: 'competitors',
|
||
competitors: competitorsArray,
|
||
competitorsHeatmap: buildCompetitorsHeatmapData(competitorsArray),
|
||
reachChart: reachChartData
|
||
? {
|
||
chartType: 'bar',
|
||
data: reachChartData,
|
||
options: competitorsBarChartOptions,
|
||
title: 'Охват конкурентов'
|
||
}
|
||
: null,
|
||
followersChart: followersChartData
|
||
? {
|
||
chartType: 'bar',
|
||
data: followersChartData,
|
||
options: competitorsBarChartOptions,
|
||
title: 'Подписчики конкурентов'
|
||
}
|
||
: null
|
||
};
|
||
|
||
// Add marketShareChart if exists
|
||
if (chartsData.marketShareChart && Array.isArray(chartsData.marketShareChart)) {
|
||
const marketShareChartData = buildMarketShareChartData(chartsData.marketShareChart);
|
||
if (marketShareChartData) {
|
||
segment.marketShareChart = {
|
||
type: 'marketShareChart',
|
||
title: 'Доли рынка',
|
||
chartType: 'pie',
|
||
data: marketShareChartData,
|
||
options: pieChartOptions,
|
||
chartHeight: 'height: 300px'
|
||
};
|
||
}
|
||
}
|
||
|
||
// Add comparisonTable if exists
|
||
if (chartsData.comparisonTable && Array.isArray(chartsData.comparisonTable) && chartsData.comparisonTable.length > 0) {
|
||
segment.comparisonTable = {
|
||
type: 'comparisonTable',
|
||
data: chartsData.comparisonTable
|
||
};
|
||
}
|
||
|
||
return segment;
|
||
}
|
||
return null;
|
||
|
||
case 'swot':
|
||
// Check for swotCompetitors first
|
||
if (chartsData.swotCompetitors && typeof chartsData.swotCompetitors === 'object') {
|
||
return {
|
||
type: 'swotCompetitors',
|
||
data: {
|
||
strengths: chartsData.swotCompetitors.strengths || [],
|
||
weaknesses: chartsData.swotCompetitors.weaknesses || []
|
||
}
|
||
};
|
||
}
|
||
// Fallback to swot
|
||
if (chartsData.swot && typeof chartsData.swot === 'object') {
|
||
return {
|
||
type: 'swot',
|
||
data: {
|
||
strengths: chartsData.swot.strengths || [],
|
||
weaknesses: chartsData.swot.weaknesses || [],
|
||
opportunities: chartsData.swot.opportunities || [],
|
||
threats: chartsData.swot.threats || []
|
||
}
|
||
};
|
||
}
|
||
return null;
|
||
|
||
case 'marketsharechart': {
|
||
const marketShareChartData = Array.isArray(chartsData.marketShareChart) ? buildMarketShareChartData(chartsData.marketShareChart) : normalizeChartJsData(chartsData.marketShareChart);
|
||
if (!marketShareChartData) return null;
|
||
return {
|
||
type: 'marketShareChart',
|
||
title: 'Доли рынка',
|
||
chartType: 'pie',
|
||
data: marketShareChartData,
|
||
options: pieChartOptions,
|
||
chartHeight: 'height: 300px'
|
||
};
|
||
}
|
||
case 'comparisontable': {
|
||
if (Array.isArray(chartsData.comparisonTable) && chartsData.comparisonTable.length > 0) {
|
||
return {
|
||
type: 'comparisonTable',
|
||
data: chartsData.comparisonTable
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
case 'channelspotential':
|
||
case 'channels':
|
||
if (chartsData.channelsPotential && Array.isArray(chartsData.channelsPotential)) {
|
||
const chartData = buildChannelsPotentialChartData(chartsData.channelsPotential);
|
||
if (chartData) {
|
||
return {
|
||
type: 'chart',
|
||
key: 'channelsPotential',
|
||
title: 'Потенциал каналов продвижения',
|
||
chartType: 'bar',
|
||
data: chartData,
|
||
options: channelsPotentialChartOptions,
|
||
chartHeight: 'height: 300px',
|
||
channels: chartsData.channelsPotential
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
|
||
case 'funnel':
|
||
case 'conversionfunnel':
|
||
if (chartsData.conversionFunnel && Array.isArray(chartsData.conversionFunnel)) {
|
||
const chartData = buildConversionFunnelChartData(chartsData.conversionFunnel);
|
||
if (chartData) {
|
||
return {
|
||
type: 'chart',
|
||
key: 'funnel',
|
||
title: 'Воронка конверсии',
|
||
chartType: 'bar',
|
||
data: chartData,
|
||
options: conversionFunnelChartOptions,
|
||
chartHeight: 'height: 400px'
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
|
||
case 'seasonality':
|
||
if (chartsData.seasonality && typeof chartsData.seasonality === 'object') {
|
||
const chartData = buildSeasonalityChartData(chartsData.seasonality);
|
||
if (chartData) {
|
||
return {
|
||
type: 'chart',
|
||
key: 'seasonality',
|
||
title: 'Сезонность спроса',
|
||
chartType: 'line',
|
||
data: chartData,
|
||
options: seasonalityChartOptions,
|
||
chartHeight: 'height: 300px'
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
|
||
default:
|
||
return null;
|
||
}
|
||
};
|
||
|
||
// Helper function to check if text is a JSON object that should be processed
|
||
const tryParseJsonSegment = (text) => {
|
||
if (!text || typeof text !== 'string') return null;
|
||
|
||
let trimmed = text.trim();
|
||
|
||
// Remove HTML tags if present (from rendered markdown code blocks)
|
||
if (trimmed.includes('<pre>') || trimmed.includes('<code>') || trimmed.includes('<')) {
|
||
// Try to extract JSON from HTML code blocks - handle nested tags
|
||
let codeContent = trimmed;
|
||
// Remove all HTML tags recursively
|
||
while (codeContent.includes('<') && codeContent.includes('>')) {
|
||
codeContent = codeContent.replace(/<[^>]+>/g, '');
|
||
}
|
||
// Decode common HTML entities
|
||
codeContent = codeContent
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, "'")
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/&/g, '&');
|
||
trimmed = codeContent.trim();
|
||
}
|
||
|
||
// Also try to extract JSON if it's wrapped in HTML but not in code tags
|
||
// Look for JSON-like patterns in the text
|
||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||
// Try to find JSON object/array in the text
|
||
const jsonMatch = trimmed.match(/\{[\s\S]*\}|\[[\s\S]*\]/);
|
||
if (jsonMatch) {
|
||
trimmed = jsonMatch[0].trim();
|
||
}
|
||
}
|
||
|
||
// Check if it's a JSON object or array
|
||
if ((!trimmed.startsWith('{') || !trimmed.endsWith('}')) && (!trimmed.startsWith('[') || !trimmed.endsWith(']'))) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const parsedJson = JSON.parse(trimmed);
|
||
|
||
// Check if it's an array of competitors
|
||
if (Array.isArray(parsedJson) && parsedJson.length > 0) {
|
||
// Check if it looks like competitors data
|
||
if (parsedJson[0]?.name && (parsedJson[0]?.channelsHeatmap || parsedJson[0]?.reach || parsedJson[0]?.followers)) {
|
||
return {
|
||
type: 'competitors',
|
||
competitors: parsedJson,
|
||
competitorsHeatmap: buildCompetitorsHeatmapData(parsedJson)
|
||
};
|
||
}
|
||
}
|
||
|
||
// Check if it's Chart.js format (datasets and labels)
|
||
if (parsedJson && typeof parsedJson === 'object' && !Array.isArray(parsedJson)) {
|
||
if (parsedJson.datasets && Array.isArray(parsedJson.datasets) && parsedJson.labels && Array.isArray(parsedJson.labels)) {
|
||
// Prepare chart data with proper formatting
|
||
const chartData = {
|
||
labels: parsedJson.labels,
|
||
datasets: parsedJson.datasets.map((dataset, index) => {
|
||
// Add default colors if not provided
|
||
const colors = ['#6366F1', '#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6'];
|
||
const color = dataset.backgroundColor || dataset.borderColor || colors[index % colors.length];
|
||
|
||
return {
|
||
...dataset,
|
||
backgroundColor: dataset.backgroundColor || color,
|
||
borderColor: dataset.borderColor || color,
|
||
borderRadius: dataset.borderRadius || 4
|
||
};
|
||
})
|
||
};
|
||
|
||
// Determine chart type based on dataset type or default to bar
|
||
const chartType = parsedJson.chartType || 'bar';
|
||
|
||
// Use appropriate options based on chart type
|
||
const options = chartType === 'pie' ? pieChartOptions : competitorsBarChartOptions;
|
||
|
||
// Get title from label or use default
|
||
const title = parsedJson.datasets[0]?.label || 'График';
|
||
|
||
return {
|
||
type: 'chart',
|
||
chartType: chartType,
|
||
data: chartData,
|
||
options: options,
|
||
title: title,
|
||
chartHeight: 'height: 400px'
|
||
};
|
||
}
|
||
}
|
||
|
||
// Check if it's an audienceSegmentation object
|
||
if (parsedJson && typeof parsedJson === 'object' && !Array.isArray(parsedJson)) {
|
||
if (parsedJson.genders || parsedJson.ageGroups || parsedJson.segments) {
|
||
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(parsedJson);
|
||
if (ageGroupsChartData || gendersChartData) {
|
||
return {
|
||
type: 'audienceSegmentation',
|
||
ageGroupsChartData,
|
||
gendersChartData,
|
||
segmentsChannelMatrix: buildSegmentsChannelMatrix(parsedJson.segmentsChannelMatrix),
|
||
keyTakeaways: parsedJson.keyTakeaways || []
|
||
};
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// Not valid JSON
|
||
console.debug('Failed to parse JSON segment:', e);
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// Parse fullAnalysis and replace placeholders and JSON blocks with charts from chartsData
|
||
const parseFullAnalysisWithCharts = (fullAnalysis, chartsData) => {
|
||
if (!fullAnalysis) return [];
|
||
|
||
const segments = [];
|
||
// Combined regex for both placeholders [[CHART_*]], JSON blocks ```json:identifier, and plain ```json blocks
|
||
const combinedRegex = /(\[\[CHART_(\w+)\]\]|```json:(\w+)\n([\s\S]*?)\n```|```json\n([\s\S]*?)\n```|```\n([\s\S]*?)\n```)/g;
|
||
let lastIndex = 0;
|
||
let match;
|
||
|
||
while ((match = combinedRegex.exec(fullAnalysis)) !== null) {
|
||
const [fullMatch, , chartTypePlaceholder, jsonIdentifier, jsonBlockWithId, jsonBlockPlain, codeBlock] = match;
|
||
|
||
// Add markdown before match
|
||
if (match.index > lastIndex) {
|
||
const textBefore = fullAnalysis.slice(lastIndex, match.index);
|
||
if (textBefore.trim()) {
|
||
// Extract JSON from markdown before rendering
|
||
const { processedText, jsonSegments: extractedSegments } = extractJsonFromMarkdown(textBefore);
|
||
|
||
// Add extracted JSON segments
|
||
extractedSegments.forEach((seg) => segments.push(seg));
|
||
|
||
// Add remaining markdown (without JSON blocks)
|
||
if (processedText.trim() && !processedText.match(/^__JSON_SEGMENT_\d+__$/)) {
|
||
const renderedHtml = renderMarkdown(processedText);
|
||
// Process rendered HTML to extract JSON from code blocks
|
||
const { htmlSegments, chartSegments } = extractJsonFromRenderedHtml(renderedHtml);
|
||
// Add chart segments first
|
||
chartSegments.forEach((seg) => segments.push(seg));
|
||
// Add remaining HTML
|
||
if (htmlSegments.length > 0) {
|
||
htmlSegments.forEach((html) => segments.push({ type: 'markdown', html }));
|
||
} else if (renderedHtml.trim()) {
|
||
segments.push({ type: 'markdown', html: renderedHtml });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handle code blocks that might contain JSON
|
||
if (codeBlock || jsonBlockPlain) {
|
||
const jsonContent = codeBlock || jsonBlockPlain;
|
||
// Try to parse as JSON and process
|
||
const jsonSegment = tryParseJsonSegment(jsonContent);
|
||
if (jsonSegment) {
|
||
segments.push(jsonSegment);
|
||
} else {
|
||
// If not JSON, add as markdown code block
|
||
segments.push({ type: 'markdown', html: renderMarkdown(fullMatch) });
|
||
}
|
||
lastIndex = combinedRegex.lastIndex;
|
||
continue;
|
||
}
|
||
|
||
// Handle placeholder [[CHART_*]]
|
||
if (chartTypePlaceholder) {
|
||
const chartSegment = getChartSegmentByPlaceholder(chartTypePlaceholder, chartsData);
|
||
if (chartSegment) {
|
||
segments.push(chartSegment);
|
||
}
|
||
}
|
||
// Handle JSON block ```json:identifier
|
||
else if (jsonIdentifier) {
|
||
const chartSegment = getChartSegmentByIdentifier(jsonIdentifier, chartsData);
|
||
if (chartSegment) {
|
||
segments.push(chartSegment);
|
||
} else {
|
||
// If no chart found, try to parse JSON and use old logic as fallback
|
||
try {
|
||
const parsedData = JSON.parse(jsonBlockWithId);
|
||
const segment = mapJsonBlockToSegment(jsonIdentifier, parsedData, fullMatch);
|
||
if (segment) {
|
||
segments.push(segment);
|
||
} else {
|
||
// If segment is null, don't add anything - the JSON block will be ignored
|
||
// This prevents raw JSON from being displayed
|
||
console.warn(`Не удалось обработать JSON блок ${jsonIdentifier}`);
|
||
}
|
||
} catch (error) {
|
||
console.error(`Не удалось распарсить блок ${jsonIdentifier}:`, error);
|
||
// Don't add raw JSON block to segments - it will be ignored
|
||
}
|
||
}
|
||
}
|
||
|
||
lastIndex = combinedRegex.lastIndex;
|
||
}
|
||
|
||
// Add remaining markdown
|
||
const remaining = fullAnalysis.slice(lastIndex);
|
||
if (remaining.trim()) {
|
||
// Extract JSON from markdown before rendering
|
||
const { processedText, jsonSegments: extractedSegments } = extractJsonFromMarkdown(remaining);
|
||
|
||
// Add extracted JSON segments
|
||
extractedSegments.forEach((seg) => segments.push(seg));
|
||
|
||
// Add remaining markdown (without JSON blocks)
|
||
if (processedText.trim() && !processedText.match(/^__JSON_SEGMENT_\d+__$/)) {
|
||
const renderedHtml = renderMarkdown(processedText);
|
||
// Process rendered HTML to extract JSON from code blocks
|
||
const { htmlSegments, chartSegments } = extractJsonFromRenderedHtml(renderedHtml);
|
||
// Add chart segments first
|
||
chartSegments.forEach((seg) => segments.push(seg));
|
||
// Add remaining HTML
|
||
if (htmlSegments.length > 0) {
|
||
htmlSegments.forEach((html) => segments.push({ type: 'markdown', html }));
|
||
} else if (renderedHtml.trim()) {
|
||
segments.push({ type: 'markdown', html: renderedHtml });
|
||
}
|
||
}
|
||
}
|
||
|
||
return segments;
|
||
};
|
||
|
||
// New computed property for chartsData
|
||
const chartsDataSegments = computed(() => {
|
||
if (!report.value?.chartsData) return [];
|
||
|
||
const chartsData = report.value.chartsData;
|
||
|
||
// Handle case when chartsData is an array directly (shouldn't happen, but handle it)
|
||
if (Array.isArray(chartsData)) {
|
||
// If it's an array of competitors, process it
|
||
if (chartsData.length > 0 && chartsData[0]?.name && (chartsData[0]?.channelsHeatmap || chartsData[0]?.reach || chartsData[0]?.followers)) {
|
||
const reachChartData = buildCompetitorsBarChartData(chartsData, 'reach');
|
||
const followersChartData = buildCompetitorsBarChartData(chartsData, 'followers');
|
||
|
||
return [
|
||
{
|
||
type: 'competitors',
|
||
competitors: chartsData,
|
||
competitorsHeatmap: buildCompetitorsHeatmapData(chartsData),
|
||
reachChart: reachChartData
|
||
? {
|
||
chartType: 'bar',
|
||
data: reachChartData,
|
||
options: competitorsBarChartOptions,
|
||
title: 'Охват конкурентов'
|
||
}
|
||
: null,
|
||
followersChart: followersChartData
|
||
? {
|
||
chartType: 'bar',
|
||
data: followersChartData,
|
||
options: competitorsBarChartOptions,
|
||
title: 'Подписчики конкурентов'
|
||
}
|
||
: null
|
||
}
|
||
];
|
||
}
|
||
// If it's an array with audienceSegmentation-like structure, process it
|
||
if (chartsData.length > 0 && (chartsData[0]?.genders || chartsData[0]?.ageGroups)) {
|
||
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData[0]);
|
||
if (ageGroupsChartData || gendersChartData) {
|
||
return [
|
||
{
|
||
type: 'audienceSegmentation',
|
||
ageGroupsChartData,
|
||
gendersChartData,
|
||
segmentsChannelMatrix: buildSegmentsChannelMatrix(chartsData[0].segmentsChannelMatrix),
|
||
keyTakeaways: chartsData[0].keyTakeaways || []
|
||
}
|
||
];
|
||
}
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// Handle case when chartsData is an audienceSegmentation object directly
|
||
// (has genders, ageGroups, segments, etc. but no audienceSegmentation property)
|
||
if (chartsData.genders || chartsData.ageGroups || chartsData.segments) {
|
||
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData);
|
||
if (ageGroupsChartData || gendersChartData) {
|
||
return [
|
||
{
|
||
type: 'audienceSegmentation',
|
||
ageGroupsChartData,
|
||
gendersChartData,
|
||
segmentsChannelMatrix: buildSegmentsChannelMatrix(chartsData.segmentsChannelMatrix),
|
||
keyTakeaways: chartsData.keyTakeaways || []
|
||
}
|
||
];
|
||
}
|
||
}
|
||
|
||
return mapChartsDataToSegments(chartsData);
|
||
});
|
||
|
||
// Main parsedContent that combines fullAnalysis with chartsData
|
||
const parsedContent = computed(() => {
|
||
let segments = [];
|
||
|
||
// If we have both fullAnalysis and chartsData, parse fullAnalysis and inject charts
|
||
if (report.value?.fullAnalysis && report.value?.chartsData) {
|
||
segments = parseFullAnalysisWithCharts(report.value.fullAnalysis, report.value.chartsData);
|
||
}
|
||
// If only chartsData exists, use it
|
||
else if (report.value?.chartsData) {
|
||
segments = chartsDataSegments.value;
|
||
}
|
||
// Otherwise, fall back to parsing fullAnalysis with JSON blocks (for old data)
|
||
else if (report.value?.fullAnalysis) {
|
||
const content = report.value.fullAnalysis;
|
||
// Regex for both ```json:identifier and plain ```json or ``` code blocks
|
||
const regex = /```json:(\w+)\n([\s\S]*?)\n```|```json\n([\s\S]*?)\n```|```\n([\s\S]*?)\n```/g;
|
||
let lastIndex = 0;
|
||
let match;
|
||
|
||
while ((match = regex.exec(content)) !== null) {
|
||
const [fullMatch, identifier, jsonBlockWithId, jsonBlockPlain, codeBlock] = match;
|
||
|
||
if (match.index > lastIndex) {
|
||
const textBefore = content.slice(lastIndex, match.index);
|
||
if (textBefore.trim()) {
|
||
// Extract JSON from markdown before rendering
|
||
const { processedText, jsonSegments: extractedSegments } = extractJsonFromMarkdown(textBefore);
|
||
|
||
// Add extracted JSON segments
|
||
extractedSegments.forEach((seg) => segments.push(seg));
|
||
|
||
// Add remaining markdown (without JSON blocks)
|
||
if (processedText.trim() && !processedText.match(/^__JSON_SEGMENT_\d+__$/)) {
|
||
segments.push({ type: 'markdown', html: renderMarkdown(processedText) });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handle code blocks that might contain JSON
|
||
if (codeBlock || jsonBlockPlain) {
|
||
const jsonContent = codeBlock || jsonBlockPlain;
|
||
// Try to parse as JSON and process
|
||
const jsonSegment = tryParseJsonSegment(jsonContent);
|
||
if (jsonSegment) {
|
||
segments.push(jsonSegment);
|
||
} else {
|
||
// If not JSON, add as markdown code block
|
||
segments.push({ type: 'markdown', html: renderMarkdown(fullMatch) });
|
||
}
|
||
lastIndex = regex.lastIndex;
|
||
continue;
|
||
}
|
||
|
||
// Handle ```json:identifier blocks
|
||
if (identifier && jsonBlockWithId) {
|
||
let parsedData = null;
|
||
try {
|
||
parsedData = JSON.parse(jsonBlockWithId);
|
||
} catch (error) {
|
||
console.error(`Не удалось распарсить блок ${identifier}:`, error);
|
||
}
|
||
|
||
const segment = mapJsonBlockToSegment(identifier, parsedData, fullMatch);
|
||
if (segment) {
|
||
segments.push(segment);
|
||
}
|
||
}
|
||
|
||
lastIndex = regex.lastIndex;
|
||
}
|
||
|
||
const remaining = content.slice(lastIndex);
|
||
if (remaining.trim()) {
|
||
// Extract JSON from markdown before rendering
|
||
const { processedText, jsonSegments: extractedSegments } = extractJsonFromMarkdown(remaining);
|
||
|
||
// Add extracted JSON segments
|
||
extractedSegments.forEach((seg) => segments.push(seg));
|
||
|
||
// Add remaining markdown (without JSON blocks)
|
||
if (processedText.trim() && !processedText.match(/^__JSON_SEGMENT_\d+__$/)) {
|
||
segments.push({ type: 'markdown', html: renderMarkdown(processedText) });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Filter out any segments without a valid type and ensure all segments are properly formatted
|
||
return segments.filter((segment) => {
|
||
// If segment doesn't have a type, try to process it
|
||
if (!segment || !segment.type) {
|
||
// If it's an array (like competitors), process it
|
||
if (Array.isArray(segment) && segment.length > 0 && segment[0]?.name) {
|
||
const processed = {
|
||
type: 'competitors',
|
||
competitors: segment,
|
||
competitorsHeatmap: buildCompetitorsHeatmapData(segment)
|
||
};
|
||
segments[segments.indexOf(segment)] = processed;
|
||
return true;
|
||
}
|
||
// If it's an object with audienceSegmentation-like structure, process it
|
||
if (segment && typeof segment === 'object' && (segment.genders || segment.ageGroups || segment.segments)) {
|
||
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(segment);
|
||
if (ageGroupsChartData || gendersChartData) {
|
||
const processed = {
|
||
type: 'audienceSegmentation',
|
||
ageGroupsChartData,
|
||
gendersChartData,
|
||
segmentsChannelMatrix: buildSegmentsChannelMatrix(segment.segmentsChannelMatrix),
|
||
keyTakeaways: segment.keyTakeaways || []
|
||
};
|
||
segments[segments.indexOf(segment)] = processed;
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
});
|
||
|
||
// Layout segments: group adjacent "paragraph markdown" + (chart/marketShareChart/comparisonTable) into a two-column container.
|
||
const isParagraphMarkdownSegment = (segment) => {
|
||
return segment?.type === 'markdown' && typeof segment.html === 'string' && /<p[\s>]/i.test(segment.html);
|
||
};
|
||
|
||
const isPairableMediaSegment = (segment) => {
|
||
return segment?.type === 'chart' || segment?.type === 'marketShareChart' || segment?.type === 'comparisonTable';
|
||
};
|
||
|
||
const displayContent = computed(() => {
|
||
const input = parsedContent.value || [];
|
||
const out = [];
|
||
|
||
for (let i = 0; i < input.length; i++) {
|
||
const cur = input[i];
|
||
const next = input[i + 1];
|
||
|
||
// Text then media
|
||
if (isParagraphMarkdownSegment(cur) && isPairableMediaSegment(next)) {
|
||
out.push({
|
||
type: 'paired',
|
||
text: cur,
|
||
media: next,
|
||
// on desktop we still keep text left; on mobile we can keep original (text first)
|
||
mobileOrder: 'text-first'
|
||
});
|
||
i++; // skip next
|
||
continue;
|
||
}
|
||
|
||
// Media then text
|
||
if (isPairableMediaSegment(cur) && isParagraphMarkdownSegment(next)) {
|
||
out.push({
|
||
type: 'paired',
|
||
text: next,
|
||
media: cur,
|
||
// keep original order on mobile (chart first)
|
||
mobileOrder: 'chart-first'
|
||
});
|
||
i++; // skip next
|
||
continue;
|
||
}
|
||
|
||
out.push(cur);
|
||
}
|
||
|
||
return out;
|
||
});
|
||
|
||
const toCompactChartOptions = (options) => {
|
||
const base = options && typeof options === 'object' ? options : {};
|
||
|
||
const basePlugins = base.plugins && typeof base.plugins === 'object' ? base.plugins : {};
|
||
const baseLegend = basePlugins.legend && typeof basePlugins.legend === 'object' ? basePlugins.legend : {};
|
||
const baseLegendLabels = baseLegend.labels && typeof baseLegend.labels === 'object' ? baseLegend.labels : {};
|
||
|
||
// Keep "legend.display: false" if it was explicitly turned off
|
||
const legendDisplay = typeof baseLegend.display === 'boolean' ? baseLegend.display : true;
|
||
|
||
const compact = {
|
||
...base,
|
||
responsive: true,
|
||
// For this request: keep aspect ratio behavior, but make it visually compact via aspectRatio + CSS max-height
|
||
maintainAspectRatio: true,
|
||
aspectRatio: typeof base.aspectRatio === 'number' ? base.aspectRatio : 3,
|
||
layout: {
|
||
...(base.layout && typeof base.layout === 'object' ? base.layout : {}),
|
||
padding: 0
|
||
},
|
||
plugins: {
|
||
...basePlugins,
|
||
legend: {
|
||
...baseLegend,
|
||
display: legendDisplay,
|
||
// Avoid legend eating vertical space in compact mode
|
||
position: baseLegend.position || 'bottom',
|
||
labels: {
|
||
...baseLegendLabels,
|
||
padding: 6,
|
||
boxWidth: 10,
|
||
boxHeight: 10,
|
||
usePointStyle: true,
|
||
font: {
|
||
...(baseLegendLabels.font && typeof baseLegendLabels.font === 'object' ? baseLegendLabels.font : {}),
|
||
size: 10
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// Compact axis labels/ticks if scales exist
|
||
if (compact.scales && typeof compact.scales === 'object') {
|
||
const nextScales = { ...compact.scales };
|
||
for (const axisKey of Object.keys(nextScales)) {
|
||
const axis = nextScales[axisKey];
|
||
if (!axis || typeof axis !== 'object') continue;
|
||
const ticks = axis.ticks && typeof axis.ticks === 'object' ? axis.ticks : {};
|
||
const title = axis.title && typeof axis.title === 'object' ? axis.title : {};
|
||
|
||
nextScales[axisKey] = {
|
||
...axis,
|
||
ticks: {
|
||
...ticks,
|
||
autoSkip: typeof ticks.autoSkip === 'boolean' ? ticks.autoSkip : true,
|
||
maxRotation: typeof ticks.maxRotation === 'number' ? ticks.maxRotation : 0,
|
||
padding: 4,
|
||
font: {
|
||
...(ticks.font && typeof ticks.font === 'object' ? ticks.font : {}),
|
||
size: 10
|
||
}
|
||
},
|
||
title: {
|
||
...title,
|
||
padding: typeof title.padding !== 'undefined' ? title.padding : 4,
|
||
font: {
|
||
...(title.font && typeof title.font === 'object' ? title.font : {}),
|
||
size: 11
|
||
}
|
||
}
|
||
};
|
||
}
|
||
compact.scales = nextScales;
|
||
}
|
||
|
||
return compact;
|
||
};
|
||
|
||
const pieChartOptions = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
display: true,
|
||
position: 'bottom'
|
||
},
|
||
tooltip: {
|
||
callbacks: {
|
||
label: function (context) {
|
||
return context.label + ': ' + context.parsed + '%';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// Get analysis type label
|
||
const getAnalysisTypeLabel = (type) => {
|
||
const labels = {
|
||
РЫНОК: 'Анализ внешней среды',
|
||
КОНКУРЕНТЫ: 'Анализ конкурентов',
|
||
ЦА: 'Анализ целевой аудитории',
|
||
КАНАЛЫ: 'Анализ рекламных каналов',
|
||
SWOT: 'SWOT-анализ'
|
||
};
|
||
return labels[type] || type;
|
||
};
|
||
|
||
// Format date time
|
||
const formatDateTime = (dateString) => {
|
||
if (!dateString) return '';
|
||
const date = new Date(dateString);
|
||
date.setHours(date.getHours() + 5);
|
||
return date.toLocaleString('ru-RU', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
};
|
||
|
||
// Navigate to strategy page
|
||
const goToStrategy = () => {
|
||
if (analysisId.value) {
|
||
router.push({
|
||
name: 'marketing-promotion',
|
||
query: { analysisId: analysisId.value }
|
||
});
|
||
} else {
|
||
toast.add({
|
||
severity: 'warn',
|
||
summary: 'Предупреждение',
|
||
detail: 'ID анализа не найден',
|
||
life: 3000
|
||
});
|
||
}
|
||
};
|
||
|
||
// Load analysis from query params
|
||
onMounted(async () => {
|
||
const queryAnalysisId = route.query.analysisId;
|
||
if (queryAnalysisId) {
|
||
// Try to load existing analysis
|
||
try {
|
||
analysisId.value = queryAnalysisId;
|
||
const result = await MarketingService.getAnalysisResult(queryAnalysisId);
|
||
status.value = result.status;
|
||
|
||
// Сохраняем тип анализа, если он есть
|
||
if (result.analysisType) {
|
||
currentAnalysisType.value = result.analysisType;
|
||
}
|
||
|
||
if (result.status === 'completed' && result.report) {
|
||
report.value = result.report;
|
||
} else if (result.status === 'processing' || result.status === 'queued') {
|
||
estimatedCompletionTime.value = result.estimatedCompletionTime;
|
||
startPolling(queryAnalysisId);
|
||
}
|
||
} catch (error) {
|
||
console.error('Ошибка при загрузке анализа:', error);
|
||
toast.add({
|
||
severity: 'error',
|
||
summary: 'Ошибка',
|
||
detail: error.message || 'Не удалось загрузить анализ',
|
||
life: 5000
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// Cleanup on unmount
|
||
onBeforeUnmount(() => {
|
||
stopPolling();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.marketing-analysis-page {
|
||
font-family: var(--font-family);
|
||
}
|
||
|
||
.card {
|
||
background: var(--surface-card);
|
||
border-radius: 12px;
|
||
box-shadow:
|
||
0 1px 3px rgba(0, 0, 0, 0.12),
|
||
0 1px 2px rgba(0, 0, 0, 0.24);
|
||
padding: 1.5rem;
|
||
}
|
||
|
||
.card-header {
|
||
border-bottom: 1px solid var(--surface-border);
|
||
padding-bottom: 1rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.card-content {
|
||
padding-top: 0;
|
||
}
|
||
|
||
.markdown-content {
|
||
line-height: 1.7;
|
||
color: var(--text-color);
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
.markdown-content :deep(h1) {
|
||
font-size: 1.75rem;
|
||
font-weight: 700;
|
||
margin-top: 1.5rem;
|
||
margin-bottom: 1rem;
|
||
color: var(--text-color);
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.markdown-content :deep(h1:first-child) {
|
||
margin-top: 0;
|
||
}
|
||
|
||
.markdown-content :deep(h2) {
|
||
font-size: 1.5rem;
|
||
font-weight: 600;
|
||
margin-top: 1.5rem;
|
||
margin-bottom: 0.75rem;
|
||
color: var(--text-color);
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.markdown-content :deep(h3) {
|
||
font-size: 1.25rem;
|
||
font-weight: 600;
|
||
margin-top: 1.25rem;
|
||
margin-bottom: 0.75rem;
|
||
color: var(--text-color);
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.markdown-content :deep(h4) {
|
||
font-size: 1.1rem;
|
||
font-weight: 600;
|
||
margin-top: 1rem;
|
||
margin-bottom: 0.5rem;
|
||
color: var(--text-color);
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.markdown-content :deep(h5),
|
||
.markdown-content :deep(h6) {
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
margin-top: 0.75rem;
|
||
margin-bottom: 0.5rem;
|
||
color: var(--text-color);
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.markdown-content :deep(p) {
|
||
margin-bottom: 1rem;
|
||
color: var(--text-color);
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.markdown-content :deep(p:last-child) {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.markdown-content :deep(ul),
|
||
.markdown-content :deep(ol) {
|
||
margin-top: 0.5rem;
|
||
margin-bottom: 1rem;
|
||
padding-left: 2rem;
|
||
color: var(--text-color);
|
||
}
|
||
|
||
.markdown-content :deep(ul) {
|
||
list-style-type: disc;
|
||
}
|
||
|
||
.markdown-content :deep(ol) {
|
||
list-style-type: decimal;
|
||
}
|
||
|
||
.markdown-content :deep(li) {
|
||
margin-bottom: 0.5rem;
|
||
line-height: 1.7;
|
||
color: var(--text-color);
|
||
}
|
||
|
||
.markdown-content :deep(li > p) {
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.markdown-content :deep(li > ul),
|
||
.markdown-content :deep(li > ol) {
|
||
margin-top: 0.5rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.markdown-content :deep(strong) {
|
||
font-weight: 600;
|
||
color: var(--text-color);
|
||
}
|
||
|
||
.markdown-content :deep(em) {
|
||
font-style: italic;
|
||
}
|
||
|
||
.markdown-content :deep(code) {
|
||
background-color: var(--surface-100);
|
||
padding: 0.125rem 0.375rem;
|
||
border-radius: 0.25rem;
|
||
font-size: 0.875em;
|
||
font-family: 'Courier New', monospace;
|
||
}
|
||
|
||
.markdown-content :deep(pre) {
|
||
background-color: var(--surface-100);
|
||
padding: 1rem;
|
||
border-radius: 0.5rem;
|
||
overflow-x: auto;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.markdown-content :deep(pre code) {
|
||
background-color: transparent;
|
||
padding: 0;
|
||
}
|
||
|
||
.markdown-content :deep(table) {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin-top: 1rem;
|
||
margin-bottom: 1rem;
|
||
border: 1px solid var(--surface-border);
|
||
border-radius: 0.5rem;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.markdown-content :deep(thead) {
|
||
background-color: var(--surface-100);
|
||
}
|
||
|
||
.markdown-content :deep(th) {
|
||
padding: 0.75rem 1rem;
|
||
text-align: left;
|
||
font-weight: 600;
|
||
color: var(--text-color);
|
||
border-bottom: 2px solid var(--surface-border);
|
||
}
|
||
|
||
.markdown-content :deep(td) {
|
||
padding: 0.75rem 1rem;
|
||
border-bottom: 1px solid var(--surface-border);
|
||
color: var(--text-color);
|
||
}
|
||
|
||
.markdown-content :deep(tr:last-child td) {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.markdown-content :deep(tr:hover) {
|
||
background-color: var(--surface-50);
|
||
}
|
||
|
||
.report-pair {
|
||
/* Use text wrapping around the chart to avoid empty whitespace under the chart */
|
||
display: block;
|
||
}
|
||
|
||
.report-pair__text,
|
||
.report-pair__media {
|
||
min-width: 0;
|
||
}
|
||
|
||
/* .report-pair__text: text wraps around floated media (no extra styling needed) */
|
||
|
||
.report-pair__media {
|
||
float: right;
|
||
width: 40%;
|
||
max-width: 40%;
|
||
margin-left: 1.5rem; /* gap between text and chart */
|
||
margin-bottom: 0.75rem; /* let text wrap under after chart ends */
|
||
}
|
||
|
||
.report-pair__mediaChart {
|
||
width: 100%;
|
||
}
|
||
|
||
.report-pair__chart-container {
|
||
width: 100%;
|
||
/* Keep charts compact without forcing wrapper height */
|
||
max-height: 250px;
|
||
height: auto;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* clearfix for floated media */
|
||
.report-pair::after {
|
||
content: '';
|
||
display: block;
|
||
clear: both;
|
||
}
|
||
|
||
/* Remove top/bottom margins inside paired markdown so text starts flush with chart title/area */
|
||
.report-pair__text .markdown-content :deep(> *:first-child) {
|
||
margin-top: 0 !important;
|
||
}
|
||
.report-pair__text .markdown-content :deep(> *:last-child) {
|
||
margin-bottom: 0 !important;
|
||
}
|
||
|
||
/* Ensure Chart.js canvas doesn't add extra whitespace and respects container width */
|
||
.report-pair__chart-container :deep(canvas) {
|
||
width: 100% !important;
|
||
height: auto !important;
|
||
max-height: 250px;
|
||
display: block;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.report-pair {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* Preserve original order when the report had chart before text */
|
||
.report-pair--mobile-chart-first .report-pair__media {
|
||
order: 1;
|
||
}
|
||
.report-pair--mobile-chart-first .report-pair__text {
|
||
order: 2;
|
||
}
|
||
|
||
.report-pair__media {
|
||
float: none;
|
||
width: 100%;
|
||
max-width: none;
|
||
margin-left: 0;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
/* Preserve original order on mobile */
|
||
.report-pair--mobile-text-first .report-pair__text {
|
||
order: 1;
|
||
}
|
||
.report-pair--mobile-text-first .report-pair__media {
|
||
order: 2;
|
||
}
|
||
.report-pair--mobile-chart-first .report-pair__media {
|
||
order: 1;
|
||
}
|
||
.report-pair--mobile-chart-first .report-pair__text {
|
||
order: 2;
|
||
}
|
||
}
|
||
|
||
.chart-container {
|
||
position: relative;
|
||
}
|
||
|
||
/* Channel Matrix Table Styles */
|
||
.channel-matrix-table {
|
||
border: 1px solid var(--surface-border);
|
||
border-radius: 0.5rem;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.channel-matrix-th {
|
||
padding: 0.75rem 1rem;
|
||
text-align: left;
|
||
font-weight: 600;
|
||
color: var(--text-color);
|
||
background-color: var(--surface-100);
|
||
border-bottom: 2px solid var(--surface-border);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.channel-matrix-td {
|
||
padding: 0.75rem 1rem;
|
||
border-bottom: 1px solid var(--surface-border);
|
||
color: var(--text-color);
|
||
}
|
||
|
||
.channel-matrix-tr:last-child .channel-matrix-td {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.channel-matrix-tr:hover {
|
||
background-color: var(--surface-50);
|
||
}
|
||
|
||
.channel-matrix-badge {
|
||
display: inline-block;
|
||
padding: 0.375rem 0.75rem;
|
||
border-radius: 0.375rem;
|
||
color: white;
|
||
font-weight: 600;
|
||
font-size: 0.875rem;
|
||
min-width: 70px;
|
||
text-align: center;
|
||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
/* Competitors Heatmap Table Styles */
|
||
.competitors-heatmap-table {
|
||
border: 1px solid var(--surface-border);
|
||
border-radius: 0.5rem;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.competitors-heatmap-th {
|
||
padding: 0.75rem 1rem;
|
||
text-align: left;
|
||
font-weight: 600;
|
||
color: var(--text-color);
|
||
background-color: var(--surface-100);
|
||
border-bottom: 2px solid var(--surface-border);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.competitors-heatmap-td {
|
||
padding: 0.75rem 1rem;
|
||
border-bottom: 1px solid var(--surface-border);
|
||
color: var(--text-color);
|
||
}
|
||
|
||
.competitors-heatmap-tr:last-child .competitors-heatmap-td {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.competitors-heatmap-tr:hover {
|
||
background-color: var(--surface-50);
|
||
}
|
||
|
||
.competitors-heatmap-badge {
|
||
display: inline-block;
|
||
padding: 0.5rem 0.875rem;
|
||
border-radius: 0.375rem;
|
||
color: white;
|
||
font-weight: 600;
|
||
font-size: 0.875rem;
|
||
min-width: 55px;
|
||
text-align: center;
|
||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
/* Comparison Table Styles */
|
||
.comparison-table {
|
||
border: 1px solid var(--surface-border);
|
||
border-radius: 0.5rem;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.comparison-table-th {
|
||
padding: 0.75rem 1rem;
|
||
text-align: left;
|
||
font-weight: 600;
|
||
color: var(--text-color);
|
||
background-color: var(--surface-100);
|
||
border-bottom: 2px solid var(--surface-border);
|
||
}
|
||
|
||
.comparison-table-td {
|
||
padding: 0.75rem 1rem;
|
||
border-bottom: 1px solid var(--surface-border);
|
||
color: var(--text-color);
|
||
}
|
||
|
||
.comparison-table-tr:last-child .comparison-table-td {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.comparison-table-tr:hover {
|
||
background-color: var(--surface-50);
|
||
}
|
||
</style>
|