Files
marketing/src/views/pages/marketing/MarketingAnalysis.vue
T
2026-01-22 21:41:16 +05:00

3745 lines
183 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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="marketingBudget" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Бюджет на маркетинг </label>
<InputNumber id="marketingBudget" v-model="formData.marketingBudget" :min="0" placeholder="Введите бюджет в тенге" class="w-full" :class="{ 'p-invalid': errors.marketingBudget }" />
<small v-if="errors.marketingBudget" class="p-error">{{ errors.marketingBudget }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Укажите ваш бюджет на маркетинг (опционально)</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>
<!-- Единая страница: v2 dashboard сверху + старый анализ ниже -->
<div v-else class="space-y-4">
<!-- v2 Dashboard (показываем первым) -->
<div v-if="v2Loading || v2ReportNormalized" class="card">
<div class="card-content">
<MarketingAnalysisPptDashboard embedded :report="v2ReportNormalized" :loading="v2Loading" :error="v2Error" />
</div>
</div>
<!-- Статус обработки (старый анализ) -->
<div v-if="status === 'processing' || status === 'queued'" class="space-y-4">
<!-- Заголовок с кнопкой -->
<div class="card">
<div class="card-header mb-4">
<div class="flex flex-wrap justify-content-between align-items-center gap-3">
<div class="flex-1 min-w-0">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Анализ выполняется</h2>
</div>
<div class="flex gap-2 align-items-center flex-shrink-0">
<Button :label="isAnalysisOpen ? 'Скрыть анализ' : 'Показать анализ'" :icon="isAnalysisOpen ? 'pi pi-chevron-up' : 'pi pi-chevron-down'" severity="secondary" outlined @click="isAnalysisOpen = !isAnalysisOpen" />
</div>
</div>
</div>
</div>
<div v-show="isAnalysisOpen" class="card">
<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>
<!-- Ошибка -->
<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 flex-wrap justify-content-between align-items-center gap-3">
<div class="flex-1 min-w-0">
<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>
<div class="flex gap-2 align-items-center flex-shrink-0">
<Button :label="isAnalysisOpen ? 'Скрыть анализ' : 'Показать анализ'" :icon="isAnalysisOpen ? 'pi pi-chevron-up' : 'pi pi-chevron-down'" severity="secondary" outlined @click="isAnalysisOpen = !isAnalysisOpen" />
<Button label="Скачать PDF" icon="pi pi-download" @click="handleDownloadPdf" :loading="downloadingPdf" />
</div>
</div>
</div>
</div>
<div v-show="isAnalysisOpen" class="space-y-4">
<!-- Краткое резюме (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">
<!-- IMPORTANT: preserve original markdown order in DOM -->
<template v-if="segment.order === 'text-media'">
<div class="report-pair__text">
<div class="report-pair__textStack">
<div v-for="(t, tIdx) in segment.texts || []" :key="tIdx" class="markdown-content" v-html="t.html"></div>
</div>
</div>
<div class="report-pair__media">
<div v-if="segment.media.type === 'chart'">
<div class="report-pair__mediaChart">
<div class="chart-container report-pair__chart-container">
<Chart v-if="hasRenderableChartData(segment.media.data)" :type="segment.media.chartType" :data="segment.media.data" :options="toCompactChartOptions(segment.media.options)" />
<div v-else class="p-3 border-round text-center text-surface-500 dark:text-surface-400">Нет данных для отображения</div>
</div>
<div v-if="segment.media.title" class="chart-caption text-surface-700 dark:text-surface-300">{{ segment.media.title }}</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="report-pair__mediaChart">
<div class="chart-container report-pair__chart-container">
<Chart v-if="hasRenderableChartData(segment.media.data)" :type="segment.media.chartType" :data="segment.media.data" :options="toCompactChartOptions(segment.media.options)" />
<div v-else class="p-3 border-round text-center text-surface-500 dark:text-surface-400">Нет данных для отображения</div>
</div>
<div v-if="segment.media.title" class="chart-caption text-surface-700 dark:text-surface-300">{{ segment.media.title }}</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>
</template>
<template v-else>
<div class="report-pair__media">
<div v-if="segment.media.type === 'chart'">
<div class="report-pair__mediaChart">
<div class="chart-container report-pair__chart-container">
<Chart v-if="hasRenderableChartData(segment.media.data)" :type="segment.media.chartType" :data="segment.media.data" :options="toCompactChartOptions(segment.media.options)" />
<div v-else class="p-3 border-round text-center text-surface-500 dark:text-surface-400">Нет данных для отображения</div>
</div>
<div v-if="segment.media.title" class="chart-caption text-surface-700 dark:text-surface-300">{{ segment.media.title }}</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="report-pair__mediaChart">
<div class="chart-container report-pair__chart-container">
<Chart v-if="hasRenderableChartData(segment.media.data)" :type="segment.media.chartType" :data="segment.media.data" :options="toCompactChartOptions(segment.media.options)" />
<div v-else class="p-3 border-round text-center text-surface-500 dark:text-surface-400">Нет данных для отображения</div>
</div>
<div v-if="segment.media.title" class="chart-caption text-surface-700 dark:text-surface-300">{{ segment.media.title }}</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="report-pair__textStack">
<div v-for="(t, tIdx) in segment.texts || []" :key="tIdx" class="markdown-content" v-html="t.html"></div>
</div>
</div>
</template>
</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="chart-container" :style="segment.chartHeight">
<Chart v-if="hasRenderableChartData(segment.data)" :type="segment.chartType" :data="segment.data" :options="segment.options" />
<div v-else class="p-3 border-round text-center text-surface-500 dark:text-surface-400">Нет данных для отображения</div>
</div>
<div v-if="segment.title" class="chart-caption text-surface-700 dark:text-surface-300">{{ segment.title }}</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) -->
<SegmentsChannelMatrixHeatmap v-if="segment.segmentsChannelMatrix" :matrix="segment.segmentsChannelMatrix" />
<!-- 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 === 'segmentsChannelMatrix'">
<HeatmapTable :matrix="segment.matrix" :title="segment.title || 'Матрица каналов по сегментам'" />
</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 v-if="hasRenderableChartData(segment.marketShareChart.data)" :type="segment.marketShareChart.chartType" :data="segment.marketShareChart.data" :options="segment.marketShareChart.options" />
<div v-else class="p-3 border-round text-center text-surface-500 dark:text-surface-400">Нет данных для отображения</div>
</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 v-if="hasRenderableChartData(segment.data)" :type="segment.chartType" :data="segment.data" :options="segment.options" />
<div v-else class="p-3 border-round text-center text-surface-500 dark:text-surface-400">Нет данных для отображения</div>
</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>
</div>
<!-- Кнопки действий (всегда видны, когда есть анализ) -->
<div v-if="(status === 'completed' && report) || v2ReportNormalized" 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 SegmentsChannelMatrixHeatmap from '@/components/marketing/SegmentsChannelMatrixHeatmap.vue';
import MarketingService from '@/service/MarketingService';
import MarketingAnalysisPptDashboard from '@/views/pages/MarketingAnalysis.vue';
import { marked } from 'marked';
import markedKatex from 'marked-katex-extension';
import Button from 'primevue/button';
import Chart from 'primevue/chart';
import Checkbox from 'primevue/checkbox';
import InputNumber from 'primevue/inputnumber';
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();
let isKatexMarkedConfigured = false;
// 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: '',
marketingBudget: null,
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 isAnalysisOpen = ref(false);
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);
// v2 dashboard state (loaded via start?generateV2=true + GET /analysis/v2/{id})
const v2AnalysisId = ref(null);
const v2Report = ref(null); // raw v2 payload from GET /api/marketing/analysis/v2/{id}
const v2Loading = ref(false);
const v2Error = ref(null);
const isV2ReportPayload = (payload) => {
if (!payload || typeof payload !== 'object') return false;
if (payload.report_title || payload.reportTitle) return true;
if (payload.sections && typeof payload.sections === 'object') return true;
return false;
};
const applyV2Report = (payload) => {
v2Report.value = payload;
v2Loading.value = false;
v2Error.value = null;
};
const v2ReportNormalized = computed(() => {
const root = v2Report.value;
if (!root) return null;
const title = root.reportTitle || root.report_title || '';
const s = root.sections || {};
const pick = (camel, snake) => s[camel] || s[snake] || null;
return {
report_title: title,
sections: {
market_overview: pick('marketOverview', 'market_overview'),
target_audience: pick('targetAudience', 'target_audience'),
competitive_environment: pick('competitiveEnvironment', 'competitive_environment'),
demand_and_behavior: pick('demandAndBehavior', 'demand_and_behavior'),
customer_journey: pick('customerJourney', 'customer_journey'),
acquisition_channels: pick('acquisitionChannels', 'acquisition_channels'),
marketing_economics: pick('marketingEconomics', 'marketing_economics'),
bottlenecks: pick('bottlenecks', 'bottlenecks'),
recommendations: pick('recommendations', 'recommendations')
}
};
});
const drawBarValues = (chart) => {
const ctx = chart.ctx;
ctx.save();
ctx.font = 'bold 14px "Inter", sans-serif'; // Шрифт покрупнее, как на фото
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
chart.data.datasets.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i);
if (!meta.hidden) {
meta.data.forEach((bar, index) => {
const value = dataset.data[index];
if (value !== null && value !== undefined && value != 0) {
let label = value.toLocaleString();
// Если это проценты, добавляем знак %
if ((dataset.unit === '%') || (typeof dataset.data[index] === 'string' && String(dataset.data[index]).includes('%'))) {
label = String(value).replace('%', '') + '%';
}
const isHorizontal = meta.indexAxis === 'y';
if (isHorizontal) {
// Горизонтальный: цифра справа темная
ctx.fillStyle = document.documentElement.classList.contains('dark') ? '#ffffff' : '#1f2937';
ctx.textAlign = 'left';
ctx.fillText(label, bar.x + 8, bar.y);
} else {
// === ВЕРТИКАЛЬНЫЙ (КАК НА ФОТО) ===
const barHeight = Math.abs(bar.base - bar.y);
// Если столбец достаточно высокий (>25px), рисуем цифру ВНУТРИ белым цветом
if (barHeight > 25) {
ctx.fillStyle = '#ffffff';
// bar.y - это верхушка столбца. Добавляем 15px, чтобы спустить цифру чуть ниже верха
ctx.fillText(label, bar.x, bar.y + 15);
} else {
// Если столбец мелкий, рисуем цифру СВЕРХУ темным цветом
ctx.fillStyle = document.documentElement.classList.contains('dark') ? '#ffffff' : '#1f2937';
ctx.fillText(label, bar.x, bar.y - 12);
}
}
}
});
}
});
ctx.restore();
};
// Опции: Дизайн "Как на фото" (С цифрами внутри)
const photoStyleBarOptions = {
responsive: true,
maintainAspectRatio: false,
layout: { padding: { top: 25 } }, // Отступ сверху для тултипов
animation: {
duration: 500,
onComplete: (animation) => drawBarValues(animation.chart)
},
plugins: {
legend: { display: false }, // Легенда не нужна
tooltip: {
enabled: true,
callbacks: { label: (ctx) => `${ctx.formattedValue}%` }
}
},
scales: {
y: {
display: true, // Ось Y оставляем (как на фото 0, 10, 20...), но можно поставить false, если надо скрыть
beginAtZero: true,
grid: { display: true, drawBorder: false, color: 'rgba(0,0,0,0.05)' },
ticks: { font: { size: 10 } }
},
x: {
grid: { display: false }, // Вертикальные линии убираем
ticks: {
font: { size: 12, weight: '600' },
color: '#64748b'
}
}
}
};
// Опции: Горизонтальные бары
const horizontalBarChartOptions = {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
layout: { padding: { right: 40 } },
animation: {
duration: 500,
onComplete: (animation) => drawBarValues(animation.chart)
},
plugins: { legend: { display: false } },
scales: {
x: { display: false, beginAtZero: true },
y: {
grid: { display: false },
ticks: { font: { size: 12, weight: '500' }, crossAlign: 'far' }
}
}
};
// Опции: Круговая диаграмма
const pieChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true, position: 'bottom', align: 'center' },
tooltip: {
callbacks: { label: (context) => context.label + ': ' + context.parsed + '%' }
}
}
};
// Опции: Линейный график (Сезонность)
const seasonalityChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: true, position: 'bottom' } },
scales: { y: { beginAtZero: true } }
};
// Опции: Воронка
const conversionFunnelChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: true, position: 'bottom' } },
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Пользователи' } }
}
};
// Опции: Потенциал каналов
const channelsPotentialChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, max: 100 } }
};
// Хелпер для цветов
const MARKETING_COLORS = ['#4F46E5', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'];
const getMarketingColors = (count, offset = 0) => {
const safeCount = Math.max(0, Number(count) || 0);
if (safeCount === 0) return [];
return Array.from({ length: safeCount }, (_, i) => MARKETING_COLORS[(i + offset) % MARKETING_COLORS.length]);
};
const parsePercentageData = (obj) => {
if (!obj || typeof obj !== 'object') return { labels: [], values: [] };
const labels = Object.keys(obj).map(k => k.replace(/_/g, ' '));
const values = Object.values(obj).map(v => {
if (typeof v === 'string') return parseFloat(v.replace('%', ''));
return v;
});
return { labels, values };
};
const refreshV2 = async () => {
if (!v2AnalysisId.value) return;
v2Loading.value = true;
v2Error.value = null;
try {
const data = await MarketingService.getAnalysisV2ById(v2AnalysisId.value);
v2Report.value = data;
} catch (e) {
v2Error.value = e?.message || 'Не удалось загрузить v2 отчёт';
toast.add({
severity: 'warn',
summary: 'v2 недоступен',
detail: v2Error.value,
life: 4000
});
} finally {
v2Loading.value = 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);
if (!result) return;
if (isV2ReportPayload(result)) {
stopPolling();
applyV2Report(result);
status.value = 'completed';
return;
}
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;
v2Report.value = null;
v2AnalysisId.value = null;
v2Error.value = null;
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();
}
if (formData.value.marketingBudget !== null && formData.value.marketingBudget !== undefined) {
requestData.marketing_budget = formData.value.marketingBudget;
}
const result = await MarketingService.startAnalysis(requestData, { generateV2: true });
// Обработка ответа: может быть массив или один объект
if (Array.isArray(result)) {
// Множественные анализы
const firstAnalysis = result[0];
analysisId.value = firstAnalysis.analysisId;
status.value = firstAnalysis.status;
estimatedCompletionTime.value = firstAnalysis.estimatedCompletion;
v2AnalysisId.value = firstAnalysis.v2AnalysisId || null;
router.replace({ query: { ...route.query, analysisId: analysisId.value, v2AnalysisId: v2AnalysisId.value || undefined } });
// Сохраняем типы анализов
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);
// v2: загрузка сохраненного отчёта (если есть)
if (v2AnalysisId.value) {
await refreshV2();
}
} 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;
v2AnalysisId.value = result.v2AnalysisId || null;
router.replace({ query: { ...route.query, analysisId: analysisId.value, v2AnalysisId: v2AnalysisId.value || undefined } });
toast.add({
severity: 'success',
summary: 'Анализ запущен',
detail: result.message || 'Анализ успешно запущен. Результаты будут готовы в течение 5-10 минут.',
life: 5000
});
// Start polling
startPolling(result.analysisId);
// v2: загрузка сохраненного отчёта (если есть)
if (v2AnalysisId.value) {
await refreshV2();
}
}
} 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;
v2AnalysisId.value = null;
v2Report.value = null;
v2Loading.value = false;
v2Error.value = null;
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+))?)?\r?\n([\s\S]*?)\r?\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 {
// Support LaTeX blocks written as \[ ... \] by normalizing to $$ ... $$ (what KaTeX/marked extension expects).
// We apply this only outside fenced code blocks to avoid mangling JSON/code.
const normalizeLatexMathDelimiters = (input) => {
if (!input || typeof input !== 'string') return input;
// Split into "fenced code blocks" and "everything else".
// Fence pattern: ```lang?\n ... \n```
const fenceRe = /```[^\n]*\n[\s\S]*?\n```/g;
let out = '';
let lastIndex = 0;
let m;
const normalizeChunk = (chunk) => {
// Block math: \[ ... \] -> $$ ... $$
// Use non-greedy match across newlines.
let s = chunk.replace(/\\\[([\s\S]*?)\\\]/g, (_full, body) => `\n\n$$\n${body.trim()}\n$$\n\n`);
// Inline math: \( ... \) -> $ ... $ (optional but useful)
s = s.replace(/\\\(([\s\S]*?)\\\)/g, (_full, body) => `$${body.trim()}$`);
return s;
};
while ((m = fenceRe.exec(input)) !== null) {
const before = input.slice(lastIndex, m.index);
out += normalizeChunk(before);
out += m[0]; // keep fenced code blocks unchanged
lastIndex = m.index + m[0].length;
}
out += normalizeChunk(input.slice(lastIndex));
return out;
};
// Register KaTeX extension once (idempotent-ish in practice, but we guard to be safe).
// We avoid throwing on invalid LaTeX so markdown rendering never crashes the report.
if (!isKatexMarkedConfigured) {
marked.use(
markedKatex({
throwOnError: false,
strict: 'ignore'
})
);
isKatexMarkedConfigured = true;
}
// В marked v17 используется marked.parse() с опциями вторым параметром
const normalized = normalizeLatexMathDelimiters(markdown);
return marked.parse(normalized, { breaks: true, gfm: true });
} catch (error) {
console.error('Error rendering markdown:', error);
// В случае ошибки возвращаем исходный текст
return markdown;
}
};
/**
* Split markdown into block-level segments so charts/code blocks can be rendered strictly in-flow
* between the exact neighboring blocks (paragraphs, lists, headings, etc.).
*
* Important: we keep rendering via `renderMarkdown()` for compatibility, but use `marked.lexer()`
* to determine block boundaries and to mark token type for safer pairing logic.
*/
const pushMarkdownTokens = (segments, markdownChunk) => {
if (!markdownChunk || typeof markdownChunk !== 'string') return;
const trimmed = markdownChunk.trim();
if (!trimmed) return;
let tokens = [];
try {
tokens = marked.lexer(markdownChunk, { breaks: true, gfm: true }) || [];
} catch (e) {
// If lexer fails, fall back to a single segment (still in-flow, just less granular).
segments.push({ type: 'markdown', html: renderMarkdown(markdownChunk), mdTokenType: 'raw' });
return;
}
for (const token of tokens) {
if (!token || token.type === 'space') continue;
const raw = typeof token.raw === 'string' ? token.raw : '';
if (!raw.trim()) continue;
segments.push({
type: 'markdown',
html: renderMarkdown(raw),
mdTokenType: token.type
});
}
};
const buildSeasonalityChartData = (seasonality) => {
if (!seasonality || typeof seasonality !== 'object') return null;
// New backend format: Chart.js data shape { labels: [...], datasets: [...] }
if (isChartJsData(seasonality)) return normalizeChartJsData(seasonality, 'line');
// Chart.js-like (labels exist) but datasets missing/empty -> return safe shape so UI can show placeholder
if (Array.isArray(seasonality.labels)) {
return {
labels: seasonality.labels,
datasets: Array.isArray(seasonality.datasets) ? seasonality.datasets : []
};
}
// Legacy format: object map { "Январь": 80, ... }
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: getMarketingColors(funnel.length),
borderColor: '#ffffff',
borderWidth: 2,
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: getMarketingColors(ageValues.length),
borderColor: '#ffffff',
borderWidth: 2
}
]
};
// 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: getMarketingColors(segmentation.genders.length),
borderColor: '#ffffff',
borderWidth: 2
}
]
}
: 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;
return {
labels: marketShareChart.map((item) => item.name),
datasets: [
{
data: marketShareChart.map((item) => item.value),
backgroundColor: getMarketingColors(marketShareChart.length),
borderColor: '#ffffff',
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: getMarketingColors(competitors.length, metricType === 'reach' ? 0 : 1),
borderColor: '#ffffff',
borderWidth: 2,
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 || {}
}))
};
};
// --- ВСТАВИТЬ СЮДА ---
// Сборка данных для ДОХОДА (Фото 2)
const buildIncomeChartData = (targetAudience) => {
// Данные могут быть в snake_case или camelCase
const incomeData = targetAudience?.income_by_age || targetAudience?.incomeByAge;
if (!incomeData || typeof incomeData !== 'object') return null;
const labels = Object.keys(incomeData);
const values = Object.values(incomeData);
return {
labels: labels,
datasets: [
{
label: 'Средний доход (KZT)',
data: values,
backgroundColor: getMarketingColors(values.length),
borderRadius: 4
}
]
};
};
const buildChannelsChartData = (acquisitionChannels) => {
const shares = acquisitionChannels?.channel_shares || acquisitionChannels?.channelShares;
if (!shares || typeof shares !== 'object') return null;
// Превращаем ключи "instagram_target" в "Instagram Target"
const labels = Object.keys(shares).map(key =>
key.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
);
// Чистим значения от знака % для графика
const values = Object.values(shares).map(val =>
(typeof val === 'string') ? parseFloat(val.replace('%', '')) : val
);
return {
labels: labels,
datasets: [
{
label: 'Доля',
data: values,
unit: '%', // Маркер, чтобы рисовать %
backgroundColor: getMarketingColors(values.length, 2),
borderRadius: 6
}
]
};
};
// 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
};
// 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
// - For pie/doughnut/bar we default to per-item colors (arrays) + white borders
// - If backgroundColor is already an array or function, we keep it (custom colors)
const normalizeChartJsData = (raw, chartType) => {
if (!isChartJsData(raw)) return null;
const type = String(chartType || raw.chartType || '').toLowerCase();
const wantsPerItemColors = type === 'pie' || type === 'doughnut' || type === 'bar';
return {
labels: Array.isArray(raw.labels) ? raw.labels : [],
datasets: raw.datasets.map((dataset, idx) => {
const data = Array.isArray(dataset?.data) ? dataset.data : [];
const hasCustomBgArray = Array.isArray(dataset?.backgroundColor);
const hasCustomBgFn = typeof dataset?.backgroundColor === 'function';
const backgroundColor = wantsPerItemColors && !hasCustomBgArray && !hasCustomBgFn ? getMarketingColors(data.length, idx) : dataset?.backgroundColor;
// Keep existing borderColor if provided; otherwise apply white borders for separation on partitioned charts
const borderColor = dataset?.borderColor ?? (wantsPerItemColors ? '#ffffff' : undefined);
const borderWidth = dataset?.borderWidth ?? (wantsPerItemColors ? 2 : undefined);
// For non-partitioned charts, keep old behavior: if colors are missing, use a reasonable fallback
const fallbackColor = getMarketingColors(1, idx)[0];
const resolvedBg = typeof backgroundColor === 'undefined' ? dataset?.borderColor || fallbackColor : backgroundColor;
const resolvedBorder = typeof borderColor === 'undefined' ? dataset?.borderColor || resolvedBg : borderColor;
return {
...dataset,
data,
backgroundColor: resolvedBg,
borderColor: resolvedBorder,
...(typeof borderWidth !== 'undefined' ? { borderWidth } : {}),
borderRadius: dataset.borderRadius ?? 4
};
})
};
};
// Helper: check if chart has any renderable data (avoid crashing when datasets is missing/empty)
const hasRenderableChartData = (chartData) => {
if (!chartData || typeof chartData !== 'object') return false;
const datasets = Array.isArray(chartData.datasets) ? chartData.datasets : [];
if (datasets.length === 0) return false;
return datasets.some((ds) => Array.isArray(ds?.data) && ds.data.length > 0);
};
// Main function to map chartsData to segments
const mapChartsDataToSegments = (chartsData) => {
if (!chartsData || typeof chartsData !== 'object') return [];
const segments = [];
const sections = chartsData.sections || chartsData;
// --- 1. СПРОС И ПОВЕДЕНИЕ ---
const demandSection = sections.demand_and_behavior || sections.demandAndBehavior;
if (demandSection) {
// Причины отказов (Оранжевые, как на фото 1)
const refusals = demandSection.reasons_for_refusal || demandSection.reasonsForRefusal;
if (refusals) {
const { labels, values } = parsePercentageData(refusals);
segments.push({
type: 'chart',
key: 'refusalReasons',
title: 'Причины отказов',
chartType: 'bar',
data: {
labels,
datasets: [{
data: values,
backgroundColor: '#fb923c', // Оранжевый
borderRadius: 8,
barPercentage: 0.7,
unit: '%'
}]
},
options: photoStyleBarOptions, // <-- Стиль как на фото (цифры внутри)
chartHeight: 'height: 300px'
});
}
// Факторы принятия решения (Сиреневые, стиль фото 6)
const factors = demandSection.decision_factors || demandSection.decisionFactors;
if (factors) {
const { labels, values } = parsePercentageData(factors);
segments.push({
type: 'chart',
key: 'decisionFactors',
title: 'Факторы принятия решения',
chartType: 'bar',
data: {
labels,
datasets: [{
data: values,
backgroundColor: '#818cf8', // Сиреневый
borderRadius: 8,
barPercentage: 0.7,
unit: '%'
}]
},
options: photoStyleBarOptions,
chartHeight: 'height: 300px'
});
}
}
// --- 2. ЦЕЛЕВАЯ АУДИТОРИЯ ---
const audienceSection = sections.target_audience || sections.targetAudience;
if (audienceSection) {
// ВОЗРАСТНАЯ СТРУКТУРА (ФОТО 3 - СИНИЙ ЦВЕТ)
const ageStruct = audienceSection.age_structure || audienceSection.ageStructure;
if (ageStruct) {
const { labels, values } = parsePercentageData(ageStruct);
segments.push({
type: 'chart',
key: 'ageStructure',
title: 'Возрастная структура',
chartType: 'bar',
data: {
labels,
datasets: [{
data: values,
// !!! ЦВЕТ КАК НА ФОТО (СИНИЙ) !!!
backgroundColor: '#60a5fa',
borderRadius: 8, // Скругление
barPercentage: 0.65,
unit: '%'
}]
},
options: photoStyleBarOptions, // Белые цифры внутри
chartHeight: 'height: 300px'
});
}
// Доход (Фото 5 - Мятный цвет)
const incomeData = audienceSection.income_by_age || audienceSection.incomeByAge;
if (incomeData || audienceSection.metrics?.above_average_income_share) {
let labels = [], values = [];
if (incomeData) {
const parsed = parsePercentageData(incomeData);
labels = parsed.labels;
values = parsed.values;
} else {
labels = ['Низкий', 'Средний', 'Высокий'];
values = [20, 50, 30];
}
segments.push({
type: 'chart',
key: 'incomeLevel',
title: 'Уровень дохода аудитории',
chartType: 'bar',
data: {
labels,
datasets: [{
data: values,
backgroundColor: '#34d399', // Мятный
borderRadius: 8,
barPercentage: 0.65,
unit: '%'
}]
},
options: photoStyleBarOptions,
chartHeight: 'height: 300px'
});
}
}
// --- 3. КАНАЛЫ (Фото 4 - Разноцветные) ---
const acquisitionSection = sections.acquisition_channels || sections.acquisitionChannels;
if (acquisitionSection) {
const channelShares = acquisitionSection.channel_shares || acquisitionSection.channelShares;
if (channelShares) {
const { labels, values } = parsePercentageData(channelShares);
// Убираем нули
const filteredIndices = values.map((v, i) => v > 0 ? i : -1).filter(i => i !== -1);
const finalLabels = filteredIndices.map(i => labels[i]);
const finalValues = filteredIndices.map(i => values[i]);
if (finalValues.length > 0) {
segments.push({
type: 'chart',
key: 'acquisitionChannels',
title: 'Доли каналов привлечения',
chartType: 'bar',
data: {
labels: finalLabels,
datasets: [{
data: finalValues,
backgroundColor: getMarketingColors(finalValues.length), // Разноцветные
borderRadius: 8,
barPercentage: 0.6,
unit: '%'
}]
},
options: photoStyleBarOptions,
chartHeight: 'height: 300px'
});
}
}
}
// --- 4. СЕЗОННОСТЬ (Line) ---
const marketSection = sections.market_overview || sections.marketOverview;
if (marketSection) {
const seasonalData = marketSection.monthly_online_index || marketSection.monthlyOnlineIndex;
if (seasonalData) {
segments.push({
type: 'chart',
key: 'seasonality',
title: 'Динамика спроса',
chartType: 'line',
data: {
labels: Object.keys(seasonalData),
datasets: [{
label: 'Индекс',
data: Object.values(seasonalData),
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.2)',
fill: true,
tension: 0.4
}]
},
options: seasonalityChartOptions,
chartHeight: 'height: 350px'
});
}
}
// --- LEGACY (Старый код, просто чтобы не ломалось, если придет старый JSON) ---
if (Array.isArray(chartsData)) {
if (chartsData.length > 0 && chartsData[0]?.name) {
const reachData = chartsData.map(c => c.reach || 0);
return [{
type: 'chart',
title: 'Охват конкурентов',
chartType: 'bar',
data: { labels: chartsData.map(c => c.name), datasets: [{ data: reachData, backgroundColor: '#60a5fa' }] },
options: photoStyleBarOptions
}];
}
return [];
}
return segments;
};
const mapJsonBlockToSegment = (identifier, data, rawText) => {
const key = identifier?.toLowerCase();
switch (key) {
case 'audienceage': {
const chartData = normalizeChartJsData(data, 'bar');
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, 'pie');
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, 'line') : 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, 'bar') : 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, 'bar') : 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, 'pie') : 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 || []
}
};
}
case 'segmentschannelmatrix': {
const matrix = buildSegmentsChannelMatrix(data);
if (!matrix || !Array.isArray(matrix.data) || matrix.data.length === 0) return null;
return {
type: 'segmentsChannelMatrix',
title: 'Матрица каналов по сегментам',
matrix
};
}
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, 'bar');
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, 'pie');
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, 'pie');
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) {
const chartData = isChartJsData(chartsData.channelsPotential) ? normalizeChartJsData(chartsData.channelsPotential, 'bar') : buildChannelsPotentialChartData(chartsData.channelsPotential);
if (chartData) {
return {
type: 'chart',
key: 'channelsPotential',
title: 'Потенциал каналов продвижения',
chartType: 'bar',
data: chartData,
options: channelsPotentialChartOptions,
chartHeight: 'height: 300px',
channels: Array.isArray(chartsData.channelsPotential) ? chartsData.channelsPotential : []
};
}
}
return null;
case 'funnel':
case 'conversionfunnel':
if (chartsData.conversionFunnel) {
const chartData = isChartJsData(chartsData.conversionFunnel) ? normalizeChartJsData(chartsData.conversionFunnel, 'bar') : 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(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/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)) {
// 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' || chartType === 'doughnut' ? pieChartOptions : chartType === 'line' ? seasonalityChartOptions : competitorsBarChartOptions;
// Normalize chart data (colors/borders, safety)
const chartData = normalizeChartJsData(parsedJson, chartType);
if (!chartData) return null;
// 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+)\r?\n([\s\S]*?)\r?\n```|```json\r?\n([\s\S]*?)\r?\n```|```\r?\n([\s\S]*?)\r?\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()) {
// IMPORTANT: keep markdown strictly in-flow.
// JSON/Chart blocks are handled by the main regex loop and must appear exactly where they are in fullAnalysis.
pushMarkdownTokens(segments, textBefore);
}
}
// 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), mdTokenType: 'code' });
}
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()) {
// Keep tail markdown in-flow; chart blocks already handled by regex matches above.
pushMarkdownTokens(segments, remaining);
}
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+)\r?\n([\s\S]*?)\r?\n```|```json\r?\n([\s\S]*?)\r?\n```|```\r?\n([\s\S]*?)\r?\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()) {
// Keep text strictly in place; JSON blocks are handled by the regex loop.
pushMarkdownTokens(segments, textBefore);
}
}
// 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), mdTokenType: 'code' });
}
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()) {
pushMarkdownTokens(segments, remaining);
}
}
// 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' && segment?.mdTokenType === 'paragraph';
};
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)) {
// Pull subsequent paragraph tokens into the same left column so text can continue
// alongside a tall right-column chart/table (no float, still strictly in-flow).
const texts = [cur];
let j = i + 2;
while (j < input.length && isParagraphMarkdownSegment(input[j])) {
texts.push(input[j]);
j++;
}
out.push({
type: 'paired',
texts,
media: next,
order: 'text-media'
});
i = j - 1; // consumed media + following paragraph(s)
continue;
}
// Media then text
if (isPairableMediaSegment(cur) && isParagraphMarkdownSegment(next)) {
// IMPORTANT: do NOT pull many paragraphs when media is on the left.
// Otherwise right column grows tall and leaves a visible blank area under left media (table/chart).
const texts = [next];
const j = i + 2;
out.push({
type: 'paired',
texts,
media: cur,
order: 'media-text'
});
i = j - 1; // consumed media + single adjacent paragraph
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: 'bottom',
align: 'center',
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;
};
// 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;
const queryV2AnalysisId = route.query.v2AnalysisId;
if (queryAnalysisId) {
// Try to load existing analysis
try {
analysisId.value = queryAnalysisId;
const result = await MarketingService.getAnalysisResult(queryAnalysisId);
const isV2 = isV2ReportPayload(result);
if (isV2) {
applyV2Report(result);
status.value = 'completed';
} else if (result) {
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);
}
// v2 restore:
// 1) from query param
// 2) fallback to /analysis/my lookup (docs say v2AnalysisId is present there)
if (queryV2AnalysisId) {
v2AnalysisId.value = String(queryV2AnalysisId);
await refreshV2();
} else {
try {
const list = await MarketingService.getMyAnalyses();
const found = Array.isArray(list) ? list.find((a) => a?.analysisId === queryAnalysisId) : null;
if (found?.v2AnalysisId) {
v2AnalysisId.value = found.v2AnalysisId;
router.replace({ query: { ...route.query, v2AnalysisId: v2AnalysisId.value } });
await refreshV2();
}
} catch (_) {
// ignore v2 restore errors; old analysis page must keep working
}
}
}
} catch (error) {
console.error('Ошибка при загрузке анализа:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось загрузить анализ',
life: 5000
});
}
}
});
// Cleanup on unmount
onBeforeUnmount(() => {
stopPolling();
});
</script>
<style scoped>
.v2-glow :deep(canvas) {
filter: drop-shadow(0 0 14px rgba(59, 130, 246, 0.22));
}
.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 {
/* Keep everything strictly in normal flow; no float/absolute positioning */
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 40%);
gap: 1.5rem;
align-items: start;
}
.report-pair__text,
.report-pair__media {
min-width: 0;
}
.report-pair__textStack {
display: flex;
flex-direction: column;
gap: 1rem;
}
.report-pair__media {
width: 100%;
align-self: start;
}
.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;
}
/* 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;
}
.chart-caption {
margin-top: 0.5rem;
text-align: center;
font-size: 0.95rem;
font-weight: 600;
line-height: 1.25rem;
}
@media (max-width: 768px) {
.report-pair {
grid-template-columns: 1fr;
}
}
.chart-container {
position: relative;
}
/* 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>